docs: remove completed plan/spec files
All 16 plan and spec files from the 2026-03-13 GUI restructuring are implemented. Remove them to keep the repo clean. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
ec492658c2
commit
73676c9d88
16 changed files with 0 additions and 14557 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,312 +0,0 @@
|
|||
# GUI Config Wiring — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the in-memory `loadConfig()` stub with real `.core/gui/config.yaml` loading via go-config, and wire `handleConfigTask` to persist changes to disk.
|
||||
|
||||
**Architecture:** Display orchestrator owns a `*config.Config` instance pointed at `~/.core/gui/config.yaml`. On startup, it loads file contents into the existing `configData` map. On `TaskSaveConfig`, it calls `cfg.Set()` + `cfg.Commit()` to persist. Sub-services remain unchanged — they already QUERY for their section and receive `map[string]any`.
|
||||
|
||||
**Tech Stack:** Go 1.26, `forge.lthn.ai/core/go` v0.2.2 (DI/IPC), `forge.lthn.ai/core/go-config` (Viper-backed YAML config), testify (assert/require)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-13-gui-config-wiring-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `go.mod` | Add `forge.lthn.ai/core/go-config` dependency |
|
||||
| `pkg/display/display.go` | Add `cfg *config.Config` field, replace `loadConfig()` stub, update `handleConfigTask` to persist via `cfg.Set()` + `cfg.Commit()`, add `guiConfigPath()` helper |
|
||||
| `pkg/display/display_test.go` | Add config loading + persistence tests |
|
||||
| `pkg/window/service.go` | Flesh out `applyConfig()` stub: read `default_width`, `default_height`, `state_file` |
|
||||
| `pkg/systray/service.go` | Flesh out `applyConfig()` stub: read `icon` field (tooltip already works) |
|
||||
| `pkg/menu/service.go` | Flesh out `applyConfig()` stub: read `show_dev_tools`, expose via accessor |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Wire go-config into Display Orchestrator
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`
|
||||
- Modify: `pkg/display/display.go`
|
||||
- Modify: `pkg/display/display_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing test — config loads from file**
|
||||
|
||||
Add to `pkg/display/display_test.go`:
|
||||
|
||||
```go
|
||||
func TestLoadConfig_Good(t *testing.T) {
|
||||
// Create temp config file
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, ".core", "gui", "config.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
window:
|
||||
default_width: 1280
|
||||
default_height: 720
|
||||
systray:
|
||||
tooltip: "Test App"
|
||||
menu:
|
||||
show_dev_tools: false
|
||||
`), 0o644))
|
||||
|
||||
s, _ := New()
|
||||
s.loadConfigFrom(cfgPath)
|
||||
|
||||
// Verify configData was populated from file
|
||||
assert.Equal(t, 1280, s.configData["window"]["default_width"])
|
||||
assert.Equal(t, "Test App", s.configData["systray"]["tooltip"])
|
||||
assert.Equal(t, false, s.configData["menu"]["show_dev_tools"])
|
||||
}
|
||||
|
||||
func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
||||
s, _ := New()
|
||||
s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
||||
|
||||
// Should not panic, configData stays at empty defaults
|
||||
assert.Empty(t, s.configData["window"])
|
||||
assert.Empty(t, s.configData["systray"])
|
||||
assert.Empty(t, s.configData["menu"])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add go-config dependency**
|
||||
|
||||
```bash
|
||||
cd /path/to/core/gui
|
||||
go get forge.lthn.ai/core/go-config
|
||||
```
|
||||
|
||||
The Go workspace will resolve it locally from `~/Code/core/go-config`.
|
||||
|
||||
- [ ] **Step 3: Implement `loadConfig()` and `loadConfigFrom()`**
|
||||
|
||||
In `pkg/display/display.go`, add the `cfg` field and replace the stub:
|
||||
|
||||
```go
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go-config"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
wailsApp *application.App
|
||||
app App
|
||||
config Options
|
||||
configData map[string]map[string]any
|
||||
cfg *config.Config // go-config instance for file persistence
|
||||
notifier *notifications.NotificationService
|
||||
events *WSEventManager
|
||||
}
|
||||
|
||||
func guiConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return filepath.Join(".core", "gui", "config.yaml")
|
||||
}
|
||||
return filepath.Join(home, ".core", "gui", "config.yaml")
|
||||
}
|
||||
|
||||
func (s *Service) loadConfig() {
|
||||
s.loadConfigFrom(guiConfigPath())
|
||||
}
|
||||
|
||||
func (s *Service) loadConfigFrom(path string) {
|
||||
cfg, err := config.New(config.WithPath(path))
|
||||
if err != nil {
|
||||
// Non-critical — continue with empty configData
|
||||
return
|
||||
}
|
||||
s.cfg = cfg
|
||||
|
||||
for _, section := range []string{"window", "systray", "menu"} {
|
||||
var data map[string]any
|
||||
if err := cfg.Get(section, &data); err == nil && data != nil {
|
||||
s.configData[section] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write failing test — config persists on TaskSaveConfig**
|
||||
|
||||
```go
|
||||
func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
|
||||
s, _ := New()
|
||||
s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet)
|
||||
|
||||
// Simulate a TaskSaveConfig through the handler
|
||||
c, _ := core.New(
|
||||
core.WithService(func(c *core.Core) (any, error) {
|
||||
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
return s, nil
|
||||
}),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{
|
||||
Value: map[string]any{"default_width": 1920},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify file was written
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "default_width")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `handleConfigTask` to persist**
|
||||
|
||||
```go
|
||||
func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case window.TaskSaveConfig:
|
||||
s.configData["window"] = t.Value
|
||||
s.persistSection("window", t.Value)
|
||||
return nil, true, nil
|
||||
case systray.TaskSaveConfig:
|
||||
s.configData["systray"] = t.Value
|
||||
s.persistSection("systray", t.Value)
|
||||
return nil, true, nil
|
||||
case menu.TaskSaveConfig:
|
||||
s.configData["menu"] = t.Value
|
||||
s.persistSection("menu", t.Value)
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) persistSection(key string, value map[string]any) {
|
||||
if s.cfg == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cfg.Set(key, value)
|
||||
_ = s.cfg.Commit()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests, verify green**
|
||||
|
||||
```bash
|
||||
core go test --run TestLoadConfig
|
||||
core go test --run TestHandleConfigTask_Persists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Flesh Out Sub-Service `applyConfig()` Stubs
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/window/service.go`
|
||||
- Modify: `pkg/systray/service.go`
|
||||
- Modify: `pkg/menu/service.go`
|
||||
|
||||
- [ ] **Step 1: Window — apply default dimensions**
|
||||
|
||||
In `pkg/window/service.go`, replace the stub:
|
||||
|
||||
```go
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if w, ok := cfg["default_width"]; ok {
|
||||
if width, ok := w.(int); ok {
|
||||
s.manager.SetDefaultWidth(width)
|
||||
}
|
||||
}
|
||||
if h, ok := cfg["default_height"]; ok {
|
||||
if height, ok := h.(int); ok {
|
||||
s.manager.SetDefaultHeight(height)
|
||||
}
|
||||
}
|
||||
if sf, ok := cfg["state_file"]; ok {
|
||||
if stateFile, ok := sf.(string); ok {
|
||||
s.manager.State().SetPath(stateFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The Manager's `SetDefaultWidth`, `SetDefaultHeight`, and `State().SetPath()` methods may need to be added if they don't exist yet. If not present, skip those calls and add a `// TODO:` — this task is about wiring config, not extending Manager's API.
|
||||
|
||||
- [ ] **Step 2: Systray — add icon path handling**
|
||||
|
||||
In `pkg/systray/service.go`, extend the existing `applyConfig`:
|
||||
|
||||
```go
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
tooltip, _ := cfg["tooltip"].(string)
|
||||
if tooltip == "" {
|
||||
tooltip = "Core"
|
||||
}
|
||||
_ = s.manager.Setup(tooltip, tooltip)
|
||||
|
||||
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
|
||||
// Icon loading is deferred to when assets are available.
|
||||
// Store the path for later use.
|
||||
s.iconPath = iconPath
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `iconPath string` field to `Service` struct.
|
||||
|
||||
- [ ] **Step 3: Menu — read show_dev_tools flag**
|
||||
|
||||
In `pkg/menu/service.go`, replace the stub:
|
||||
|
||||
```go
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if v, ok := cfg["show_dev_tools"]; ok {
|
||||
if show, ok := v.(bool); ok {
|
||||
s.showDevTools = show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShowDevTools returns whether developer tools menu items should be shown.
|
||||
func (s *Service) ShowDevTools() bool {
|
||||
return s.showDevTools
|
||||
}
|
||||
```
|
||||
|
||||
Add `showDevTools bool` field to `Service` struct (default `false`).
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
```bash
|
||||
core go test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
1. `loadConfig()` reads from `~/.core/gui/config.yaml` via go-config
|
||||
2. `handleConfigTask` persists changes to disk via `cfg.Set()` + `cfg.Commit()`
|
||||
3. Missing/malformed config file does not crash the GUI
|
||||
4. Sub-service `applyConfig()` methods consume real config values
|
||||
5. All existing tests continue to pass
|
||||
6. New tests cover load, persist, and missing-file scenarios
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Manifest/slots integration**: go-scm `Manifest.Layout`/`Manifest.Slots` could feed a `layout` config section for user slot preferences. Not needed yet.
|
||||
- **Manager API extensions**: `SetDefaultWidth()`, `SetDefaultHeight()`, `State().SetPath()` — add when window Manager is extended.
|
||||
- **Config file watching**: Viper supports `WatchConfig()` for live reload. Not needed for a desktop app where config changes come through IPC.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,268 +0,0 @@
|
|||
# CoreGUI Display Package Split
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Restructure `pkg/display/` monolith into 4 focused packages
|
||||
|
||||
## Context
|
||||
|
||||
CoreGUI (`forge.lthn.ai/core/gui`) is an abstraction layer over Wails v3 — the "display server" that TypeScript applications bind to for window, tray, and menu management. Apps never import Wails directly; CoreGUI provides the stable API contract. If Wails breaks, it's fixed in one place.
|
||||
|
||||
This lineage traces back to `/Users/snider/Code/dappserver` — the client-side server for PWAs that formed a contract of APIs/polyfills inside the webview window to talk to the runtime.
|
||||
|
||||
Today, all 3,910 LOC live in a single `pkg/display/` package across 15 files. This design splits it into 4 packages with clear boundaries.
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
### pkg/window — Window lifecycle and spatial management
|
||||
|
||||
Extracted from `display.go` (window CRUD, tiling, snapping, layouts), `window.go`, `window_state.go`, `layout.go`.
|
||||
|
||||
**Responsibilities:**
|
||||
- `Window` struct (CoreGUI's own, NOT a type alias — replaces `type Window = application.WebviewWindowOptions`)
|
||||
- `WindowOption` functional options rewritten against CoreGUI's `Window` struct: `func(*Window) error`
|
||||
- `WindowStateManager` — JSON persistence to `~/.config/Core/window_state.json`
|
||||
- `LayoutManager` — named window arrangements to `~/.config/Core/layouts.json`
|
||||
- Tiling (9 modes), snapping (9 positions), stacking
|
||||
- Workflow presets (coding, debugging, presenting, side-by-side)
|
||||
- `Platform` adapter interface insulating Wails
|
||||
|
||||
**Source files (from current pkg/display/):**
|
||||
|
||||
| Current File | Destination | Notes |
|
||||
|---|---|---|
|
||||
| `display.go` (~800 LOC window section) | `window.go`, `tiling.go`, `layout.go` | Split by concern |
|
||||
| `window.go` (90 LOC) | `options.go` | Functional options, own Window type |
|
||||
| `window_state.go` (261 LOC) | `state.go` | JSON persistence |
|
||||
| `layout.go` (149 LOC) | `layout.go` | Named arrangements |
|
||||
|
||||
### pkg/systray — System tray and panel
|
||||
|
||||
Extracted from `tray.go` plus `TrayMenuItem` types from `display.go`.
|
||||
|
||||
**Responsibilities:**
|
||||
- Tray creation, lifecycle
|
||||
- Icon management (template for macOS, dual-mode for Windows/Linux)
|
||||
- Tooltip and label
|
||||
- Dynamic menu builder (`TrayMenuItem` recursive tree)
|
||||
- Callback registry (`RegisterTrayMenuCallback`) — stored as `Manager` fields, NOT package-level vars
|
||||
- Attached panel window (hidden, frameless, offset) — accepts a `WindowHandle` interface (see Shared Types)
|
||||
- `Platform` adapter interface insulating Wails
|
||||
- **Migration note:** Current `activeTray` and `trayMenuCallbacks` package-level vars become fields on `Manager`
|
||||
|
||||
**Source files:**
|
||||
|
||||
| Current File | Destination | Notes |
|
||||
|---|---|---|
|
||||
| `tray.go` (200 LOC) | `tray.go` | Core tray lifecycle |
|
||||
| `display.go` (TrayMenuItem types, SetTrayMenu, etc.) | `menu.go`, `types.go` | Dynamic menu + types |
|
||||
|
||||
### pkg/menu — Application menus
|
||||
|
||||
Extracted from `menu.go`.
|
||||
|
||||
**Responsibilities:**
|
||||
- Menu builder — constructs menu item trees (labels, accelerators, submenus, separators)
|
||||
- Menu item types and accelerator bindings
|
||||
- `Platform` adapter interface insulating Wails
|
||||
- **Click handlers live in `pkg/display`**, not here. `pkg/menu` builds structure only; the orchestrator injects closures for app-specific actions (open file, new window, etc.)
|
||||
|
||||
**Source files:**
|
||||
|
||||
| Current File | Destination | Notes |
|
||||
|---|---|---|
|
||||
| `menu.go` (185 LOC) | `menu.go` | App menu construction |
|
||||
|
||||
### pkg/display — Orchestrator / display server contract
|
||||
|
||||
What remains — the glue layer and the API surface TypeScript apps bind to.
|
||||
|
||||
**Responsibilities:**
|
||||
- `Service` struct embedding `core.ServiceRuntime`
|
||||
- Composes `window.Manager`, `systray.Manager`, `menu.Manager`
|
||||
- `WSEventManager` — WebSocket pub/sub bridge for TS apps (the display server channel)
|
||||
- Dialog helpers (file open, save, directory select)
|
||||
- Clipboard (read/write text, HTML, images)
|
||||
- Notifications (native system, fallback to dialog)
|
||||
- Theme detection (dark/light)
|
||||
- IPC action types
|
||||
- `Register()` factory for core DI
|
||||
- Shared types consumed by TS apps: `ScreenInfo`, `WorkArea`
|
||||
|
||||
**Source files that stay:**
|
||||
|
||||
| File | LOC | Notes |
|
||||
|---|---|---|
|
||||
| `events.go` | 365 | WebSocket bridge — the display server contract |
|
||||
| `dialog.go` | 192 | File/directory dialogs |
|
||||
| `notification.go` | 127 | System notifications |
|
||||
| `clipboard.go` | 61 | Clipboard operations |
|
||||
| `theme.go` | 38 | Theme detection |
|
||||
| `actions.go` | 20 | IPC message types |
|
||||
| `display.go` (~500 LOC) | Orchestration, startup, service wiring |
|
||||
|
||||
### ui/ — Feature demo (top-level)
|
||||
|
||||
Moved from `pkg/display/ui/` to top-level `ui/`.
|
||||
|
||||
- Reference implementation demonstrating all CoreGUI capabilities
|
||||
- Sets the standard pattern for downstream apps (BugSETI, LEM, Mining, IDE)
|
||||
- Existing Angular code in `pkg/display/ui/` moves as-is to top-level `ui/`; `go:embed` directives update to match
|
||||
- Placeholder README added explaining its purpose as feature demo
|
||||
- Future: Playwright inside WebView2 for automated testing, errors surfaced to agents
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/window
|
||||
├── imports pkg/systray
|
||||
├── imports pkg/menu
|
||||
└── imports core/go (DI)
|
||||
|
||||
pkg/window ──→ core/go, wails/v3 (behind Platform interface)
|
||||
pkg/systray ──→ core/go, wails/v3 (behind Platform interface)
|
||||
pkg/menu ──→ core/go, wails/v3 (behind Platform interface)
|
||||
```
|
||||
|
||||
No circular dependencies. Window, systray, and menu are peers — they do not import each other. Display imports all three and wires them together.
|
||||
|
||||
Shared types (`ScreenInfo`, `WorkArea`) live in `pkg/display` since that's the contract layer TS apps consume.
|
||||
|
||||
### Shared Types
|
||||
|
||||
A `WindowHandle` interface lives in `pkg/display` for cross-package use (e.g. systray attaching a panel window without importing `pkg/window`):
|
||||
|
||||
```go
|
||||
// pkg/display/types.go
|
||||
type WindowHandle interface {
|
||||
Name() string
|
||||
Show()
|
||||
Hide()
|
||||
SetPosition(x, y int)
|
||||
SetSize(width, height int)
|
||||
}
|
||||
```
|
||||
|
||||
Both `pkg/window.PlatformWindow` and `pkg/systray.PlatformTray.AttachWindow()` work with this interface — no `any` types, no cross-package peer imports.
|
||||
|
||||
## Wails Insulation Pattern
|
||||
|
||||
Each sub-package defines a `Platform` interface — the adapter contract. Wails never leaks past this boundary.
|
||||
|
||||
```go
|
||||
// pkg/window/platform.go
|
||||
type Platform interface {
|
||||
CreateWindow(opts PlatformWindowOptions) PlatformWindow
|
||||
GetWindows() []PlatformWindow
|
||||
}
|
||||
|
||||
type PlatformWindow interface {
|
||||
// Identity
|
||||
Name() string
|
||||
|
||||
// Queries
|
||||
Position() (int, int)
|
||||
Size() (int, int)
|
||||
IsMaximised() bool
|
||||
IsFocused() bool
|
||||
|
||||
// Mutations
|
||||
SetTitle(title string)
|
||||
SetPosition(x, y int)
|
||||
SetSize(width, height int)
|
||||
SetBackgroundColour(r, g, b, a uint8)
|
||||
SetVisibility(visible bool)
|
||||
SetAlwaysOnTop(alwaysOnTop bool)
|
||||
|
||||
// Window state
|
||||
Maximise()
|
||||
Restore()
|
||||
Minimise()
|
||||
Focus()
|
||||
Close()
|
||||
Show()
|
||||
Hide()
|
||||
Fullscreen()
|
||||
UnFullscreen()
|
||||
|
||||
// Events (for WSEventManager insulation)
|
||||
OnWindowEvent(handler func(event WindowEvent))
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/systray/platform.go
|
||||
type Platform interface {
|
||||
NewTray() PlatformTray
|
||||
}
|
||||
|
||||
type PlatformTray interface {
|
||||
SetIcon(data []byte)
|
||||
SetTemplateIcon(data []byte)
|
||||
SetTooltip(text string)
|
||||
SetLabel(text string)
|
||||
SetMenu(menu PlatformMenu)
|
||||
AttachWindow(w display.WindowHandle)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// pkg/menu/platform.go
|
||||
type Platform interface {
|
||||
NewMenu() PlatformMenu
|
||||
}
|
||||
|
||||
type PlatformMenu interface {
|
||||
Add(label string) PlatformMenuItem
|
||||
AddSeparator()
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- Interfaces only expose what CoreGUI actually uses — no speculative wrapping
|
||||
- Wails adapter implementations live in each package (e.g. `pkg/window/wails.go`)
|
||||
- Mock implementations for testing (e.g. `pkg/window/mock_test.go`)
|
||||
- If Wails changes (v4, breaking API), update 3 adapter files — nothing else changes
|
||||
|
||||
### WSEventManager Insulation
|
||||
|
||||
`WSEventManager` (stays in `pkg/display`) currently calls `application.Get()` directly and takes `*application.WebviewWindow` in `AttachWindowListeners`. After the split:
|
||||
|
||||
- `AttachWindowListeners` accepts `PlatformWindow` (which has `OnWindowEvent`) instead of the Wails concrete type
|
||||
- The `application.Get()` call moves into the Wails adapter — the event manager receives an `EventSource` interface
|
||||
- This allows testing the WebSocket bridge without a Wails runtime
|
||||
|
||||
### WindowStateManager Insulation
|
||||
|
||||
`CaptureState` currently takes `*application.WebviewWindow`. After the split:
|
||||
|
||||
- `CaptureState` accepts `PlatformWindow` interface (has `Name()`, `Position()`, `Size()`, `IsMaximised()`)
|
||||
- `ApplyState` returns CoreGUI's own `Window` struct, not `application.WebviewWindowOptions`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each package gets its own test suite with mock platform:
|
||||
|
||||
| Package | Test File | Mock | Coverage |
|
||||
|---|---|---|---|
|
||||
| `pkg/window` | `window_test.go` | `mockPlatform` + `mockWindow` | CRUD, state persistence, tiling, snapping, layouts, presets |
|
||||
| `pkg/systray` | `tray_test.go` | `mockPlatform` + `mockTray` | Icon, menu, callbacks, panel attachment |
|
||||
| `pkg/menu` | `menu_test.go` | `mockPlatform` + `mockMenu` | Construction, item types, accelerators |
|
||||
| `pkg/display` | `display_test.go` | Composes sub-package mocks | Orchestration, events, dialogs, clipboard, notifications |
|
||||
|
||||
Existing tests from `display_test.go` (636 LOC) split to follow their code. Each package gets its own `newTestX(t)` helper for creating a service with mock platform.
|
||||
|
||||
Test framework: `testify` (assert + require). Naming convention: `_Good`/`_Bad`/`_Ugly` suffix pattern from core/go.
|
||||
|
||||
## Reference Patterns
|
||||
|
||||
These existing implementations inform the systray and window patterns:
|
||||
|
||||
- **LEM** (`/Users/snider/Code/lthn/LEM/cmd/lem-desktop/tray.go`): Multi-window TrayService with dashboard snapshots, platform-specific icons
|
||||
- **Mining** (`/Users/snider/Code/snider/Mining/`): Angular custom elements (`createCustomElement`), Wails service facade, sparkline SVG charts
|
||||
- **core/ide** (`/Users/snider/Code/core/ide/main.go`): Simpler systray with tray panel window
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
# GUI Config Wiring — Design Spec
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Replace the in-memory `loadConfig()` stub in `pkg/display` with real `go-config` file-backed configuration, and wire `handleConfigTask` to persist changes to disk.
|
||||
|
||||
## Context
|
||||
|
||||
The Service Conclave IPC integration (previous spec) established the pattern: display owns config, sub-services QUERY for their section during `OnStartup`, and saves route through `TaskSaveConfig` back to display. All the IPC plumbing works today — but `loadConfig()` creates empty maps and `handleConfigTask` only updates memory. Nothing reads from or writes to disk.
|
||||
|
||||
`go-config` (`forge.lthn.ai/core/go-config`) provides exactly what we need: `config.New(config.WithPath(...))` loads a YAML file via Viper, `Get(key, &out)` reads sections by dot-notation key, `Set(key, v)` updates in memory, and `Commit()` persists to disk. It handles directory creation, file-not-exists (returns empty config), and concurrent access via `sync.RWMutex`.
|
||||
|
||||
## Design
|
||||
|
||||
### Config File Location
|
||||
|
||||
`.core/gui/config.yaml` — resolved relative to `$HOME`:
|
||||
|
||||
```
|
||||
~/.core/gui/config.yaml
|
||||
```
|
||||
|
||||
This follows the go-config convention (`~/.core/config.yaml`) but scoped to the GUI module.
|
||||
|
||||
### Config Format
|
||||
|
||||
```yaml
|
||||
window:
|
||||
state_file: window_state.json
|
||||
default_width: 1024
|
||||
default_height: 768
|
||||
systray:
|
||||
icon: apptray.png
|
||||
tooltip: "Core GUI"
|
||||
menu:
|
||||
show_dev_tools: true
|
||||
```
|
||||
|
||||
Top-level keys map 1:1 to sub-service names. Each sub-service receives its section as `map[string]any` via the existing `QueryConfig` / `handleConfigQuery` pattern.
|
||||
|
||||
### Changes to `pkg/display/display.go`
|
||||
|
||||
1. **New field**: Add `cfg *config.Config` to `Service` struct (replaces reliance on `configData` maps for initial load).
|
||||
|
||||
2. **`loadConfig()`**: Replace stub with:
|
||||
```go
|
||||
func (s *Service) loadConfig() {
|
||||
cfg, err := config.New(config.WithPath(guiConfigPath()))
|
||||
if err != nil {
|
||||
// Log warning, continue with empty config — GUI should not crash
|
||||
// if config file is missing or malformed.
|
||||
s.cfg = nil
|
||||
return
|
||||
}
|
||||
s.cfg = cfg
|
||||
|
||||
// Populate configData sections from file
|
||||
for _, section := range []string{"window", "systray", "menu"} {
|
||||
var data map[string]any
|
||||
if err := cfg.Get(section, &data); err == nil && data != nil {
|
||||
s.configData[section] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **`handleConfigTask()`**: After updating `configData` in memory, persist via `cfg.Set()` + `cfg.Commit()`:
|
||||
```go
|
||||
case window.TaskSaveConfig:
|
||||
s.configData["window"] = t.Value
|
||||
if s.cfg != nil {
|
||||
_ = s.cfg.Set("window", t.Value)
|
||||
_ = s.cfg.Commit()
|
||||
}
|
||||
return nil, true, nil
|
||||
```
|
||||
|
||||
4. **`guiConfigPath()`**: Helper returning `~/.core/gui/config.yaml`. Falls back to `.core/gui/config.yaml` in CWD if `$HOME` is unresolvable (shouldn't happen in practice).
|
||||
|
||||
### What Does NOT Change
|
||||
|
||||
- **`configData` map**: Remains the in-memory cache. `handleConfigQuery` still returns from it. This avoids Viper's type coercion quirks — sub-services get raw `map[string]any`.
|
||||
- **Sub-service `applyConfig()` methods**: Already wired via IPC. They receive real values once `loadConfig()` populates `configData` from the file. No changes needed — the stubs in `window.applyConfig`, `systray.applyConfig`, and `menu.applyConfig` just need to use the values they already receive.
|
||||
- **IPC message types**: `QueryConfig`, `TaskSaveConfig` — unchanged.
|
||||
- **go.mod**: Add `forge.lthn.ai/core/go-config` as a direct dependency. The Go workspace resolves it locally.
|
||||
|
||||
### Sub-Service `applyConfig()` Implementations
|
||||
|
||||
These are small enhancements to existing stubs (not new architecture):
|
||||
|
||||
- **`window.applyConfig()`**: Read `default_width`, `default_height` from config and set on Manager defaults. Read `state_file` to configure StateManager path.
|
||||
- **`systray.applyConfig()`**: Already partially implemented (reads `tooltip`). Add `icon` path reading.
|
||||
- **`menu.applyConfig()`**: Read `show_dev_tools` bool and store it for `buildMenu()` to conditionally include the Developer menu.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Config is non-critical. If the file is missing, malformed, or unreadable:
|
||||
- `loadConfig()` logs a warning and continues with empty `configData` maps.
|
||||
- Sub-services receive empty maps and apply their hardcoded defaults.
|
||||
- `handleConfigTask` saves still work — `config.New` creates the file on first `Commit()`.
|
||||
|
||||
### Future Work: Manifest/Slots Integration
|
||||
|
||||
The go-scm manifest (`Manifest.Layout`, `Manifest.Slots`) declares which UI components go where (HLCRF positions to component names). Config could store user preferences for those slots (e.g., "I want the terminal in the bottom panel"). This is deferred — the current config covers operational settings only. When implemented, it would add a `layout` section to the config file that overrides manifest slot defaults.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports forge.lthn.ai/core/go-config // NEW
|
||||
├── imports pkg/window (message types)
|
||||
├── imports pkg/systray (message types)
|
||||
├── imports pkg/menu (message types)
|
||||
└── imports core/go (DI, IPC)
|
||||
```
|
||||
|
||||
No new imports in sub-packages. Only the display orchestrator touches go-config.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
# CoreGUI Spec A: Extract & Insulate
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Extract 5 new core.Service packages from pkg/display, each with Platform interface insulation and full IPC coverage for the TS SDK
|
||||
|
||||
## Context
|
||||
|
||||
The Service Conclave IPC design (`2026-03-13-service-conclave-ipc-design.md`) established the three-layer pattern: IPC Bus → Service → Platform Interface. It converted window, systray, and menu into core.Services. Five feature areas remain embedded in `pkg/display` with direct `application.Get()` calls, making them untestable and invisible to the WS bridge.
|
||||
|
||||
This spec extracts those features into independent packages following the same pattern. Full IPC coverage ensures the TypeScript SDK (via WSEventManager) can access every feature, enabling PWA-only frontend development.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Extraction Targets
|
||||
|
||||
| Package | Source file | Platform methods | IPC pattern |
|
||||
|---------|------------|-----------------|-------------|
|
||||
| `pkg/clipboard` | `display/clipboard.go` | `Text()`, `SetText()` | Query read, Task write/clear |
|
||||
| `pkg/dialog` | `display/dialog.go` | `OpenFile()`, `SaveFile()`, `OpenDirectory()`, `MessageDialog()` | Tasks (show UI, return result) |
|
||||
| `pkg/notification` | `display/notification.go` | `Send()`, `RequestPermission()`, `CheckPermission()` | Tasks + Query auth |
|
||||
| `pkg/environment` | `display/theme.go` + `display/interfaces.go` (EventSource) | `IsDarkMode()`, `Info()`, `AccentColour()`, `OpenFileManager()`, `OnThemeChange()` | Query + Action theme change |
|
||||
| `pkg/screen` | `display/display.go` (GetScreens/GetWorkAreas) | `GetAll()`, `GetPrimary()` | Query screen info |
|
||||
|
||||
### Three-Layer Stack (per package)
|
||||
|
||||
```
|
||||
IPC Bus (core/go QUERY/TASK/ACTION)
|
||||
↓
|
||||
Service (core.ServiceRuntime[Options] + business logic)
|
||||
↓
|
||||
Platform Interface (Wails v3 adapter, injected via Register closure)
|
||||
```
|
||||
|
||||
### Registration Pattern
|
||||
|
||||
Each package exposes `Register(platform Platform) func(*core.Core) (any, error)`:
|
||||
|
||||
```go
|
||||
core.New(
|
||||
core.WithService(display.Register(wailsApp)),
|
||||
core.WithService(window.Register(windowPlatform)),
|
||||
core.WithService(systray.Register(trayPlatform)),
|
||||
core.WithService(menu.Register(menuPlatform)),
|
||||
core.WithService(clipboard.Register(clipPlatform)),
|
||||
core.WithService(dialog.Register(dialogPlatform)),
|
||||
core.WithService(notification.Register(notifyPlatform)),
|
||||
core.WithService(environment.Register(envPlatform)),
|
||||
core.WithService(screen.Register(screenPlatform)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
```
|
||||
|
||||
Display registers first (owns config), then all sub-services. Order among sub-services does not matter — they only depend on display's config query.
|
||||
|
||||
### WS Bridge Integration
|
||||
|
||||
The display orchestrator's `HandleIPCEvents` gains cases for all new Action types. The existing `WSEventManager.Emit()` pattern is unchanged — display translates IPC Actions to `Event` structs for WebSocket clients.
|
||||
|
||||
---
|
||||
|
||||
## Package Designs
|
||||
|
||||
### 1. pkg/clipboard
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Text() (string, bool)
|
||||
SetText(text string) bool
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
type QueryText struct{} // → ClipboardContent{Text string, HasContent bool}
|
||||
type TaskSetText struct{ Text string } // → bool
|
||||
type TaskClear struct{} // → bool
|
||||
```
|
||||
|
||||
**Wails adapter:** Wraps `app.Clipboard.Text()` and `app.Clipboard.SetText()`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**Implementation note:** `TaskClear` is implemented by the Service calling `platform.SetText("")` — no separate Platform method needed.
|
||||
|
||||
**WS bridge:** No actions. TS apps use `clipboard:text` (query) and `clipboard:set-text` / `clipboard:clear` (tasks) via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/dialog
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
OpenFile(opts OpenFileOptions) ([]string, error)
|
||||
SaveFile(opts SaveFileOptions) (string, error)
|
||||
OpenDirectory(opts OpenDirectoryOptions) (string, error)
|
||||
MessageDialog(opts MessageDialogOptions) (string, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Our own types (no Wails leakage):**
|
||||
|
||||
```go
|
||||
type OpenFileOptions struct {
|
||||
Title string
|
||||
Directory string
|
||||
Filename string
|
||||
Filters []FileFilter
|
||||
AllowMultiple bool
|
||||
}
|
||||
|
||||
type SaveFileOptions struct {
|
||||
Title string
|
||||
Directory string
|
||||
Filename string
|
||||
Filters []FileFilter
|
||||
}
|
||||
|
||||
type OpenDirectoryOptions struct {
|
||||
Title string
|
||||
Directory string
|
||||
AllowMultiple bool
|
||||
}
|
||||
|
||||
type MessageDialogOptions struct {
|
||||
Type DialogType
|
||||
Title string
|
||||
Message string
|
||||
Buttons []string
|
||||
}
|
||||
|
||||
type DialogType int
|
||||
|
||||
const (
|
||||
DialogInfo DialogType = iota
|
||||
DialogWarning
|
||||
DialogError
|
||||
DialogQuestion
|
||||
)
|
||||
|
||||
type FileFilter struct {
|
||||
DisplayName string
|
||||
Pattern string
|
||||
Extensions []string // forward-compat — unused by Wails v3 currently
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages (all Tasks — dialogs are side-effects):**
|
||||
|
||||
```go
|
||||
type TaskOpenFile struct{ Opts OpenFileOptions } // → []string
|
||||
type TaskSaveFile struct{ Opts SaveFileOptions } // → string
|
||||
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions } // → string (or []string if AllowMultiple)
|
||||
type TaskMessageDialog struct{ Opts MessageDialogOptions } // → string (button clicked)
|
||||
```
|
||||
|
||||
**Wails adapter:** Translates our types to `application.OpenFileDialogStruct`, `application.SaveFileDialogStruct`, `application.MessageDialog`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**WS bridge:** No actions. TS apps call `dialog:open-file`, `dialog:save-file`, `dialog:open-directory`, `dialog:message` via WS→IPC Tasks. Response includes selected path(s) or button clicked.
|
||||
|
||||
---
|
||||
|
||||
### 3. pkg/notification
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Send(opts NotificationOptions) error
|
||||
RequestPermission() (bool, error)
|
||||
CheckPermission() (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type NotificationSeverity int
|
||||
|
||||
const (
|
||||
SeverityInfo NotificationSeverity = iota
|
||||
SeverityWarning
|
||||
SeverityError
|
||||
)
|
||||
|
||||
type NotificationOptions struct {
|
||||
ID string
|
||||
Title string
|
||||
Message string
|
||||
Subtitle string
|
||||
Severity NotificationSeverity // used by fallback to select DialogType
|
||||
}
|
||||
|
||||
type PermissionStatus struct {
|
||||
Granted bool
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryPermission struct{} // → PermissionStatus
|
||||
|
||||
// Tasks
|
||||
type TaskSend struct{ Opts NotificationOptions } // → error only
|
||||
type TaskRequestPermission struct{} // → bool (granted)
|
||||
|
||||
// Actions
|
||||
type ActionNotificationClicked struct{ ID string } // future — when Wails supports callbacks
|
||||
```
|
||||
|
||||
**Fallback logic (Service layer, not Platform):** If `platform.Send()` returns an error, the Service PERFORMs `dialog.TaskMessageDialog` via IPC as a fallback, mapping `Severity` to the appropriate `DialogType` (Info→DialogInfo, Warning→DialogWarning, Error→DialogError). This keeps the Platform interface clean (just native notifications) while the Service owns the fallback decision.
|
||||
|
||||
**Migration note:** The existing `ShowInfoNotification`, `ShowWarningNotification`, and `ShowErrorNotification` convenience methods are replaced by `TaskSend` with the appropriate `Severity` field. The existing `ConfirmDialog(title, msg) bool` in display is replaced by `dialog.TaskMessageDialog` with `Type: DialogQuestion, Buttons: ["Yes", "No"]` — callers migrate from checking `bool` to checking `result == "Yes"`.
|
||||
|
||||
**Wails adapter:** Wraps `*notifications.NotificationService`.
|
||||
|
||||
**Config:** None currently.
|
||||
|
||||
**WS bridge:** `notification.clicked` (future). TS apps call `notification:send`, `notification:request-permission` via WS→IPC Tasks.
|
||||
|
||||
---
|
||||
|
||||
### 4. pkg/environment
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
IsDarkMode() bool
|
||||
Info() EnvironmentInfo
|
||||
AccentColour() string
|
||||
OpenFileManager(path string, selectFile bool) error
|
||||
OnThemeChange(handler func(isDark bool)) func()
|
||||
}
|
||||
```
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type EnvironmentInfo struct {
|
||||
OS string
|
||||
Arch string
|
||||
Debug bool
|
||||
Platform PlatformInfo
|
||||
}
|
||||
|
||||
type PlatformInfo struct {
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
type ThemeInfo struct {
|
||||
IsDark bool
|
||||
Theme string // "dark" or "light"
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryTheme struct{} // → ThemeInfo
|
||||
type QueryInfo struct{} // → EnvironmentInfo
|
||||
type QueryAccentColour struct{} // → string
|
||||
|
||||
// Tasks
|
||||
type TaskOpenFileManager struct{ Path string; Select bool } // → error
|
||||
|
||||
// Actions
|
||||
type ActionThemeChanged struct{ IsDark bool }
|
||||
```
|
||||
|
||||
**Theme change flow:** The Service's `OnStartup` registers `platform.OnThemeChange()` callback. When fired, the Service broadcasts `ActionThemeChanged` via `s.Core().ACTION()`. The display orchestrator's `HandleIPCEvents` converts this to `Event{Type: EventThemeChange}` for WebSocket clients.
|
||||
|
||||
**What it replaces:** `display.ThemeInfo`, `display.GetTheme()`, `display.GetSystemTheme()`, the `EventSource` interface in `display/types.go`, and the `wailsEventSource` adapter in `display/interfaces.go`.
|
||||
|
||||
**Wails adapter:** Wraps `app.Env.IsDarkMode()`, `app.Env.Info()`, `app.Env.GetAccentColor()`, `app.Env.OpenFileManager()`, and `app.Event.OnApplicationEvent(ThemeChanged, ...)`.
|
||||
|
||||
**Config:** None — read-only system state.
|
||||
|
||||
**WS bridge:** `theme.change` (existing EventThemeChange). TS apps also call `environment:theme`, `environment:info`, `environment:accent-colour`, `environment:open-file-manager` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 5. pkg/screen
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
GetAll() []Screen
|
||||
GetPrimary() *Screen
|
||||
}
|
||||
```
|
||||
|
||||
Two methods. Computed queries (`GetAtPoint`, `GetWorkAreas`) are handled by the Service from `GetAll()` results.
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type Screen struct {
|
||||
ID string
|
||||
Name string
|
||||
ScaleFactor float64
|
||||
Size Size
|
||||
Bounds Rect // position + dimensions (Bounds.X, Bounds.Y for origin)
|
||||
WorkArea Rect
|
||||
IsPrimary bool
|
||||
Rotation float64
|
||||
}
|
||||
|
||||
type Rect struct {
|
||||
X, Y, Width, Height int
|
||||
}
|
||||
|
||||
type Size struct {
|
||||
Width, Height int
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryAll struct{} // → []Screen
|
||||
type QueryPrimary struct{} // → *Screen
|
||||
type QueryByID struct{ ID string } // → *Screen (nil if not found)
|
||||
type QueryAtPoint struct{ X, Y int } // → *Screen (nil if none)
|
||||
type QueryWorkAreas struct{} // → []Rect
|
||||
|
||||
// Actions
|
||||
type ActionScreensChanged struct{ Screens []Screen } // future — when Wails supports display hotplug
|
||||
```
|
||||
|
||||
**Service-level computed queries:** `QueryAtPoint` iterates `platform.GetAll()` and returns the screen whose Bounds contain the point. `QueryWorkAreas` extracts WorkArea from each screen.
|
||||
|
||||
**What moves from display:** `GetScreens()`, `GetWorkAreas()`, `GetScreen(id)`, `GetScreenAtPoint()` — all currently in `display.go` calling `application.Get().GetScreens()` directly.
|
||||
|
||||
**Wails adapter:** Wraps `app.GetScreens()` and finds primary from the list.
|
||||
|
||||
**Config:** None — read-only hardware state.
|
||||
|
||||
**WS bridge:** `screen.change` (existing EventScreenChange, currently unwired). TS apps call `screen:all`, `screen:primary`, `screen:at-point`, `screen:work-areas` via WS→IPC Queries.
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
After extraction, `pkg/display` sheds ~420 lines and retains:
|
||||
|
||||
- **WSEventManager**: Bridges all sub-service Actions to WebSocket events (gains cases for new Action types)
|
||||
- **Config ownership**: go-config, `.core/gui/config.yaml`, `QueryConfig`/`TaskSaveConfig` handlers
|
||||
- **App-level wiring**: Creates Wails Platform adapters, passes to each sub-service's `Register()`
|
||||
- **WS→IPC translation**: Inbound WebSocket messages translated to IPC Tasks/Queries
|
||||
|
||||
The orchestrator no longer contains any clipboard/dialog/notification/theme/screen logic — only routing.
|
||||
|
||||
### Removed Types
|
||||
|
||||
The following are removed from `pkg/display` after extraction:
|
||||
|
||||
- `EventSource` interface (`types.go`) — replaced by `environment.Platform.OnThemeChange()`
|
||||
- `wailsEventSource` adapter (`interfaces.go`) — moves into environment's Wails adapter
|
||||
- `newWailsEventSource()` factory (`interfaces.go`) — no longer needed
|
||||
- `WSEventManager.SetupWindowEventListeners()` (`events.go`) — theme events now arrive via IPC `ActionThemeChanged`
|
||||
- `NewWSEventManager(es EventSource)` signature changes to `NewWSEventManager()` — no longer takes `EventSource`
|
||||
- `ClipboardContentType` enum (`clipboard.go`) — Wails v3 only supports text
|
||||
- `DialogManager` interface subset in `interfaces.go` — replaced by `dialog.Platform`
|
||||
- `wailsDialogManager` adapter — moves into dialog's Wails adapter
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
// ... existing window/systray/menu cases ...
|
||||
case environment.ActionThemeChanged:
|
||||
s.events.Emit(Event{Type: EventThemeChange,
|
||||
Data: map[string]any{"isDark": m.IsDark, "theme": themeStr(m.IsDark)}})
|
||||
case notification.ActionNotificationClicked:
|
||||
s.events.Emit(Event{Type: "notification.clicked",
|
||||
Data: map[string]any{"id": m.ID}})
|
||||
case screen.ActionScreensChanged:
|
||||
s.events.Emit(Event{Type: EventScreenChange,
|
||||
Data: map[string]any{"screens": m.Screens}})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
func (s *Service) handleWSMessage(conn *websocket.Conn, msg WSMessage) {
|
||||
switch msg.Type {
|
||||
// ... existing window cases ...
|
||||
case "clipboard:text":
|
||||
result, handled, err = s.Core().QUERY(clipboard.QueryText{})
|
||||
case "clipboard:set-text":
|
||||
result, handled, err = s.Core().PERFORM(clipboard.TaskSetText{Text: msg.Data["text"].(string)})
|
||||
case "dialog:open-file":
|
||||
result, handled, err = s.Core().PERFORM(dialog.TaskOpenFile{Opts: decodeOpenFileOpts(msg.Data)})
|
||||
case "notification:send":
|
||||
result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: decodeNotifyOpts(msg.Data)})
|
||||
case "environment:theme":
|
||||
result, handled, err = s.Core().QUERY(environment.QueryTheme{})
|
||||
case "screen:all":
|
||||
result, handled, err = s.Core().QUERY(screen.QueryAll{})
|
||||
// ... etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/clipboard (message types only)
|
||||
├── imports pkg/dialog (message types only)
|
||||
├── imports pkg/notification (message types only)
|
||||
├── imports pkg/environment (message types only)
|
||||
├── imports pkg/screen (message types only)
|
||||
├── imports pkg/window (message types only)
|
||||
├── imports pkg/systray (message types only)
|
||||
├── imports pkg/menu (message types only)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── imports core/go-config
|
||||
|
||||
pkg/clipboard, pkg/dialog, pkg/screen (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
|
||||
pkg/notification (has fallback dependency)
|
||||
├── imports core/go (DI, IPC)
|
||||
├── imports pkg/dialog (message types only — for fallback)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
|
||||
pkg/environment (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
```
|
||||
|
||||
No circular dependencies. Sub-packages do not import the orchestrator. Only notification imports dialog (message types only, for the fallback PERFORM).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each package is independently testable with mock platforms and a real core.Core:
|
||||
|
||||
```go
|
||||
func TestClipboard_QueryText_Good(t *testing.T) {
|
||||
mock := &mockPlatform{text: "hello", ok: true}
|
||||
c, _ := core.New(
|
||||
core.WithService(clipboard.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
result, handled, err := c.QUERY(clipboard.QueryText{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
content := result.(clipboard.ClipboardContent)
|
||||
assert.Equal(t, "hello", content.Text)
|
||||
assert.True(t, content.HasContent)
|
||||
}
|
||||
|
||||
func TestNotification_Fallback_Good(t *testing.T) {
|
||||
// notification platform fails → falls back to dialog via IPC
|
||||
mockNotify := &mockNotifyPlatform{sendErr: errors.New("no permission")}
|
||||
mockDialog := &mockDialogPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(dialog.Register(mockDialog)),
|
||||
core.WithService(notification.Register(mockNotify)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(notification.TaskSend{
|
||||
Opts: notification.NotificationOptions{Title: "Test", Message: "Hello"},
|
||||
})
|
||||
assert.True(t, handled)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, mockDialog.messageDialogCalled) // fallback fired
|
||||
}
|
||||
```
|
||||
|
||||
Integration test with full conclave follows the existing `TestServiceConclave_Good` pattern.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Clipboard content types**: Image/HTML clipboard (Wails v3 only supports text currently)
|
||||
- **Notification callbacks**: `ActionNotificationClicked` — requires Wails v3 notification callback support
|
||||
- **Screen hotplug**: `ActionScreensChanged` — requires Wails v3 display change events
|
||||
- **Dialog sync returns**: Current Wails v3 dialog API is async; sync wrapper is a follow-up
|
||||
- **File drop integration**: `EnableFileDrop` on windows — separate from dialog, deferred to Spec B
|
||||
- **Prompt dialog**: Text-input dialog (`PromptDialog`) — not supported natively by Wails v3, existing stub dropped
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
# CoreGUI Spec D: Context Menus & Final Cleanup
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add context menu package as core.Service, remove stale Wails wrappers from display orchestrator
|
||||
|
||||
## Context
|
||||
|
||||
Specs A–C extracted 12 packages covering 10 of the 11 Wails v3 Manager APIs. Two gaps remain:
|
||||
|
||||
1. **`app.ContextMenu`** — the only Wails v3 manager without a core.Service wrapper
|
||||
2. **Stale `interfaces.go`** — display orchestrator still holds `wailsDialogManager`, `wailsEnvManager`, `wailsEventManager` wrappers and 4 direct `s.app.*` calls that bypass IPC
|
||||
|
||||
This spec closes both gaps, achieving full Wails v3 Manager API coverage through the IPC bus.
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Package
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/contextmenu` | `Add()`, `Remove()`, `Get()`, `GetAll()` | Tasks (register/remove), Query (get/list), Action (item clicked) |
|
||||
|
||||
### Display Cleanup
|
||||
|
||||
Remove the `App` interface's `Dialog()`, `Env()`, `Event()` methods and their Wails adapter implementations. Migrate the 4 remaining direct calls to use IPC. The `App` interface reduces to `Quit()` and `Logger()` only.
|
||||
|
||||
---
|
||||
|
||||
## Package Design
|
||||
|
||||
### 1. pkg/contextmenu
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Add(name string, menu ContextMenuDef) error
|
||||
Remove(name string) error
|
||||
Get(name string) (*ContextMenuDef, bool)
|
||||
GetAll() map[string]ContextMenuDef
|
||||
}
|
||||
```
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type ContextMenuDef struct {
|
||||
Name string `json:"name"`
|
||||
Items []MenuItemDef `json:"items"`
|
||||
}
|
||||
|
||||
type MenuItemDef struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu"
|
||||
Accelerator string `json:"accelerator,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"` // nil = true (default)
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"` // identifies which item was clicked
|
||||
Items []MenuItemDef `json:"items,omitempty"` // submenu children
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryGet struct{ Name string } // → *ContextMenuDef (nil if not found)
|
||||
type QueryList struct{} // → map[string]ContextMenuDef
|
||||
|
||||
// Tasks
|
||||
type TaskAdd struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
} // → error
|
||||
|
||||
type TaskRemove struct {
|
||||
Name string `json:"name"`
|
||||
} // → error
|
||||
|
||||
// Actions
|
||||
type ActionItemClicked struct {
|
||||
MenuName string `json:"menuName"`
|
||||
ActionID string `json:"actionId"`
|
||||
Data string `json:"data,omitempty"` // from --custom-contextmenu-data CSS
|
||||
}
|
||||
```
|
||||
|
||||
**Service logic:** The Service maintains a `map[string]ContextMenuDef` registry (mirroring the Wails `contextMenus` map). `TaskAdd` calls `platform.Add(name, menu)` — the Wails adapter translates `ContextMenuDef` to `*application.ContextMenu` with `OnClick` callbacks that broadcast `ActionItemClicked` via `s.Core().ACTION()`. `TaskRemove` calls `platform.Remove()` and deletes from registry. `QueryGet` and `QueryList` read from registry.
|
||||
|
||||
**Callback bridging:** The Wails adapter's `OnClick` handler receives `*application.Context` which provides `ContextMenuData()`. The adapter maps each `MenuItemDef.ActionID` to a callback that broadcasts `ActionItemClicked{MenuName, ActionID, ctx.ContextMenuData()}`.
|
||||
|
||||
**Wails adapter:** Wraps `app.ContextMenu.Add()`, `app.ContextMenu.Remove()`, `app.ContextMenu.Get()`, `app.ContextMenu.GetAll()`. Translates between our `ContextMenuDef`/`MenuItemDef` types and Wails `*ContextMenu`/`*MenuItem`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**WS bridge events:** `contextmenu.item-clicked` (broadcast with menuName, actionId, data). TS apps call `contextmenu:add`, `contextmenu:remove`, `contextmenu:get`, `contextmenu:list` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New HandleIPCEvents Case
|
||||
|
||||
```go
|
||||
case contextmenu.ActionItemClicked:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventContextMenuClick,
|
||||
Data: map[string]any{
|
||||
"menuName": m.MenuName,
|
||||
"actionId": m.ActionID,
|
||||
"data": m.Data,
|
||||
}})
|
||||
}
|
||||
```
|
||||
|
||||
### New EventType Constant
|
||||
|
||||
```go
|
||||
EventContextMenuClick EventType = "contextmenu.item-clicked"
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "contextmenu:add":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
menuJSON, _ := json.Marshal(msg.Data["menu"])
|
||||
var menuDef contextmenu.ContextMenuDef
|
||||
_ = json.Unmarshal(menuJSON, &menuDef)
|
||||
result, handled, err = s.Core().PERFORM(contextmenu.TaskAdd{
|
||||
Name: name, Menu: menuDef,
|
||||
})
|
||||
case "contextmenu:remove":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
result, handled, err = s.Core().PERFORM(contextmenu.TaskRemove{Name: name})
|
||||
case "contextmenu:get":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
result, handled, err = s.Core().QUERY(contextmenu.QueryGet{Name: name})
|
||||
case "contextmenu:list":
|
||||
result, handled, err = s.Core().QUERY(contextmenu.QueryList{})
|
||||
```
|
||||
|
||||
### IDE Command Migration
|
||||
|
||||
Replace direct `s.app.Event().Emit()` calls with IPC Actions:
|
||||
|
||||
```go
|
||||
// New Action in pkg/display/messages.go
|
||||
type ActionIDECommand struct {
|
||||
Command string `json:"command"` // "save", "run", "build"
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Before (stale):
|
||||
func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") }
|
||||
func (s *Service) handleRun() { s.app.Event().Emit("ide:run") }
|
||||
func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") }
|
||||
|
||||
// After (IPC):
|
||||
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
|
||||
func (s *Service) handleRun() { _ = s.Core().ACTION(ActionIDECommand{Command: "run"}) }
|
||||
func (s *Service) handleBuild() { _ = s.Core().ACTION(ActionIDECommand{Command: "build"}) }
|
||||
```
|
||||
|
||||
The `HandleIPCEvents` bridges these to WS:
|
||||
|
||||
```go
|
||||
case ActionIDECommand:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventIDECommand,
|
||||
Data: map[string]any{"command": m.Command}})
|
||||
}
|
||||
```
|
||||
|
||||
### handleOpenFile Migration
|
||||
|
||||
```go
|
||||
// Before (stale — uses s.app.Dialog() directly):
|
||||
func (s *Service) handleOpenFile() {
|
||||
dialog := s.app.Dialog().OpenFile()
|
||||
dialog.SetTitle("Open File")
|
||||
// ...
|
||||
}
|
||||
|
||||
// After (IPC):
|
||||
func (s *Service) handleOpenFile() {
|
||||
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
|
||||
Opts: dialog.OpenFileOptions{
|
||||
Title: "Open File",
|
||||
AllowMultiple: false,
|
||||
},
|
||||
})
|
||||
if err != nil || !handled {
|
||||
return
|
||||
}
|
||||
paths, ok := result.([]string)
|
||||
if !ok || len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
window.WithName("editor"),
|
||||
window.WithTitle(paths[0] + " - Editor"),
|
||||
window.WithURL("/#/developer/editor?file=" + paths[0]),
|
||||
window.WithSize(1200, 800),
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Removed Types
|
||||
|
||||
After migration, remove from `interfaces.go`:
|
||||
- `DialogManager` interface
|
||||
- `EnvManager` interface
|
||||
- `EventManager` interface
|
||||
- `wailsDialogManager` struct + methods
|
||||
- `wailsEnvManager` struct + methods
|
||||
- `wailsEventManager` struct + methods
|
||||
- `App.Dialog()`, `App.Env()`, `App.Event()` methods from interface
|
||||
|
||||
The `App` interface reduces to:
|
||||
|
||||
```go
|
||||
type App interface {
|
||||
Logger() Logger
|
||||
Quit()
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/contextmenu (message types only)
|
||||
├── imports pkg/dialog (message types — for handleOpenFile migration)
|
||||
├── ... existing imports ...
|
||||
|
||||
pkg/contextmenu (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestContextMenu_AddAndClick_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(contextmenu.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(contextmenu.TaskAdd{
|
||||
Name: "file-menu",
|
||||
Menu: contextmenu.ContextMenuDef{
|
||||
Name: "file-menu",
|
||||
Items: []contextmenu.MenuItemDef{
|
||||
{Label: "Open", ActionID: "open"},
|
||||
{Label: "Delete", ActionID: "delete"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Simulate click via mock
|
||||
mock.simulateClick("file-menu", "open", "file-123")
|
||||
|
||||
// Verify action was broadcast
|
||||
}
|
||||
|
||||
func TestContextMenu_Remove_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(contextmenu.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Add then remove
|
||||
_, _, _ = c.PERFORM(contextmenu.TaskAdd{Name: "test", Menu: contextmenu.ContextMenuDef{Name: "test"}})
|
||||
_, handled, err := c.PERFORM(contextmenu.TaskRemove{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify removed
|
||||
result, _, _ := c.QUERY(contextmenu.QueryGet{Name: "test"})
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Dynamic menu updates**: `TaskUpdate` to modify items on an existing menu (SetEnabled, SetLabel, SetChecked). Currently requires remove + re-add.
|
||||
- **Per-window context menus**: Context menus are global — scoping to specific windows would require window ID in the registration.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
# CoreGUI Spec E: MCP Bridge & WebView Service
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add `pkg/webview` service wrapping `go-webview` CDP client, add `pkg/mcp` MCP display subsystem exposing all GUI packages as MCP tools, update display orchestrator for webview event bridging
|
||||
|
||||
## Context
|
||||
|
||||
Specs A–D extracted 14 packages covering all 11 Wails v3 Manager APIs through IPC. The old `core-gui` repo (archived) had an MCP bridge (`mcp_bridge.go`, 1136 lines) exposing ~90 tools via HTTP REST. Two gaps remain:
|
||||
|
||||
1. **WebView interaction** — JS eval, console capture, DOM queries, screenshots, network inspection — has no core.Service wrapper
|
||||
2. **MCP tool layer** — no way for Claude Code or other MCP clients to call the GUI IPC packages
|
||||
|
||||
This spec closes both gaps by:
|
||||
- Wrapping `go-webview` (CDP client) as `pkg/webview` — a core.Service with IPC messages
|
||||
- Creating `pkg/mcp` — an MCP subsystem that translates tool calls to IPC messages across all 15 packages
|
||||
|
||||
The old bridge's non-GUI tools (file ops, process mgmt, lang detect) already live in `core/mcp` via `go-io`, `go-process`, and `go-i18n` — they are not ported here.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dependency Direction
|
||||
|
||||
```
|
||||
core/gui/pkg/mcp (MCP subsystem)
|
||||
├── imports core/go (Core, PERFORM, QUERY)
|
||||
├── imports core/gui/pkg/* (IPC message types only)
|
||||
├── imports github.com/modelcontextprotocol/go-sdk/mcp (MCP SDK)
|
||||
└── structurally satisfies core/mcp Subsystem interface (zero import)
|
||||
|
||||
core/gui/pkg/webview (new service)
|
||||
├── imports core/go (ServiceRuntime, IPC)
|
||||
├── imports forge.lthn.ai/core/go-webview (CDP client)
|
||||
└── no Platform interface (go-webview IS the abstraction)
|
||||
```
|
||||
|
||||
No circular dependencies. `core/mcp` never imports `core/gui` — structural typing satisfies the `Subsystem` interface. The consumer wires the subsystem at the application level.
|
||||
|
||||
### Integration Point
|
||||
|
||||
The consumer (e.g. `cmd/core-gui`) creates the subsystem and passes it to `core/mcp`:
|
||||
|
||||
```go
|
||||
guiSub := guimcp.New(coreInstance)
|
||||
mcpSvc, _ := coremcp.New(coremcp.WithSubsystem(guiSub))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package Design
|
||||
|
||||
### 1. pkg/webview
|
||||
|
||||
Wraps `go-webview` (Chrome DevTools Protocol client) as a core.Service. No Platform interface — `go-webview` already abstracts Chrome/Chromium via CDP WebSocket. The service manages a map of window name → `*webview.Webview` connections, lazily created on first access.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
||||
Timeout time.Duration // Operation timeout (default: 30s)
|
||||
ConsoleLimit int // Max console messages per window (default: 1000)
|
||||
}
|
||||
```
|
||||
|
||||
**Register factory:**
|
||||
|
||||
```go
|
||||
func Register(opts ...func(*Options)) func(*core.Core) (any, error)
|
||||
```
|
||||
|
||||
No Platform interface — the service creates `go-webview.Webview` instances directly using the configured debug URL. Each window gets its own CDP connection, managed in `connections map[string]*webview.Webview`.
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries (read-only)
|
||||
type QueryURL struct{ Window string `json:"window"` }
|
||||
type QueryTitle struct{ Window string `json:"window"` }
|
||||
type QueryConsole struct{ Window string `json:"window"`; Level string `json:"level,omitempty"`; Limit int `json:"limit,omitempty"` }
|
||||
type QuerySelector struct{ Window string `json:"window"`; Selector string `json:"selector"` }
|
||||
type QuerySelectorAll struct{ Window string `json:"window"`; Selector string `json:"selector"` }
|
||||
type QueryDOMTree struct{ Window string `json:"window"`; Selector string `json:"selector,omitempty"` }
|
||||
|
||||
// Tasks (side-effects)
|
||||
type TaskEvaluate struct{ Window string `json:"window"`; Script string `json:"script"` }
|
||||
type TaskClick struct{ Window string `json:"window"`; Selector string `json:"selector"` }
|
||||
type TaskType struct{ Window string `json:"window"`; Selector string `json:"selector"`; Text string `json:"text"` }
|
||||
type TaskNavigate struct{ Window string `json:"window"`; URL string `json:"url"` }
|
||||
type TaskScreenshot struct{ Window string `json:"window"` } // → ScreenshotResult{Base64 string, MimeType string}
|
||||
type TaskScroll struct{ Window string `json:"window"`; X int `json:"x"`; Y int `json:"y"` } // X, Y are absolute scroll position (window.scrollTo)
|
||||
type TaskHover struct{ Window string `json:"window"`; Selector string `json:"selector"` }
|
||||
type TaskSelect struct{ Window string `json:"window"`; Selector string `json:"selector"`; Value string `json:"value"` }
|
||||
type TaskCheck struct{ Window string `json:"window"`; Selector string `json:"selector"`; Checked bool `json:"checked"` }
|
||||
type TaskUploadFile struct{ Window string `json:"window"`; Selector string `json:"selector"`; Paths []string `json:"paths"` }
|
||||
type TaskSetViewport struct{ Window string `json:"window"`; Width int `json:"width"`; Height int `json:"height"` }
|
||||
type TaskClearConsole struct{ Window string `json:"window"` }
|
||||
|
||||
// Actions (broadcast)
|
||||
type ActionConsoleMessage struct{ Window string `json:"window"`; Message ConsoleMessage `json:"message"` }
|
||||
type ActionException struct{ Window string `json:"window"`; Exception ExceptionInfo `json:"exception"` }
|
||||
```
|
||||
|
||||
**Our own types** (decoupled from go-webview — field names normalised from go-webview's `LineNumber`→`Line`, `ColumnNumber`→`Column`):
|
||||
|
||||
```go
|
||||
type ConsoleMessage struct {
|
||||
Type string `json:"type"` // "log", "warn", "error", "info", "debug"
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Line int `json:"line,omitempty"` // go-webview: Line (same)
|
||||
Column int `json:"column,omitempty"` // go-webview: Column (same)
|
||||
}
|
||||
|
||||
type ElementInfo struct {
|
||||
TagName string `json:"tagName"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
InnerText string `json:"innerText,omitempty"`
|
||||
InnerHTML string `json:"innerHTML,omitempty"`
|
||||
BoundingBox *BoundingBox `json:"boundingBox,omitempty"`
|
||||
}
|
||||
|
||||
type BoundingBox struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
type ExceptionInfo struct {
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Line int `json:"line,omitempty"` // go-webview: LineNumber
|
||||
Column int `json:"column,omitempty"` // go-webview: ColumnNumber
|
||||
StackTrace string `json:"stackTrace,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ScreenshotResult wraps the raw PNG bytes as base64 for JSON/MCP transport.
|
||||
type ScreenshotResult struct {
|
||||
Base64 string `json:"base64"` // base64-encoded PNG
|
||||
MimeType string `json:"mimeType"` // always "image/png"
|
||||
}
|
||||
```
|
||||
|
||||
**Type conversion:** The service converts between go-webview types and our own types. Key mappings:
|
||||
- `gowebview.ConsoleMessage` → `ConsoleMessage` (field names match)
|
||||
- `gowebview.ExceptionInfo` → `ExceptionInfo` (`LineNumber`→`Line`, `ColumnNumber`→`Column`)
|
||||
- `gowebview.ElementInfo` → `ElementInfo` (field names match)
|
||||
- `Screenshot() ([]byte, error)` → `ScreenshotResult{Base64: base64.StdEncoding.EncodeToString(png), MimeType: "image/png"}`
|
||||
|
||||
**Service logic:** The service registers IPC handlers for all Query/Task types. On each call, it looks up (or lazily creates) the `*webview.Webview` connection for the named window, delegates to go-webview, and converts the result to our own types. Console watchers broadcast `ActionConsoleMessage` via `s.Core().ACTION()`. Exception watchers broadcast `ActionException`.
|
||||
|
||||
**Window-name → CDP-target mapping:** Each Wails WebviewWindow runs as a separate CDP target. On first access for a window name, the service calls `go-webview.ListTargets(debugURL)` to enumerate all browser targets, matches by page title or URL containing the window name, and creates a `*webview.Webview` connected to that target's WebSocket URL. If no match is found, falls back to the first available page target (single-window mode).
|
||||
|
||||
**Prerequisite:** `go-webview.ListTargets()` currently returns an unexported `targetInfo` type. Before implementation, export this as `TargetInfo` in go-webview (one-line rename, non-breaking since the function is newly introduced).
|
||||
|
||||
**Connection lifecycle:** Connections are created on first access via the target mapping above and cached in `connections map[string]*webview.Webview`. When a window is closed (detected via `window.ActionWindowClosed` IPC event), the corresponding webview connection is closed and removed from the map. The service implements `Stoppable` to close all connections on shutdown.
|
||||
|
||||
**Config:** None persisted — runtime options only.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/mcp
|
||||
|
||||
MCP display subsystem that registers GUI tools with an MCP server. Implements `Name() string` and `RegisterTools(server *mcp.Server)` — structurally satisfying `core/mcp`'s `Subsystem` interface without importing it.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```go
|
||||
func New(c *core.Core) *Subsystem
|
||||
```
|
||||
|
||||
**Subsystem struct:**
|
||||
|
||||
```go
|
||||
type Subsystem struct {
|
||||
core *core.Core
|
||||
}
|
||||
|
||||
func (s *Subsystem) Name() string { return "display" }
|
||||
|
||||
func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
||||
s.registerWebviewTools(server)
|
||||
s.registerWindowTools(server)
|
||||
s.registerLayoutTools(server)
|
||||
s.registerScreenTools(server)
|
||||
s.registerClipboardTools(server)
|
||||
s.registerDialogTools(server)
|
||||
s.registerNotificationTools(server)
|
||||
s.registerTrayTools(server)
|
||||
s.registerEnvironmentTools(server)
|
||||
s.registerBrowserTools(server)
|
||||
s.registerContextMenuTools(server)
|
||||
s.registerKeybindingTools(server)
|
||||
s.registerDockTools(server)
|
||||
s.registerLifecycleTools(server)
|
||||
}
|
||||
```
|
||||
|
||||
**Tool files and counts:**
|
||||
|
||||
| File | Tool Count | Tools | IPC Target |
|
||||
|------|-----------|-------|-----------|
|
||||
| `tools_webview.go` | 18 | `webview_eval`, `webview_click`, `webview_type`, `webview_navigate`, `webview_screenshot` (returns base64 PNG), `webview_scroll`, `webview_hover`, `webview_select`, `webview_check`, `webview_upload`, `webview_viewport`, `webview_console`, `webview_console_clear`, `webview_query`, `webview_query_all`, `webview_dom_tree`, `webview_url`, `webview_title` | `pkg/webview` |
|
||||
| `tools_window.go` | 15 | `window_list`, `window_get`, `window_focused`, `window_create`, `window_close`, `window_position`, `window_size`, `window_bounds`, `window_maximize`, `window_minimize`, `window_restore`, `window_focus`, `window_title`, `window_visibility`, `window_fullscreen` | `pkg/window` |
|
||||
| `tools_layout.go` | 7 | `layout_save`, `layout_restore`, `layout_list`, `layout_delete`, `layout_get`, `layout_tile`, `layout_snap` | `pkg/window` |
|
||||
| `tools_screen.go` | 5 | `screen_list`, `screen_get`, `screen_primary`, `screen_at_point`, `screen_work_areas` | `pkg/screen` |
|
||||
| `tools_clipboard.go` | 4 | `clipboard_read`, `clipboard_write`, `clipboard_has`, `clipboard_clear` | `pkg/clipboard` |
|
||||
| `tools_dialog.go` | 5 | `dialog_open_file`, `dialog_save_file`, `dialog_open_directory`, `dialog_confirm`, `dialog_prompt` | `pkg/dialog` |
|
||||
| `tools_notification.go` | 3 | `notification_show`, `notification_permission_request`, `notification_permission_check` | `pkg/notification` |
|
||||
| `tools_tray.go` | 4 | `tray_set_icon`, `tray_set_tooltip`, `tray_set_label`, `tray_info` | `pkg/systray` |
|
||||
| `tools_environment.go` | 2 | `theme_get`, `theme_system` | `pkg/environment` |
|
||||
| `tools_browser.go` | 1 | `browser_open_url` | `pkg/browser` |
|
||||
| `tools_contextmenu.go` | 4 | `contextmenu_add`, `contextmenu_remove`, `contextmenu_get`, `contextmenu_list` | `pkg/contextmenu` |
|
||||
| `tools_keybinding.go` | 2 | `keybinding_add`, `keybinding_remove` | `pkg/keybinding` |
|
||||
| `tools_dock.go` | 3 | `dock_show`, `dock_hide`, `dock_badge` | `pkg/dock` |
|
||||
| `tools_lifecycle.go` | 1 | `app_quit` | `pkg/lifecycle` |
|
||||
|
||||
**~74 tools total.**
|
||||
|
||||
**Tool handler pattern:** Each handler is a thin IPC translation. Typed Input/Output structs with JSON tags, same pattern as `core/mcp`'s file tool handlers:
|
||||
|
||||
```go
|
||||
type WebviewEvalInput struct {
|
||||
Window string `json:"window"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
type WebviewEvalOutput struct {
|
||||
Result any `json:"result"`
|
||||
Window string `json:"window"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) webviewEval(ctx context.Context, req *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) {
|
||||
result, _, err := s.core.PERFORM(webview.TaskEvaluate{
|
||||
Window: input.Window,
|
||||
Script: input.Script,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, WebviewEvalOutput{}, err
|
||||
}
|
||||
return nil, WebviewEvalOutput{Result: result, Window: input.Window}, nil
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type ClipboardReadInput struct{}
|
||||
|
||||
type ClipboardReadOutput struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) clipboardRead(ctx context.Context, req *mcp.CallToolRequest, input ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, error) {
|
||||
result, _, err := s.core.QUERY(clipboard.QueryText{})
|
||||
if err != nil {
|
||||
return nil, ClipboardReadOutput{}, err
|
||||
}
|
||||
content, _ := result.(string)
|
||||
return nil, ClipboardReadOutput{Content: content}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Tool registration** uses `mcp.AddTool()` from the MCP SDK with typed handler functions:
|
||||
|
||||
```go
|
||||
func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "clipboard_read",
|
||||
Description: "Read the current clipboard content",
|
||||
}, s.clipboardRead)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "clipboard_write",
|
||||
Description: "Write text to the clipboard",
|
||||
}, s.clipboardWrite)
|
||||
|
||||
// ... etc
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
case webview.ActionConsoleMessage:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventWebviewConsole, Window: m.Window,
|
||||
Data: map[string]any{"message": m.Message}})
|
||||
}
|
||||
case webview.ActionException:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventWebviewException, Window: m.Window,
|
||||
Data: map[string]any{"exception": m.Exception}})
|
||||
}
|
||||
```
|
||||
|
||||
### New EventType Constants
|
||||
|
||||
```go
|
||||
EventWebviewConsole EventType = "webview.console"
|
||||
EventWebviewException EventType = "webview.exception"
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "webview:eval":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
script, _ := msg.Data["script"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskEvaluate{Window: window, Script: script})
|
||||
case "webview:click":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskClick{Window: window, Selector: selector})
|
||||
case "webview:type":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
text, _ := msg.Data["text"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskType{Window: window, Selector: selector, Text: text})
|
||||
case "webview:navigate":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
url, _ := msg.Data["url"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskNavigate{Window: window, URL: url})
|
||||
case "webview:screenshot":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskScreenshot{Window: window})
|
||||
case "webview:console":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
level, _ := msg.Data["level"].(string)
|
||||
limit := 100
|
||||
if l, ok := msg.Data["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
result, handled, err = s.Core().QUERY(webview.QueryConsole{Window: window, Level: level, Limit: limit})
|
||||
case "webview:query":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
result, handled, err = s.Core().QUERY(webview.QuerySelector{Window: window, Selector: selector})
|
||||
case "webview:url":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
result, handled, err = s.Core().QUERY(webview.QueryURL{Window: window})
|
||||
case "webview:title":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: window})
|
||||
case "webview:viewport":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
width, _ := msg.Data["width"].(float64)
|
||||
height, _ := msg.Data["height"].(float64)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskSetViewport{Window: window, Width: int(width), Height: int(height)})
|
||||
case "webview:clear-console":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: window})
|
||||
case "webview:query-all":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
result, handled, err = s.Core().QUERY(webview.QuerySelectorAll{Window: window, Selector: selector})
|
||||
case "webview:dom-tree":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
result, handled, err = s.Core().QUERY(webview.QueryDOMTree{Window: window, Selector: selector})
|
||||
case "webview:hover":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskHover{Window: window, Selector: selector})
|
||||
case "webview:select":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
value, _ := msg.Data["value"].(string)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskSelect{Window: window, Selector: selector, Value: value})
|
||||
case "webview:check":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
checked, _ := msg.Data["checked"].(bool)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskCheck{Window: window, Selector: selector, Checked: checked})
|
||||
case "webview:upload":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
selector, _ := msg.Data["selector"].(string)
|
||||
pathsRaw, _ := msg.Data["paths"].([]any)
|
||||
var paths []string
|
||||
for _, p := range pathsRaw {
|
||||
if s, ok := p.(string); ok {
|
||||
paths = append(paths, s)
|
||||
}
|
||||
}
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskUploadFile{Window: window, Selector: selector, Paths: paths})
|
||||
case "webview:scroll":
|
||||
window, _ := msg.Data["window"].(string)
|
||||
x, _ := msg.Data["x"].(float64)
|
||||
y, _ := msg.Data["y"].(float64)
|
||||
result, handled, err = s.Core().PERFORM(webview.TaskScroll{Window: window, X: int(x), Y: int(y)})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### pkg/webview
|
||||
|
||||
Mock the go-webview client behind a thin interface internal to the package for testing. The interface uses our own types (not go-webview types) to keep tests decoupled:
|
||||
|
||||
```go
|
||||
type connector interface {
|
||||
Navigate(url string) error
|
||||
Click(selector string) error
|
||||
Type(selector, text string) error
|
||||
Hover(selector string) error
|
||||
Select(selector, value string) error
|
||||
Check(selector string, checked bool) error
|
||||
Evaluate(script string) (any, error)
|
||||
Screenshot() ([]byte, error)
|
||||
GetURL() (string, error)
|
||||
GetTitle() (string, error)
|
||||
GetHTML(selector string) (string, error) // used internally by QueryDOMTree
|
||||
QuerySelector(selector string) (*ElementInfo, error)
|
||||
QuerySelectorAll(selector string) ([]*ElementInfo, error)
|
||||
GetConsole() []ConsoleMessage
|
||||
ClearConsole()
|
||||
SetViewport(width, height int) error
|
||||
UploadFile(selector string, paths []string) error
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
The real implementation wraps `*gowebview.Webview`, converting go-webview types to our own types at the boundary. Tests inject a mock connector:
|
||||
|
||||
```go
|
||||
func TestWebview_Evaluate_Good(t *testing.T) {
|
||||
mock := &mockConnector{evalResult: "hello"}
|
||||
svc := newTestService(mock)
|
||||
c, _ := core.New(core.WithService(svc), core.WithServiceLock())
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
result, handled, err := c.PERFORM(webview.TaskEvaluate{Window: "main", Script: "1+1"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "hello", result)
|
||||
}
|
||||
|
||||
func TestWebview_Console_Good(t *testing.T) {
|
||||
mock := &mockConnector{consoleMessages: []webview.ConsoleMessage{
|
||||
{Type: "log", Text: "hello"},
|
||||
{Type: "error", Text: "oops"},
|
||||
}}
|
||||
svc := newTestService(mock)
|
||||
c, _ := core.New(core.WithService(svc), core.WithServiceLock())
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
result, _, err := c.QUERY(webview.QueryConsole{Window: "main", Level: "error", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
msgs, _ := result.([]webview.ConsoleMessage)
|
||||
assert.Len(t, msgs, 1)
|
||||
assert.Equal(t, "oops", msgs[0].Text)
|
||||
}
|
||||
|
||||
func TestWebview_ConnectionCleanup_Good(t *testing.T) {
|
||||
mock := &mockConnector{}
|
||||
svc := newTestService(mock)
|
||||
c, _ := core.New(core.WithService(svc), core.WithServiceLock())
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Access creates connection
|
||||
_, _, _ = c.QUERY(webview.QueryURL{Window: "main"})
|
||||
assert.True(t, mock.connected)
|
||||
|
||||
// Window close action triggers cleanup
|
||||
_ = c.ACTION(window.ActionWindowClosed{Name: "main"})
|
||||
assert.True(t, mock.closed)
|
||||
}
|
||||
```
|
||||
|
||||
### pkg/mcp
|
||||
|
||||
Test that tool handlers correctly translate to IPC calls. Since handlers are unexported (passed to `mcp.AddTool`), tests verify at the IPC level — register real sub-services with mock platforms, create the subsystem, and verify the IPC round-trip works:
|
||||
|
||||
```go
|
||||
func TestMCP_ClipboardRead_Good(t *testing.T) {
|
||||
mockClip := &mockClipboardPlatform{content: "hello"}
|
||||
c, _ := core.New(
|
||||
core.WithService(clipboard.Register(mockClip)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Verify the IPC path that the MCP handler would use
|
||||
result, handled, err := c.QUERY(clipboard.QueryText{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
content, _ := result.(string)
|
||||
assert.Equal(t, "hello", content)
|
||||
}
|
||||
|
||||
func TestMCP_WindowList_Good(t *testing.T) {
|
||||
mockWin := &mockWindowPlatform{windows: []window.Info{{Name: "main"}}}
|
||||
c, _ := core.New(
|
||||
core.WithService(window.Register(mockWin)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Verify the IPC path that the MCP handler would use
|
||||
result, handled, err := c.QUERY(window.QueryList{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
windows, _ := result.([]window.Info)
|
||||
assert.Len(t, windows, 1)
|
||||
}
|
||||
|
||||
func TestMCP_RegisterTools_Good(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
sub := guimcp.New(c)
|
||||
assert.Equal(t, "display", sub.Name())
|
||||
// RegisterTools requires a real mcp.Server — verify it does not panic
|
||||
assert.NotPanics(t, func() { sub.RegisterTools(mcp.NewServer(nil, nil)) })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Performance metrics**: `QueryPerformance` — requires direct CDP `Performance.getMetrics` call not yet exposed by go-webview's public API. Add when go-webview gains this.
|
||||
- **Resource listing**: `QueryResources` — requires CDP `Page.getResourceTree`, not yet in go-webview. Add when go-webview gains this.
|
||||
- **Network inspection**: `QueryNetwork` / `TaskClearNetwork` — requires CDP `Network` domain enablement and request tracking, not yet in go-webview. The existing `InjectNetworkInterceptor` is for request modification, not read-only inspection.
|
||||
- **WebView action sequences**: Chaining multiple webview actions (click → wait → type → screenshot) as a single MCP tool call. Currently requires multiple sequential tool calls.
|
||||
- **Angular helpers**: `go-webview`'s `AngularHelper` provides SPA-specific testing utilities — useful for the Angular frontend but not needed for initial MCP bridge.
|
||||
- **Multi-tab CDP**: `go-webview`'s `CDPClient.NewTab()` could map to window creation for headless browser automation scenarios.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
# CoreGUI Spec B: New Input
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add keybinding and browser packages as core.Services, enhance window service with file drop events
|
||||
|
||||
## Context
|
||||
|
||||
Spec A extracted 5 packages from display. This spec adds input capabilities not yet present in core/gui: keyboard shortcuts, browser delegation, and file drop events. Each follows the three-layer pattern (IPC Bus → Service → Platform Interface).
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Packages
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/keybinding` | `Add()`, `Remove()`, `GetAll()` | Task add/remove, Query list, Action triggered |
|
||||
| `pkg/browser` | `OpenURL()`, `OpenFile()` | Tasks (side-effects) |
|
||||
|
||||
### Window Service Enhancement
|
||||
|
||||
File drop is not a separate package — it extends `pkg/window` with an `OnFileDrop` callback on `PlatformWindow` and a new `ActionFilesDropped` broadcast.
|
||||
|
||||
---
|
||||
|
||||
## Package Designs
|
||||
|
||||
### 1. pkg/keybinding
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Add(accelerator string, handler func()) error
|
||||
Remove(accelerator string) error
|
||||
GetAll() []string
|
||||
}
|
||||
```
|
||||
|
||||
Platform-aware accelerator syntax: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux). Special keys: `F1-F12`, `Escape`, `Enter`, `Space`, `Tab`, `Backspace`, `Delete`, arrow keys.
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type BindingInfo struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryList struct{} // → []BindingInfo
|
||||
|
||||
// Tasks
|
||||
type TaskAdd struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
} // → error
|
||||
|
||||
type TaskRemove struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
} // → error
|
||||
|
||||
// Actions
|
||||
type ActionTriggered struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
```
|
||||
|
||||
**Service logic:** The Service maintains a `map[string]BindingInfo` registry. When `TaskAdd` is received, it returns `ErrAlreadyRegistered` if the accelerator exists (callers must `TaskRemove` first to rebind). Otherwise, the Service calls `platform.Add(accelerator, callback)` where the callback broadcasts `ActionTriggered` via `s.Core().ACTION()`. `QueryList` reads from the in-memory registry (not `platform.GetAll()`) — `platform.GetAll()` is for adapter-level reconciliation only. The display orchestrator bridges `ActionTriggered` → `Event{Type: "keybinding.triggered"}` for WS clients.
|
||||
|
||||
**Wails adapter:** Wraps `app.KeyBinding.Add()`, `app.KeyBinding.Remove()`, `app.KeyBinding.GetAll()`.
|
||||
|
||||
**Config:** None — bindings are registered programmatically.
|
||||
|
||||
**WS bridge events:** `keybinding.triggered` (broadcast). TS apps call `keybinding:add`, `keybinding:remove`, `keybinding:list` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/browser
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
OpenURL(url string) error
|
||||
OpenFile(path string) error
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages (all Tasks):**
|
||||
|
||||
```go
|
||||
type TaskOpenURL struct{ URL string `json:"url"` } // → error
|
||||
type TaskOpenFile struct{ Path string `json:"path"` } // → error
|
||||
```
|
||||
|
||||
**Wails adapter:** Wraps `app.Browser.OpenURL()` and `app.Browser.OpenFile()`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**WS bridge:** No actions. TS apps call `browser:open-url`, `browser:open-file` via WS→IPC Tasks.
|
||||
|
||||
---
|
||||
|
||||
### 3. Window Service Enhancement — File Drop
|
||||
|
||||
**PlatformWindow interface addition:**
|
||||
|
||||
```go
|
||||
// Added to existing PlatformWindow interface in pkg/window/platform.go
|
||||
OnFileDrop(handler func(paths []string, targetID string))
|
||||
```
|
||||
|
||||
**New Action in pkg/window/messages.go:**
|
||||
|
||||
```go
|
||||
type ActionFilesDropped struct {
|
||||
Name string `json:"name"` // window name
|
||||
Paths []string `json:"paths"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Service wiring:** The existing `trackWindow()` method in `pkg/window/service.go` gains a call to `pw.OnFileDrop()` that broadcasts `ActionFilesDropped`. Adding `OnFileDrop` to `PlatformWindow` is a breaking interface change — `MockWindow` in `mock_platform.go` and the Wails adapter must both gain no-op stubs.
|
||||
|
||||
**WS bridge:** Display orchestrator adds a case for `window.ActionFilesDropped` → `Event{Type: "window.filedrop"}`.
|
||||
|
||||
**Note:** File drop is opt-in per window via the existing `EnableFileDrop` field in `PlatformWindowOptions`. The HTML target element uses `data-file-drop-target` attribute (Wails v3 convention). HTML5 internal drag-and-drop is purely frontend (JS/CSS) — no Go package needed.
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
case keybinding.ActionTriggered:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: "keybinding.triggered",
|
||||
Data: map[string]any{"accelerator": m.Accelerator}})
|
||||
}
|
||||
case window.ActionFilesDropped:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: "window.filedrop", Window: m.Name,
|
||||
Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
|
||||
}
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "keybinding:add":
|
||||
accelerator, _ := msg.Data["accelerator"].(string)
|
||||
description, _ := msg.Data["description"].(string)
|
||||
result, handled, err = s.Core().PERFORM(keybinding.TaskAdd{
|
||||
Accelerator: accelerator, Description: description,
|
||||
})
|
||||
case "keybinding:remove":
|
||||
accelerator, _ := msg.Data["accelerator"].(string)
|
||||
result, handled, err = s.Core().PERFORM(keybinding.TaskRemove{
|
||||
Accelerator: accelerator,
|
||||
})
|
||||
case "keybinding:list":
|
||||
result, handled, err = s.Core().QUERY(keybinding.QueryList{})
|
||||
case "browser:open-url":
|
||||
url, _ := msg.Data["url"].(string)
|
||||
result, handled, err = s.Core().PERFORM(browser.TaskOpenURL{URL: url})
|
||||
case "browser:open-file":
|
||||
path, _ := msg.Data["path"].(string)
|
||||
result, handled, err = s.Core().PERFORM(browser.TaskOpenFile{Path: path})
|
||||
```
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/keybinding (message types only)
|
||||
├── imports pkg/browser (message types only)
|
||||
└── ... existing imports ...
|
||||
|
||||
pkg/keybinding (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
|
||||
pkg/browser (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestKeybinding_AddAndTrigger_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(keybinding.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(keybinding.TaskAdd{
|
||||
Accelerator: "Ctrl+S", Description: "Save",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Simulate shortcut trigger via mock
|
||||
mock.trigger("Ctrl+S")
|
||||
|
||||
// Verify action was broadcast (captured via registered handler)
|
||||
}
|
||||
|
||||
func TestBrowser_OpenURL_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(browser.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(browser.TaskOpenURL{URL: "https://example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "https://example.com", mock.lastURL)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Per-window keybindings**: Currently global only. Per-window scoping requires handler-receives-window pattern.
|
||||
- **Browser fallback**: Copy URL to clipboard when browser open fails.
|
||||
- **HTML5 drag-drop helpers**: TS SDK utilities for internal drag-drop — purely frontend.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
# CoreGUI Spec C: Platform & Events
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add dock/badge and application lifecycle event packages as core.Services
|
||||
|
||||
## Context
|
||||
|
||||
Spec A extracted display features, Spec B added input capabilities. This spec covers platform-specific features (dock icon, badge) and application lifecycle events — the remaining Wails v3 features needed for full IPC coverage.
|
||||
|
||||
Cursor management is excluded — CSS `cursor:` property handles it in the webview without Go involvement.
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Packages
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/dock` | `ShowIcon()`, `HideIcon()`, `SetBadge()`, `RemoveBadge()` | Tasks (mutations), Query visibility |
|
||||
| `pkg/lifecycle` | `OnApplicationEvent()` | Actions broadcast for each event type |
|
||||
|
||||
---
|
||||
|
||||
## Package Designs
|
||||
|
||||
### 1. pkg/dock
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
ShowIcon() error
|
||||
HideIcon() error
|
||||
SetBadge(label string) error
|
||||
RemoveBadge() error
|
||||
IsVisible() bool
|
||||
}
|
||||
```
|
||||
|
||||
macOS: dock icon show/hide + badge. Windows: taskbar badge only (show/hide not supported). Linux: not supported — adapter returns nil. Platform adapter returns nil on unsupported OS.
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryVisible struct{} // → bool
|
||||
|
||||
// Tasks
|
||||
type TaskShowIcon struct{} // → error
|
||||
type TaskHideIcon struct{} // → error
|
||||
type TaskSetBadge struct{ Label string } // → error
|
||||
type TaskRemoveBadge struct{} // → error
|
||||
|
||||
// Actions
|
||||
type ActionVisibilityChanged struct{ Visible bool }
|
||||
```
|
||||
|
||||
**Badge conventions:**
|
||||
- Empty string `""`: default system badge indicator
|
||||
- Numeric `"3"`, `"99"`: unread count
|
||||
- Text `"New"`, `"Paused"`: brief status labels
|
||||
|
||||
**Wails adapter:** Wraps `app.Dock.HideAppIcon()`, `app.Dock.ShowAppIcon()` (macOS only), and badge APIs.
|
||||
|
||||
**Service logic:** After a successful `TaskShowIcon`, the Service broadcasts `ActionVisibilityChanged{Visible: true}`. After `TaskHideIcon`, broadcasts `ActionVisibilityChanged{Visible: false}`. `QueryVisible` delegates to `platform.IsVisible()`.
|
||||
|
||||
**Config:** None — stateless. No config section required in display orchestrator.
|
||||
|
||||
**WS bridge events:** `dock.visibility-changed` (on show/hide). TS apps call `dock:show`, `dock:hide`, `dock:badge`, `dock:badge-remove`, `dock:visible` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/lifecycle
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
// OnApplicationEvent registers a handler for a fire-and-forget event type.
|
||||
// Events that carry data (e.g. file path) use dedicated methods.
|
||||
OnApplicationEvent(eventType EventType, handler func()) func() // returns cancel
|
||||
OnOpenedWithFile(handler func(path string)) func() // returns cancel
|
||||
}
|
||||
```
|
||||
|
||||
Separate method for file-open because it carries data (the file path).
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type EventType int
|
||||
|
||||
const (
|
||||
EventApplicationStarted EventType = iota
|
||||
EventWillTerminate
|
||||
EventDidBecomeActive
|
||||
EventDidResignActive
|
||||
EventPowerStatusChanged // Windows: APMPowerStatusChange
|
||||
EventSystemSuspend // Windows: APMSuspend
|
||||
EventSystemResume // Windows: APMResume
|
||||
)
|
||||
```
|
||||
|
||||
**IPC messages (all Actions — lifecycle events are broadcasts):**
|
||||
|
||||
```go
|
||||
type ActionApplicationStarted struct{}
|
||||
type ActionOpenedWithFile struct{ Path string }
|
||||
type ActionWillTerminate struct{}
|
||||
type ActionDidBecomeActive struct{}
|
||||
type ActionDidResignActive struct{}
|
||||
type ActionPowerStatusChanged struct{}
|
||||
type ActionSystemSuspend struct{}
|
||||
type ActionSystemResume struct{}
|
||||
```
|
||||
|
||||
**Service logic:** During `OnStartup`, the Service registers a platform callback for each `EventType` and for file-open. Each callback broadcasts the corresponding Action via `s.Core().ACTION()`. `OnShutdown` cancels all registrations.
|
||||
|
||||
**Wails adapter:** Wraps `app.Event.OnApplicationEvent()` for each Wails event type:
|
||||
|
||||
| Our EventType | Wails event | Platforms |
|
||||
|---|---|---|
|
||||
| `EventApplicationStarted` | `events.Common.ApplicationStarted` | all |
|
||||
| `EventWillTerminate` | `events.Mac.ApplicationWillTerminate` | macOS only |
|
||||
| `EventDidBecomeActive` | `events.Mac.ApplicationDidBecomeActive` | macOS only |
|
||||
| `EventDidResignActive` | `events.Mac.ApplicationDidResignActive` | macOS only |
|
||||
| `EventPowerStatusChanged` | `events.Windows.APMPowerStatusChange` | Windows only |
|
||||
| `EventSystemSuspend` | `events.Windows.APMSuspend` | Windows only |
|
||||
| `EventSystemResume` | `events.Windows.APMResume` | Windows only |
|
||||
|
||||
Platform-specific events no-op silently on unsupported OS (adapter registers nothing).
|
||||
|
||||
**Note:** `ActionApplicationStarted` maps to the Wails `ApplicationStarted` event, which fires when the platform application starts. This is distinct from `core.ActionServiceStartup`, which fires after all core.Services complete `OnStartup`. TS clients should use `app.started` for platform-level readiness and subscribe to services individually for service-level readiness.
|
||||
|
||||
**Config:** None — event-driven, no state. No config section required in display orchestrator.
|
||||
|
||||
**WS bridge events:**
|
||||
|
||||
| Action | WS Event Type |
|
||||
|--------|--------------|
|
||||
| `ActionApplicationStarted` | `app.started` |
|
||||
| `ActionOpenedWithFile` | `app.opened-with-file` |
|
||||
| `ActionWillTerminate` | `app.will-terminate` |
|
||||
| `ActionDidBecomeActive` | `app.active` |
|
||||
| `ActionDidResignActive` | `app.inactive` |
|
||||
| `ActionPowerStatusChanged` | `system.power-change` |
|
||||
| `ActionSystemSuspend` | `system.suspend` |
|
||||
| `ActionSystemResume` | `system.resume` |
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New EventType Constants (in `pkg/display/events.go`)
|
||||
|
||||
```go
|
||||
EventDockVisibility EventType = "dock.visibility-changed"
|
||||
EventAppStarted EventType = "app.started"
|
||||
EventAppOpenedWithFile EventType = "app.opened-with-file"
|
||||
EventAppWillTerminate EventType = "app.will-terminate"
|
||||
EventAppActive EventType = "app.active"
|
||||
EventAppInactive EventType = "app.inactive"
|
||||
EventSystemPowerChange EventType = "system.power-change"
|
||||
EventSystemSuspend EventType = "system.suspend"
|
||||
EventSystemResume EventType = "system.resume"
|
||||
```
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
case dock.ActionVisibilityChanged:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventDockVisibility,
|
||||
Data: map[string]any{"visible": m.Visible}})
|
||||
}
|
||||
case lifecycle.ActionApplicationStarted:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppStarted})
|
||||
}
|
||||
case lifecycle.ActionOpenedWithFile:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppOpenedWithFile,
|
||||
Data: map[string]any{"path": m.Path}})
|
||||
}
|
||||
case lifecycle.ActionWillTerminate:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppWillTerminate})
|
||||
}
|
||||
case lifecycle.ActionDidBecomeActive:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppActive})
|
||||
}
|
||||
case lifecycle.ActionDidResignActive:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppInactive})
|
||||
}
|
||||
case lifecycle.ActionPowerStatusChanged:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemPowerChange})
|
||||
}
|
||||
case lifecycle.ActionSystemSuspend:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemSuspend})
|
||||
}
|
||||
case lifecycle.ActionSystemResume:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemResume})
|
||||
}
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "dock:show":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskShowIcon{})
|
||||
case "dock:hide":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskHideIcon{})
|
||||
case "dock:badge":
|
||||
label, _ := msg.Data["label"].(string)
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskSetBadge{Label: label})
|
||||
case "dock:badge-remove":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskRemoveBadge{})
|
||||
case "dock:visible":
|
||||
result, handled, err = s.Core().QUERY(dock.QueryVisible{})
|
||||
```
|
||||
|
||||
Lifecycle events are outbound-only (Actions → WS). No inbound WS→IPC needed.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/dock (message types only)
|
||||
├── imports pkg/lifecycle (message types only)
|
||||
└── ... existing imports ...
|
||||
|
||||
pkg/dock (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
|
||||
pkg/lifecycle (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestDock_SetBadge_Good(t *testing.T) {
|
||||
mock := &mockPlatform{visible: true}
|
||||
c, _ := core.New(
|
||||
core.WithService(dock.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(dock.TaskSetBadge{Label: "3"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "3", mock.lastBadge)
|
||||
}
|
||||
|
||||
func TestLifecycle_BecomeActive_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(lifecycle.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
var received bool
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if _, ok := msg.(lifecycle.ActionDidBecomeActive); ok {
|
||||
received = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
mock.simulateEvent(lifecycle.EventDidBecomeActive)
|
||||
assert.True(t, received)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Window close hooks**: Cancellable `OnClose` (return false to prevent). Requires hook vs listener distinction in the window service — follow-up enhancement.
|
||||
- **Custom badge styling**: Windows-specific font/colour options for `SetCustomBadge`. macOS uses system styling only.
|
||||
- **Notification centre integration**: Deep integration with macOS/Windows notification centres beyond simple badge.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
# CoreGUI Service Conclave — IPC Integration
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Wire core/go IPC into core/gui's split packages, enabling cross-package communication via ACTION/QUERY/PERFORM
|
||||
|
||||
## Context
|
||||
|
||||
CoreGUI (`forge.lthn.ai/core/gui`) has just been split from a monolithic `pkg/display/` into four packages: `pkg/window`, `pkg/systray`, `pkg/menu`, and a slimmed `pkg/display` orchestrator. Each sub-package defines a `Platform` interface insulating Wails v3.
|
||||
|
||||
Today, the orchestrator calls sub-package methods directly. This design replaces direct calls with core/go's IPC bus, making each sub-package a full `core.Service` that communicates via typed messages. This enables:
|
||||
|
||||
- Cross-package communication without import coupling
|
||||
- Declarative window config in `.core/gui/` (foundation for multi-window apps)
|
||||
- TS apps talking to individual services through the existing WebSocket bridge
|
||||
- Independent testability with mock platforms and mock core
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Layer Stack
|
||||
|
||||
```
|
||||
IPC Bus (core/go ACTION/QUERY/PERFORM)
|
||||
↓
|
||||
Manager (pkg/window.Service, pkg/systray.Service, pkg/menu.Service)
|
||||
↓
|
||||
Platform Interface (Wails v3 adapters)
|
||||
```
|
||||
|
||||
Each sub-package is a `core.Service` that registers its own IPC handlers during `OnStartup`. The display orchestrator is also a `core.Service` — it owns the Wails `*application.App`, wraps it in Platform adapters, and manages config.
|
||||
|
||||
No sub-service ever sees Wails types. The orchestrator creates Platform adapters and passes them to sub-service factories.
|
||||
|
||||
### Service Registration
|
||||
|
||||
Each sub-package exposes a `Register(platform)` factory that returns `func(*core.Core) (any, error)` — the signature `core.WithService` requires. The factory captures the Platform adapter in a closure:
|
||||
|
||||
```go
|
||||
// pkg/window/register.go
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
App-level wiring:
|
||||
|
||||
```go
|
||||
wailsApp := application.New(application.Options{...})
|
||||
|
||||
windowPlatform := window.NewWailsPlatform(wailsApp)
|
||||
trayPlatform := systray.NewWailsPlatform(wailsApp)
|
||||
menuPlatform := menu.NewWailsPlatform(wailsApp)
|
||||
|
||||
core.New(
|
||||
core.WithService(display.Register(wailsApp)),
|
||||
core.WithService(window.Register(windowPlatform)),
|
||||
core.WithService(systray.Register(trayPlatform)),
|
||||
core.WithService(menu.Register(menuPlatform)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
```
|
||||
|
||||
**Startup order**: Display registers first (owns Wails and config), then window/systray/menu. `WithServiceLock()` freezes the registry. `ServiceStartup` calls `OnStartup` sequentially in registration order.
|
||||
|
||||
**Critical constraint**: The display orchestrator's `OnStartup` MUST register its `QueryConfig` handler synchronously before returning. Sub-services depend on this query being available when their own `OnStartup` runs.
|
||||
|
||||
```go
|
||||
// pkg/display/service.go
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.loadConfig() // Load .core/gui/config.yaml via go-config
|
||||
s.Core().RegisterQuery(s.handleQuery) // QueryConfig available NOW
|
||||
s.Core().RegisterTask(s.handleTask) // TaskSaveConfig available NOW
|
||||
// ... remaining setup
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Shutdown order**: Reverse — sub-services save state before display tears down Wails.
|
||||
|
||||
## IPC Message Types
|
||||
|
||||
Each sub-service defines its own message types. The pattern: Queries return data, Tasks mutate state and may return results, Actions are fire-and-forget broadcasts.
|
||||
|
||||
### Window Messages (`pkg/window/messages.go`)
|
||||
|
||||
```go
|
||||
// Queries (read-only)
|
||||
type QueryWindowList struct{} // → []WindowInfo
|
||||
type QueryWindowByName struct{ Name string } // → *WindowInfo (nil if not found)
|
||||
|
||||
// Tasks (side-effects)
|
||||
type TaskOpenWindow struct{ Opts []WindowOption } // → WindowInfo
|
||||
type TaskCloseWindow struct{ Name string } // handler persists state BEFORE emitting ActionWindowClosed
|
||||
type TaskSetPosition struct{ Name string; X, Y int }
|
||||
type TaskSetSize struct{ Name string; W, H int }
|
||||
type TaskMaximise struct{ Name string }
|
||||
type TaskMinimise struct{ Name string }
|
||||
type TaskFocus struct{ Name string }
|
||||
|
||||
// Actions (broadcasts)
|
||||
type ActionWindowOpened struct{ Name string }
|
||||
type ActionWindowClosed struct{ Name string }
|
||||
type ActionWindowMoved struct{ Name string; X, Y int }
|
||||
type ActionWindowResized struct{ Name string; W, H int }
|
||||
type ActionWindowFocused struct{ Name string }
|
||||
type ActionWindowBlurred struct{ Name string }
|
||||
```
|
||||
|
||||
### Systray Messages (`pkg/systray/messages.go`)
|
||||
|
||||
```go
|
||||
type TaskSetTrayIcon struct{ Data []byte }
|
||||
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
||||
type TaskShowPanel struct{}
|
||||
type TaskHidePanel struct{}
|
||||
type ActionTrayClicked struct{}
|
||||
```
|
||||
|
||||
### Menu Messages (`pkg/menu/messages.go`)
|
||||
|
||||
```go
|
||||
type TaskSetAppMenu struct{ Items []MenuItem }
|
||||
type QueryGetAppMenu struct{} // → []MenuItem
|
||||
```
|
||||
|
||||
### Config Messages (display orchestrator)
|
||||
|
||||
```go
|
||||
type QueryConfig struct{ Key string } // → map[string]any
|
||||
type TaskSaveConfig struct{ Key string; Value map[string]any } // serialisable to YAML
|
||||
```
|
||||
|
||||
## Config via IPC
|
||||
|
||||
The display orchestrator owns `.core/gui/config.yaml` (loaded via go-config). Sub-services QUERY for their config section during `OnStartup`. Config saves route through the orchestrator so there is one writer to disk.
|
||||
|
||||
```yaml
|
||||
# .core/gui/config.yaml
|
||||
window:
|
||||
state_file: window_state.json
|
||||
default_width: 1024
|
||||
default_height: 768
|
||||
|
||||
systray:
|
||||
icon: apptray.png
|
||||
tooltip: "Core GUI"
|
||||
|
||||
menu:
|
||||
show_dev_tools: true
|
||||
```
|
||||
|
||||
Sub-service startup pattern:
|
||||
|
||||
```go
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Query config from the display orchestrator (registered before us)
|
||||
cfg, _, _ := s.Core().QUERY(display.QueryConfig{Key: "window"})
|
||||
if wCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(wCfg)
|
||||
}
|
||||
// Register QUERY and TASK handlers manually.
|
||||
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
|
||||
// do NOT call RegisterAction here or actions will double-fire.
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
// Used for filtering broadcast actions relevant to this service.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
switch msg.(type) {
|
||||
case core.ActionServiceStartup:
|
||||
// post-startup work if needed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## WSEventManager Bridge
|
||||
|
||||
The WSEventManager (WebSocket pub/sub for TS apps) stays in `pkg/display`. It bridges IPC actions to WebSocket events and vice versa.
|
||||
|
||||
**IPC → WebSocket** (Go to TS):
|
||||
|
||||
The display orchestrator's `HandleIPCEvents` converts IPC actions to `Event` structs and calls `WSEventManager.Emit()`:
|
||||
|
||||
```go
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
switch m := msg.(type) {
|
||||
case window.ActionWindowOpened:
|
||||
s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name,
|
||||
Data: map[string]any{"name": m.Name}})
|
||||
case window.ActionWindowClosed:
|
||||
s.events.Emit(Event{Type: EventWindowClose, Window: m.Name,
|
||||
Data: map[string]any{"name": m.Name}})
|
||||
case window.ActionWindowMoved:
|
||||
s.events.Emit(Event{Type: EventWindowMove, Window: m.Name,
|
||||
Data: map[string]any{"x": m.X, "y": m.Y}})
|
||||
case window.ActionWindowResized:
|
||||
s.events.Emit(Event{Type: EventWindowResize, Window: m.Name,
|
||||
Data: map[string]any{"w": m.W, "h": m.H}})
|
||||
case window.ActionWindowFocused:
|
||||
s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name})
|
||||
case window.ActionWindowBlurred:
|
||||
s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name})
|
||||
case systray.ActionTrayClicked:
|
||||
s.events.Emit(Event{Type: EventTrayClick})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**WebSocket → IPC** (TS to Go):
|
||||
|
||||
Inbound WS messages include a `RequestID` for response correlation. The orchestrator PERFORMs the task and writes back the result or error:
|
||||
|
||||
```go
|
||||
func (s *Service) handleWSMessage(conn *websocket.Conn, msg WSMessage) {
|
||||
var result any
|
||||
var handled bool
|
||||
var err error
|
||||
|
||||
switch msg.Type {
|
||||
case "window:open":
|
||||
result, handled, err = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
window.WithName(msg.Data["name"].(string)),
|
||||
window.WithURL(msg.Data["url"].(string)),
|
||||
},
|
||||
})
|
||||
case "window:close":
|
||||
result, handled, err = s.Core().PERFORM(
|
||||
window.TaskCloseWindow{Name: msg.Data["name"].(string)})
|
||||
}
|
||||
|
||||
s.writeResponse(conn, msg.RequestID, result, handled, err)
|
||||
}
|
||||
```
|
||||
|
||||
TS apps talk WebSocket, the orchestrator translates to/from IPC. The TS app never knows about core/go's bus.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator, core.Service)
|
||||
├── imports pkg/window (message types only)
|
||||
├── imports pkg/systray (message types only)
|
||||
├── imports pkg/menu (message types only)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── imports core/go-config (config loading)
|
||||
|
||||
pkg/window (core.Service)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
|
||||
pkg/systray (core.Service)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
|
||||
pkg/menu (core.Service)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface (Wails adapter injected)
|
||||
```
|
||||
|
||||
No circular dependencies. Sub-packages do not import each other or the orchestrator. The orchestrator imports sub-package message types for the WSEventManager bridge.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each sub-service is independently testable with mock platforms and a real core.Core:
|
||||
|
||||
```go
|
||||
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
||||
c, err := core.New(
|
||||
core.WithService(Register(&mockPlatform{})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
svc := core.MustServiceFor[*Service](c, "window")
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestTaskOpenWindow_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||
Opts: []WindowOption{WithName("test"), WithURL("/")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
info := result.(WindowInfo)
|
||||
assert.Equal(t, "test", info.Name)
|
||||
}
|
||||
|
||||
func TestTaskOpenWindow_Bad(t *testing.T) {
|
||||
// No window service registered — PERFORM returns handled=false
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskOpenWindow{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
```
|
||||
|
||||
Integration test with the full conclave:
|
||||
|
||||
```go
|
||||
func TestServiceConclave_Good(t *testing.T) {
|
||||
c, _ := core.New(
|
||||
core.WithService(display.Register(mockWailsApp)),
|
||||
core.WithService(window.Register(&mockWindowPlatform{})),
|
||||
core.WithService(systray.Register(&mockTrayPlatform{})),
|
||||
core.WithService(menu.Register(&mockMenuPlatform{})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, _ := c.PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{window.WithName("main")},
|
||||
})
|
||||
assert.True(t, handled)
|
||||
|
||||
val, handled, _ := c.QUERY(display.QueryConfig{Key: "window"})
|
||||
assert.True(t, handled)
|
||||
assert.NotNil(t, val)
|
||||
}
|
||||
|
||||
func TestServiceConclave_Bad(t *testing.T) {
|
||||
// Sub-service starts without display — config QUERY returns handled=false
|
||||
c, _ := core.New(
|
||||
core.WithService(window.Register(&mockWindowPlatform{})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, _ := c.QUERY(display.QueryConfig{Key: "window"})
|
||||
assert.False(t, handled, "no display service means no config handler")
|
||||
}
|
||||
```
|
||||
|
||||
No Wails runtime needed for any test.
|
||||
|
||||
### Service Name Convention
|
||||
|
||||
`core.WithService` auto-derives the service name from the type's package path (last segment, lowercased). Canonical names: `"display"`, `"window"`, `"systray"`, `"menu"`. Any existing `ServiceName()` methods returning the full module path should be removed to avoid lookup mismatches.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Declarative window config**: `.core/gui/windows.yaml` defining named windows with size/position/URL, restored on launch. Foundation is here (config QUERY pattern), but the declarative layer is a follow-up.
|
||||
- **Screen insulation**: `GetScreens()`, `GetWorkAreas()` still call `application.Get()` directly. Will be wrapped in a `ScreenProvider` interface.
|
||||
- **go-config dependency**: core/gui currently has no go-config dependency. Adding it is part of implementation.
|
||||
- **WS response envelope**: Full request/response protocol for WS→IPC (RequestID, error codes, retry semantics). This spec adds the foundation (`writeResponse`), the full envelope schema is a follow-up.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
Loading…
Add table
Reference in a new issue