refactor(ax): standardise display and window contracts
Some checks failed
Security Scan / security (push) Failing after 31s
Test / test (push) Successful in 1m14s

This commit is contained in:
Virgil 2026-03-31 06:22:04 +00:00
parent 552b0751b9
commit 7deb91ac0b
14 changed files with 258 additions and 246 deletions

View file

@ -3,8 +3,8 @@ package contextmenu
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
@ -84,7 +84,7 @@ func (s *Service) taskAdd(t TaskAdd) error {
})
})
if err != nil {
return fmt.Errorf("contextmenu: platform add failed: %w", err)
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
}
s.registeredMenus[t.Name] = t.Menu
@ -98,7 +98,7 @@ func (s *Service) taskRemove(t TaskRemove) error {
err := s.platform.Remove(t.Name)
if err != nil {
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
}
delete(s.registeredMenus, t.Name)

View file

@ -1,43 +1,30 @@
# Display
This repository is a display module for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration.
`pkg/display` is the Core GUI display service. It owns window orchestration, layouts, menus, system tray state, dialogs, notifications, and the IPC bridge to the frontend.
## Getting Started
## Working Locally
1. **Clone the repository:**
```bash
git clone https://github.com/Snider/display.git
```
1. Run the backend tests:
```bash
go test ./pkg/display/...
```
2. Run the full workspace tests when you touch IPC contracts:
```bash
go test ./...
```
3. Build the Angular frontend:
```bash
cd ui
npm install
npm run build
```
2. **Install the dependencies:**
```bash
cd display
go mod tidy
cd ui
npm install
```
## Declarative Windows
3. **Run the development server:**
```bash
go run ./cmd/demo-cli serve
```
This will start the Go backend and serve the Angular custom element.
Windows are created from a `window.Window` spec instead of a fluent option chain:
## Building the Custom Element
To build the Angular custom element, run the following command:
```bash
cd ui
npm run build
```go
svc.OpenWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})
```
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.
The same spec is used by `CreateWindow`, layout restore, tiling, snapping, and workflow presets.

View file

@ -578,11 +578,22 @@ func (s *Service) persistSection(key string, value map[string]any) {
// --- Service accessors ---
func (s *Service) performWindowTask(operation string, task core.Task) (any, error) {
result, handled, err := s.Core().PERFORM(task)
if err != nil {
return nil, err
}
if !handled {
return nil, coreerr.E(operation, "window service not available", nil)
}
return result, nil
}
// --- Window Management (delegates via IPC) ---
// OpenWindow creates a new window from a declarative Window spec.
func (s *Service) OpenWindow(spec window.Window) error {
_, _, err := s.Core().PERFORM(window.TaskOpenWindow{Window: spec})
_, err := s.performWindowTask("display.OpenWindow", window.TaskOpenWindow{Window: spec})
return err
}
@ -603,7 +614,7 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) {
func (s *Service) ListWindowInfos() []window.WindowInfo {
result, handled, _ := s.Core().QUERY(window.QueryWindowList{})
if !handled {
return nil
return []window.WindowInfo{}
}
list, _ := result.([]window.WindowInfo)
return list
@ -611,82 +622,82 @@ func (s *Service) ListWindowInfos() []window.WindowInfo {
// SetWindowPosition moves a window via IPC.
func (s *Service) SetWindowPosition(name string, x, y int) error {
_, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y})
_, err := s.performWindowTask("display.SetWindowPosition", window.TaskSetPosition{Name: name, X: x, Y: y})
return err
}
// SetWindowSize resizes a window via IPC.
func (s *Service) SetWindowSize(name string, width, height int) error {
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
_, err := s.performWindowTask("display.SetWindowSize", window.TaskSetSize{Name: name, Width: width, Height: height})
return err
}
// SetWindowBounds sets both position and size of a window via IPC.
func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
if _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil {
if _, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil {
return err
}
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
_, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetSize{Name: name, Width: width, Height: height})
return err
}
// MaximizeWindow maximizes a window via IPC.
func (s *Service) MaximizeWindow(name string) error {
_, _, err := s.Core().PERFORM(window.TaskMaximise{Name: name})
_, err := s.performWindowTask("display.MaximizeWindow", window.TaskMaximise{Name: name})
return err
}
// MinimizeWindow minimizes a window via IPC.
func (s *Service) MinimizeWindow(name string) error {
_, _, err := s.Core().PERFORM(window.TaskMinimise{Name: name})
_, err := s.performWindowTask("display.MinimizeWindow", window.TaskMinimise{Name: name})
return err
}
// FocusWindow brings a window to the front via IPC.
func (s *Service) FocusWindow(name string) error {
_, _, err := s.Core().PERFORM(window.TaskFocus{Name: name})
_, err := s.performWindowTask("display.FocusWindow", window.TaskFocus{Name: name})
return err
}
// CloseWindow closes a window via IPC.
func (s *Service) CloseWindow(name string) error {
_, _, err := s.Core().PERFORM(window.TaskCloseWindow{Name: name})
_, err := s.performWindowTask("display.CloseWindow", window.TaskCloseWindow{Name: name})
return err
}
// RestoreWindow restores a maximized/minimized window.
func (s *Service) RestoreWindow(name string) error {
_, _, err := s.Core().PERFORM(window.TaskRestore{Name: name})
_, err := s.performWindowTask("display.RestoreWindow", window.TaskRestore{Name: name})
return err
}
// SetWindowVisibility shows or hides a window.
func (s *Service) SetWindowVisibility(name string, visible bool) error {
_, _, err := s.Core().PERFORM(window.TaskSetVisibility{Name: name, Visible: visible})
_, err := s.performWindowTask("display.SetWindowVisibility", window.TaskSetVisibility{Name: name, Visible: visible})
return err
}
// SetWindowAlwaysOnTop sets whether a window stays on top.
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
_, _, err := s.Core().PERFORM(window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop})
_, err := s.performWindowTask("display.SetWindowAlwaysOnTop", window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop})
return err
}
// SetWindowTitle changes a window's title.
func (s *Service) SetWindowTitle(name string, title string) error {
_, _, err := s.Core().PERFORM(window.TaskSetTitle{Name: name, Title: title})
_, err := s.performWindowTask("display.SetWindowTitle", window.TaskSetTitle{Name: name, Title: title})
return err
}
// SetWindowFullscreen sets a window to fullscreen mode.
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
_, _, err := s.Core().PERFORM(window.TaskFullscreen{Name: name, Fullscreen: fullscreen})
_, err := s.performWindowTask("display.SetWindowFullscreen", window.TaskFullscreen{Name: name, Fullscreen: fullscreen})
return err
}
// SetWindowBackgroundColour sets the background colour of a window.
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
_, _, err := s.Core().PERFORM(window.TaskSetBackgroundColour{
_, err := s.performWindowTask("display.SetWindowBackgroundColour", window.TaskSetBackgroundColour{
Name: name, Red: r, Green: g, Blue: b, Alpha: a,
})
return err
@ -717,19 +728,19 @@ func (s *Service) GetWindowTitle(name string) (string, error) {
// ResetWindowState clears saved window positions.
func (s *Service) ResetWindowState() error {
_, _, _ = s.Core().PERFORM(window.TaskResetWindowState{})
return nil
_, err := s.performWindowTask("display.ResetWindowState", window.TaskResetWindowState{})
return err
}
// GetSavedWindowStates returns all saved window states.
func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
result, handled, _ := s.Core().QUERY(window.QuerySavedWindowStates{})
if !handled {
return nil
return map[string]window.WindowState{}
}
saved, _ := result.(map[string]window.WindowState)
if saved == nil {
return nil
return map[string]window.WindowState{}
}
out := make(map[string]window.WindowState, len(saved))
for name, state := range saved {
@ -743,13 +754,16 @@ func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) {
if spec.Name == "" {
return nil, coreerr.E("display.CreateWindow", "window name is required", nil)
}
result, _, err := s.Core().PERFORM(window.TaskOpenWindow{
result, err := s.performWindowTask("display.CreateWindow", window.TaskOpenWindow{
Window: spec,
})
if err != nil {
return nil, err
}
info := result.(window.WindowInfo)
info, ok := result.(window.WindowInfo)
if !ok {
return nil, coreerr.E("display.CreateWindow", "unexpected result type from window create task", nil)
}
return &info, nil
}
@ -757,13 +771,13 @@ func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) {
// SaveLayout saves the current window arrangement as a named layout.
func (s *Service) SaveLayout(name string) error {
_, _, err := s.Core().PERFORM(window.TaskSaveLayout{Name: name})
_, err := s.performWindowTask("display.SaveLayout", window.TaskSaveLayout{Name: name})
return err
}
// RestoreLayout applies a saved layout.
func (s *Service) RestoreLayout(name string) error {
_, _, err := s.Core().PERFORM(window.TaskRestoreLayout{Name: name})
_, err := s.performWindowTask("display.RestoreLayout", window.TaskRestoreLayout{Name: name})
return err
}
@ -771,7 +785,7 @@ func (s *Service) RestoreLayout(name string) error {
func (s *Service) ListLayouts() []window.LayoutInfo {
result, handled, _ := s.Core().QUERY(window.QueryLayoutList{})
if !handled {
return nil
return []window.LayoutInfo{}
}
layouts, _ := result.([]window.LayoutInfo)
return layouts
@ -779,7 +793,7 @@ func (s *Service) ListLayouts() []window.LayoutInfo {
// DeleteLayout removes a saved layout by name.
func (s *Service) DeleteLayout(name string) error {
_, _, err := s.Core().PERFORM(window.TaskDeleteLayout{Name: name})
_, err := s.performWindowTask("display.DeleteLayout", window.TaskDeleteLayout{Name: name})
return err
}
@ -797,25 +811,25 @@ func (s *Service) GetLayout(name string) *window.Layout {
// TileWindows arranges windows in a tiled layout.
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
_, _, err := s.Core().PERFORM(window.TaskTileWindows{Mode: mode.String(), Windows: windowNames})
_, err := s.performWindowTask("display.TileWindows", window.TaskTileWindows{Mode: mode.String(), Windows: windowNames})
return err
}
// SnapWindow snaps a window to a screen edge or corner.
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
_, _, err := s.Core().PERFORM(window.TaskSnapWindow{Name: name, Position: position.String()})
_, err := s.performWindowTask("display.SnapWindow", window.TaskSnapWindow{Name: name, Position: position.String()})
return err
}
// StackWindows arranges windows in a cascade pattern.
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
_, _, err := s.Core().PERFORM(window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY})
_, err := s.performWindowTask("display.StackWindows", window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY})
return err
}
// ApplyWorkflowLayout applies a predefined layout for a specific workflow.
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
_, _, err := s.Core().PERFORM(window.TaskApplyWorkflow{
_, err := s.performWindowTask("display.ApplyWorkflowLayout", window.TaskApplyWorkflow{
Workflow: workflow.String(),
})
return err
@ -878,15 +892,15 @@ func (s *Service) handleNewWorkspace() {
}
func (s *Service) handleListWorkspaces() {
ws := s.Core().Service("workspace")
if ws == nil {
workspaceService := s.Core().Service("workspace")
if workspaceService == nil {
return
}
lister, ok := ws.(interface{ ListWorkspaces() []string })
workspaceLister, ok := workspaceService.(interface{ ListWorkspaces() []string })
if !ok {
return
}
_ = lister.ListWorkspaces()
_ = workspaceLister.ListWorkspaces()
}
func (s *Service) handleNewFile() {

View file

@ -389,6 +389,14 @@ func TestCreateWindow_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "window name is required")
}
func TestCreateWindow_Bad_NoWindowService(t *testing.T) {
svc, _ := newTestDisplayService(t)
_, err := svc.CreateWindow(window.Window{Name: "orphan-window"})
require.Error(t, err)
assert.Contains(t, err.Error(), "window service not available")
}
func TestResetWindowState_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")

View file

@ -1,6 +1,6 @@
# Backend Documentation
The backend is written in Go and uses the `github.com/Snider/display` package. It utilizes the Wails v3 framework to bridge Go and the web frontend.
The backend is written in Go and uses the `forge.lthn.ai/core/gui/pkg/display` package. It uses Wails v3 to bridge Go and the web frontend.
## Core Types
@ -9,46 +9,53 @@ The `Service` struct is the main entry point for the display logic.
- **Initialization:**
- `New() (*Service, error)`: Creates a new instance of the service.
- `Startup(ctx context.Context) error`: Initializes the Wails application, builds the menu, sets up the system tray, and opens the main window.
- `Register(wailsApp *application.App) func(*core.Core) (any, error)`: Captures the Wails app and registers the service with Core.
- `OnStartup(ctx context.Context) error`: Loads config and registers IPC handlers.
- **Window Management:**
- `OpenWindow(opts ...WindowOption) error`: Opens a new window with the specified options.
- `OpenWindow(spec window.Window) error`: Opens a new window from a declarative spec.
- `CreateWindow(spec window.Window) (*window.WindowInfo, error)`: Opens a window and returns its info.
- `GetWindowInfo(name string) (*window.WindowInfo, error)`: Queries a single window.
- `ListWindowInfos() []window.WindowInfo`: Queries all tracked windows.
- `SetWindowPosition(name string, x, y int) error`
- `SetWindowSize(name string, width, height int) error`
- `SetWindowBounds(name string, x, y, width, height int) error`
- `MaximizeWindow(name string) error`
- `MinimizeWindow(name string) error`
- `RestoreWindow(name string) error`
- `FocusWindow(name string) error`
- `SetWindowFullscreen(name string, fullscreen bool) error`
- `SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error`
- `SetWindowVisibility(name string, visible bool) error`
- `SetWindowTitle(name, title string) error`
- `SetWindowBackgroundColour(name string, r, g, b, a uint8) error`
- `GetFocusedWindow() string`
- `ResetWindowState() error`
- `GetSavedWindowStates() map[string]window.WindowState`
- **Dialogs:**
- `ShowEnvironmentDialog()`: Displays a native dialog containing information about the runtime environment (OS, Arch, Debug mode, etc.).
Example:
### `WindowConfig` & `WindowOption`
Window configuration is handled using the Functional Options pattern. The `WindowConfig` struct holds parameters like:
- `Name`, `Title`
- `Width`, `Height`
- `URL`
- `AlwaysOnTop`, `Hidden`, `Frameless`
- Window button states (`MinimiseButtonState`, `MaximiseButtonState`, `CloseButtonState`)
**Available Options:**
- `WithName(name string)`
- `WithTitle(title string)`
- `WithWidth(width int)`
- `WithHeight(height int)`
- `WithURL(url string)`
- `WithAlwaysOnTop(bool)`
- `WithHidden(bool)`
- `WithFrameless(bool)`
- `WithMinimiseButtonState(state)`
- `WithMaximiseButtonState(state)`
- `WithCloseButtonState(state)`
```go
svc.OpenWindow(window.Window{
Name: "editor",
Title: "Editor",
URL: "/#/editor",
Width: 1200,
Height: 800,
})
```
## Subsystems
### Menu (`menu.go`)
The `buildMenu` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It allows for platform-specific adjustments (e.g., AppMenu on macOS).
The `buildMenu()` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It also dispatches the app-specific developer actions used by the frontend.
### System Tray (`tray.go`)
The `systemTray` method initializes the system tray icon and its context menu. It supports:
The `setupTray()` method initializes the system tray icon and its context menu. It supports:
- Showing/Hiding all windows.
- Displaying environment info.
- Quitting the application.
- Attaching a hidden window for advanced tray interactions.
- Attaching tray actions that are broadcast as IPC events.
### Actions (`actions.go`)
Defines structured messages for Inter-Process Communication (IPC) or internal event handling, such as `ActionOpenWindow` which wraps `application.WebviewWindowOptions`.
### Actions (`messages.go`)
Defines structured messages for Inter-Process Communication (IPC) and internal event handling, including `window.ActionWindowOpened`, `window.ActionWindowClosed`, and `display.ActionIDECommand`.

View file

@ -1,6 +1,6 @@
# Development Guide
This guide covers how to set up the development environment, build the project, and run the demo.
This guide covers how to set up the development environment, build the project, and run the tests.
## Prerequisites
@ -12,11 +12,7 @@ This guide covers how to set up the development environment, build the project,
## Setup
1. Clone the repository:
```bash
git clone https://github.com/Snider/display.git
cd display
```
1. Clone the repository and enter the workspace.
2. Install Go dependencies:
```bash
@ -30,23 +26,6 @@ This guide covers how to set up the development environment, build the project,
cd ..
```
## Running the Demo
The project includes a CLI to facilitate development.
### Serve Mode (Web Preview)
To start a simple HTTP server that serves the frontend and a mock API:
1. Build the frontend first:
```bash
cd ui && npm run build && cd ..
```
2. Run the serve command:
```bash
go run ./cmd/demo-cli serve
```
Access the app at `http://localhost:8080`.
## Building the Project
### Frontend
@ -56,9 +35,9 @@ npm run build
```
### Backend / Application
This project is a library/module. However, it can be tested via the demo CLI or by integrating it into a Wails application entry point.
This package is exercised through Go tests and the host application that embeds it.
To run the tests:
```bash
go test ./...
go test ./pkg/display/...
```

View file

@ -1,25 +1,24 @@
# Overview
The `display` module is a core component responsible for the visual presentation and system integration of the application. It leverages **Wails v3** to create a desktop application backend in Go and **Angular** for the frontend user interface.
The `display` module is the Core GUI surface. It coordinates windows, menus, trays, dialogs, notifications, and WebSocket events through **Wails v3** on the Go side and **Angular** in `ui/` on the frontend side.
## Architecture
The project consists of two main parts:
1. **Backend (Go):** Handles window management, system tray integration, application menus, and communication with the frontend. It is located in the root directory and packaged as a Go module.
2. **Frontend (Angular):** Provides the user interface. It is located in the `ui/` directory and is built as a custom element that interacts with the backend.
1. **Backend (Go):** Handles window management, tray/menu setup, dialogs, notifications, layout persistence, and IPC dispatch.
2. **Frontend (Angular):** Provides the user interface. It lives in `ui/` and is built as a custom element that talks to the backend through the Wails runtime.
## Key Components
### Display Service (`display`)
The core service that manages the application lifecycle. It wraps the Wails application instance and exposes methods to:
- Open and configure windows.
- Manage the system tray.
- Show system dialogs (e.g., environment info).
The core service manages the application lifecycle and exposes declarative operations such as:
- `OpenWindow(window.Window{Name: "editor", URL: "/#/editor"})`
- `SaveLayout("coding")`
- `TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})`
- `ApplyWorkflowLayout(window.WorkflowCoding)`
### System Integration
- **Menu:** A standard application menu (File, Edit, View, etc.) is constructed in `menu.go`.
- **System Tray:** A system tray icon and menu are configured in `tray.go`, allowing quick access to common actions like showing/hiding windows or viewing environment info.
### Demo CLI
A command-line interface (`cmd/demo-cli`) is provided to run and test the display module. It includes a `serve` command for web-based development.
- **Menu:** The application menu is constructed in `buildMenu()` and dispatched through IPC.
- **System Tray:** The tray menu is configured in `setupTray()` and keeps the desktop surface in sync with the runtime.
- **Events:** Window, theme, screen, lifecycle, and tray actions are broadcast as WebSocket events for the frontend.

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/notification"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -46,7 +46,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call
}
granted, ok := result.(bool)
if !ok {
return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request")
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type from notification permission request", nil)
}
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
}
@ -65,7 +65,7 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
}
status, ok := result.(notification.PermissionStatus)
if !ok {
return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check")
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type from notification permission check", nil)
}
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type from window list query", nil)
}
return nil, WindowListOutput{Windows: windows}, nil
}
@ -44,7 +44,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W
}
info, ok := result.(*window.WindowInfo)
if !ok {
return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query")
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type from window get query", nil)
}
return nil, WindowGetOutput{Window: info}, nil
}
@ -63,7 +63,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type from window list query", nil)
}
for _, w := range windows {
if w.Focused {
@ -88,7 +88,7 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu
}
info, ok := result.(window.WindowInfo)
if !ok {
return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task")
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type from window create task", nil)
}
return nil, WindowCreateOutput{Window: info}, nil
}

View file

@ -1,12 +1,12 @@
// pkg/systray/menu.go
package systray
import "fmt"
import coreerr "forge.lthn.ai/core/go-log"
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
func (m *Manager) SetMenu(items []TrayMenuItem) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.Manager.SetMenu", "tray not initialised", nil)
}
menu := m.platform.NewMenu()
m.buildMenu(menu, items)

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
@ -133,6 +134,9 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo {
CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt,
})
}
sort.Slice(infos, func(i, j int) bool {
return infos[i].Name < infos[j].Name
})
return infos
}

View file

@ -2,8 +2,8 @@ package window
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
)
@ -49,6 +49,14 @@ func (s *Service) applyConfig(configData map[string]any) {
}
}
func (s *Service) requireWindow(name string, operation string) (PlatformWindow, error) {
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil, coreerr.E(operation, "window not found: "+name, nil)
}
return platformWindow, nil
}
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -78,13 +86,13 @@ func (s *Service) queryWindowList() []WindowInfo {
names := s.manager.List()
result := make([]WindowInfo, 0, len(names))
for _, name := range names {
if pw, ok := s.manager.Get(name); ok {
x, y := pw.Position()
w, h := pw.Size()
if platformWindow, ok := s.manager.Get(name); ok {
x, y := platformWindow.Position()
width, height := platformWindow.Size()
result = append(result, WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height,
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
})
}
}
@ -92,16 +100,16 @@ func (s *Service) queryWindowList() []WindowInfo {
}
func (s *Service) queryWindowByName(name string) *WindowInfo {
pw, ok := s.manager.Get(name)
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil
}
x, y := pw.Position()
w, h := pw.Size()
x, y := platformWindow.Position()
width, height := platformWindow.Size()
return &WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height,
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
}
}
@ -203,55 +211,55 @@ func (s *Service) primaryScreenArea() (int, int, int, int) {
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
pw, err := s.manager.Create(t.Window)
platformWindow, err := s.manager.Create(t.Window)
if err != nil {
return nil, true, err
}
x, y := pw.Position()
w, h := pw.Size()
info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h}
x, y := platformWindow.Position()
width, height := platformWindow.Size()
info := WindowInfo{Name: platformWindow.Name(), Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(pw)
s.trackWindow(platformWindow)
// Broadcast to all listeners
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
_ = s.Core().ACTION(ActionWindowOpened{Name: platformWindow.Name()})
return info, true, nil
}
// trackWindow attaches platform event listeners that emit IPC actions.
func (s *Service) trackWindow(pw PlatformWindow) {
pw.OnWindowEvent(func(e WindowEvent) {
switch e.Type {
func (s *Service) trackWindow(platformWindow PlatformWindow) {
platformWindow.OnWindowEvent(func(event WindowEvent) {
switch event.Type {
case "focus":
_ = s.Core().ACTION(ActionWindowFocused{Name: e.Name})
_ = s.Core().ACTION(ActionWindowFocused{Name: event.Name})
case "blur":
_ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name})
_ = s.Core().ACTION(ActionWindowBlurred{Name: event.Name})
case "move":
if data := e.Data; data != nil {
if data := event.Data; data != nil {
x, _ := data["x"].(int)
y, _ := data["y"].(int)
_ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y})
_ = s.Core().ACTION(ActionWindowMoved{Name: event.Name, X: x, Y: y})
}
case "resize":
if data := e.Data; data != nil {
w, _ := data["width"].(int)
if w == 0 {
w, _ = data["w"].(int)
if data := event.Data; data != nil {
width, _ := data["width"].(int)
if width == 0 {
width, _ = data["w"].(int)
}
h, _ := data["height"].(int)
if h == 0 {
h, _ = data["h"].(int)
height, _ := data["height"].(int)
if height == 0 {
height, _ = data["h"].(int)
}
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
_ = s.Core().ACTION(ActionWindowResized{Name: event.Name, Width: width, Height: height})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
_ = s.Core().ACTION(ActionWindowClosed{Name: event.Name})
}
})
pw.OnFileDrop(func(paths []string, targetID string) {
platformWindow.OnFileDrop(func(paths []string, targetID string) {
_ = s.Core().ACTION(ActionFilesDropped{
Name: pw.Name(),
Name: platformWindow.Name(),
Paths: paths,
TargetID: targetID,
})
@ -259,121 +267,121 @@ func (s *Service) trackWindow(pw PlatformWindow) {
}
func (s *Service) taskCloseWindow(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskCloseWindow")
if err != nil {
return err
}
// Persist state BEFORE closing (spec requirement)
s.manager.State().CaptureState(pw)
pw.Close()
s.manager.State().CaptureState(platformWindow)
platformWindow.Close()
s.manager.Remove(name)
_ = s.Core().ACTION(ActionWindowClosed{Name: name})
return nil
}
func (s *Service) taskSetPosition(name string, x, y int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetPosition")
if err != nil {
return err
}
pw.SetPosition(x, y)
platformWindow.SetPosition(x, y)
s.manager.State().UpdatePosition(name, x, y)
return nil
}
func (s *Service) taskSetSize(name string, width, height int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize")
if err != nil {
return err
}
pw.SetSize(width, height)
platformWindow.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
func (s *Service) taskMaximise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskMaximise")
if err != nil {
return err
}
pw.Maximise()
platformWindow.Maximise()
s.manager.State().UpdateMaximized(name, true)
return nil
}
func (s *Service) taskMinimise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskMinimise")
if err != nil {
return err
}
pw.Minimise()
platformWindow.Minimise()
return nil
}
func (s *Service) taskFocus(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskFocus")
if err != nil {
return err
}
pw.Focus()
platformWindow.Focus()
return nil
}
func (s *Service) taskRestore(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskRestore")
if err != nil {
return err
}
pw.Restore()
platformWindow.Restore()
s.manager.State().UpdateMaximized(name, false)
return nil
}
func (s *Service) taskSetTitle(name, title string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetTitle")
if err != nil {
return err
}
pw.SetTitle(title)
platformWindow.SetTitle(title)
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetAlwaysOnTop")
if err != nil {
return err
}
pw.SetAlwaysOnTop(alwaysOnTop)
platformWindow.SetAlwaysOnTop(alwaysOnTop)
return nil
}
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBackgroundColour")
if err != nil {
return err
}
pw.SetBackgroundColour(red, green, blue, alpha)
platformWindow.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetVisibility")
if err != nil {
return err
}
pw.SetVisibility(visible)
platformWindow.SetVisibility(visible)
return nil
}
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskFullscreen")
if err != nil {
return err
}
if fullscreen {
pw.Fullscreen()
platformWindow.Fullscreen()
} else {
pw.UnFullscreen()
platformWindow.UnFullscreen()
}
return nil
}
@ -393,21 +401,21 @@ func (s *Service) taskSaveLayout(name string) error {
func (s *Service) taskRestoreLayout(name string) error {
layout, ok := s.manager.Layout().GetLayout(name)
if !ok {
return fmt.Errorf("layout not found: %s", name)
return coreerr.E("window.Service.taskRestoreLayout", "layout not found: "+name, nil)
}
for winName, state := range layout.Windows {
pw, found := s.manager.Get(winName)
platformWindow, found := s.manager.Get(winName)
if !found {
continue
}
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
platformWindow.SetPosition(state.X, state.Y)
platformWindow.SetSize(state.Width, state.Height)
if state.Maximized {
pw.Maximise()
platformWindow.Maximise()
} else {
pw.Restore()
platformWindow.Restore()
}
s.manager.State().CaptureState(pw)
s.manager.State().CaptureState(platformWindow)
}
return nil
}
@ -423,7 +431,7 @@ var tileModeMap = map[string]TileMode{
func (s *Service) taskTileWindows(mode string, names []string) error {
tm, ok := tileModeMap[mode]
if !ok {
return fmt.Errorf("unknown tile mode: %s", mode)
return coreerr.E("window.Service.taskTileWindows", "unknown tile mode: "+mode, nil)
}
if len(names) == 0 {
names = s.manager.List()
@ -451,7 +459,7 @@ var snapPosMap = map[string]SnapPosition{
func (s *Service) taskSnapWindow(name, position string) error {
pos, ok := snapPosMap[position]
if !ok {
return fmt.Errorf("unknown snap position: %s", position)
return coreerr.E("window.Service.taskSnapWindow", "unknown snap position: "+position, nil)
}
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
@ -467,7 +475,7 @@ var workflowLayoutMap = map[string]WorkflowLayout{
func (s *Service) taskApplyWorkflow(workflow string, names []string) error {
layout, ok := workflowLayoutMap[workflow]
if !ok {
return fmt.Errorf("unknown workflow layout: %s", workflow)
return coreerr.E("window.Service.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
}
if len(names) == 0 {
names = s.manager.List()

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
@ -208,6 +209,7 @@ func (sm *StateManager) ListStates() []string {
for name := range sm.states {
names = append(names, name)
}
sort.Strings(names)
return names
}

View file

@ -1,7 +1,10 @@
// pkg/window/window.go
package window
import "sync"
import (
"sort"
"sync"
)
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
type Window struct {
@ -142,6 +145,7 @@ func (m *Manager) List() []string {
for name := range m.windows {
names = append(names, name)
}
sort.Strings(names)
return names
}