Compare commits

..

46 commits

Author SHA1 Message Date
Snider
873eafe7b6 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:14 +01:00
Virgil
031c286fb9 Align GUI packages with AX conventions
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
2026-04-02 20:51:26 +00:00
Virgil
6b3879fb9a refactor(ax): make webview registration declarative
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:47:33 +00:00
Virgil
d9491380f8 chore(gui): align remaining AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:42:42 +00:00
Virgil
98b73fc14c refactor(display): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:39:36 +00:00
Virgil
274a81689c chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:36:02 +00:00
Virgil
d3443d4be9 chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:31:56 +00:00
Virgil
0204540b20 fix(window): fallback title for wails windows
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:26:27 +00:00
Virgil
29dc0d9877 refactor(gui): make theme override declarative
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:22:47 +00:00
Virgil
bca53679f1 ui: add global search to shell
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:16:54 +00:00
Virgil
dd9e8da619 refactor(gui): centralize API origin handling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:12:35 +00:00
Virgil
b50149af5d feat(display): support prompt dialogs via webview fallback
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:08:42 +00:00
Virgil
a23e265cc6 fix(window): normalize layout state before applying geometry
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:04:12 +00:00
Virgil
3cf69533bf Refine GUI shell styling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:59:29 +00:00
Virgil
0423f3058d chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:54:47 +00:00
Virgil
81503d0968 chore(gui): align AX naming and docs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 19:50:55 +00:00
Virgil
8db26398af Expand display websocket window bridge
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:45:31 +00:00
Virgil
f2eb9f03c4 feat(gui): wire shell routes and provider previews
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:41:35 +00:00
Virgil
fdff5435c2 refactor(gui): tighten display AX docs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:35:03 +00:00
Virgil
cf8091e7e7 Add display layout helper wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:32:00 +00:00
Virgil
b8ddd2650b docs(display): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 19:28:48 +00:00
Virgil
0d2ae6c299 Refactor MCP layout queries
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:24:59 +00:00
Virgil
cf284e9954 feat(gui): add event info and layout query fixes
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:21:28 +00:00
Virgil
3d7998a9ca feat(systray): wire tray window attachment
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:16:59 +00:00
Virgil
856bb89022 docs(gui): align public API comments with AX
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:12:40 +00:00
Virgil
cad4e212c4 feat(gui): add missing MCP aliases and webview errors
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:09:10 +00:00
Virgil
483c408497 Implement tray close-desktop action
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:03:11 +00:00
Virgil
4f03fc4c64 Implement systray panel window handling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 18:59:54 +00:00
Virgil
4f7236a8bb feat(gui): add compatibility aliases for spec names
Some checks failed
Test / test (push) Waiting to run
Security Scan / security (push) Failing after 14m34s
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 14:54:14 +00:00
Virgil
54d77d85cd fix(window): use detected screen size for tiling
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m4s
2026-04-02 14:47:40 +00:00
Virgil
4f4a4eb8e4 feat(window): add window opacity support
Some checks failed
Test / test (push) Waiting to run
Security Scan / security (push) Failing after 35s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:42:03 +00:00
Virgil
45fa6942f7 feat(window): expose visibility in window lists
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 1m18s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:36:08 +00:00
Virgil
77e03060ac feat(window): expose visibility and minimized state
Some checks failed
Security Scan / security (push) Failing after 31s
Test / test (push) Successful in 1m28s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:30:42 +00:00
Virgil
81b71ff50b refactor(display): delegate window mutations through IPC
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m22s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:25:25 +00:00
Virgil
61ddae80f4 feat(ui): replace placeholder shell with live dashboard
Some checks failed
Security Scan / security (push) Failing after 33s
Test / test (push) Successful in 1m23s
2026-04-02 14:20:45 +00:00
Virgil
c3361b7064 refactor(gui): align gui services with ax guidance
Some checks failed
Security Scan / security (push) Failing after 36s
Test / test (push) Successful in 1m15s
2026-04-02 14:13:58 +00:00
Virgil
973217ae54 feat(gui): bridge arrange-pair and find-space
Some checks failed
Security Scan / security (push) Failing after 42s
Test / test (push) Successful in 1m34s
2026-04-02 14:08:32 +00:00
Virgil
a07fa49c20 feat(gui): add missing window mutators and MCP tools
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m34s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:03:29 +00:00
Virgil
57fb567a68 feat(gui): add webview element screenshots
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Successful in 1m36s
2026-04-02 13:55:56 +00:00
Virgil
a4c696ec01 Implement display service spec wrappers
Some checks failed
Security Scan / security (push) Failing after 20s
Test / test (push) Successful in 1m28s
2026-04-02 13:48:27 +00:00
Virgil
573eb5216a feat(systray): wire tray mutations and submenus
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 1m22s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:39:36 +00:00
Virgil
a0cad39fbb feat(gui): add webview diagnostics and tray fallback
Some checks failed
Security Scan / security (push) Failing after 22s
Test / test (push) Successful in 1m25s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:29:46 +00:00
Virgil
3413b64f6c Expose layout stack and workflow actions 2026-04-02 13:23:17 +00:00
Virgil
3c5c109c3a feat(display): bridge missing GUI features
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:17:20 +00:00
Virgil
a1fbcdf6ed feat(window): restore config and screen-aware layouts
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:09:54 +00:00
Virgil
5653bfcc8d feat(mcp): implement missing GUI features
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:03:55 +00:00
155 changed files with 9736 additions and 11700 deletions

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "internal/wails3"]
path = internal/wails3
url = https://github.com/wailsapp/wails.git
branch = v3-alpha

View file

@ -38,43 +38,38 @@ The display `Service` registers with `forge.lthn.ai/core/go`'s service container
All Wails application APIs are abstracted behind interfaces in `interfaces.go` (`App`, `WindowManager`, `MenuManager`, `DialogManager`, etc.). The `wailsApp` adapter wraps the real Wails app. Tests inject a `mockApp` instead — see `mocks_test.go` and the `newServiceWithMockApp(t)` helper. All Wails application APIs are abstracted behind interfaces in `interfaces.go` (`App`, `WindowManager`, `MenuManager`, `DialogManager`, etc.). The `wailsApp` adapter wraps the real Wails app. Tests inject a `mockApp` instead — see `mocks_test.go` and the `newServiceWithMockApp(t)` helper.
### Package structure (pkg/) ### Key files in pkg/display/
| Package | Responsibility | | File | Responsibility |
|---------|---------------| |------|---------------|
| `display` | Orchestrator service — bridges sub-service IPC to WebSocket events, menu/tray setup, config persistence | | `display.go` | Service struct, lifecycle (`Startup`), window CRUD, screen queries, tiling/snapping/layout, workflow presets |
| `window` | Window lifecycle, `Manager`, `StateManager` (position persistence), `LayoutManager` (named arrangements), tiling/snapping | | `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` |
| `menu` | Application menu construction via platform abstraction | | `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
| `systray` | System tray icon, tooltip, menu via platform abstraction | | `layout.go` | `LayoutManager` — save/restore named window arrangements |
| `dialog` | File open/save, message, confirm, and prompt dialogs | | `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events |
| `clipboard` | Clipboard read/write/clear | | `interfaces.go` | Abstract interfaces + Wails adapter implementations |
| `notification` | System notifications with permission management | | `actions.go` | `ActionOpenWindow` IPC message type |
| `screen` | Screen/monitor queries (list, primary, at-point, work areas) | | `menu.go` | Application menu construction |
| `environment` | Theme detection (dark/light) and OS environment info | | `tray.go` | System tray setup |
| `keybinding` | Global keyboard shortcut registration | | `dialog.go` | File/directory dialogs |
| `contextmenu` | Named context menu registration and lifecycle | | `clipboard.go` | Clipboard read/write |
| `browser` | Open URLs and files in the default browser | | `notification.go` | System notifications |
| `dock` | macOS dock icon visibility and badge | | `theme.go` | Dark/light mode detection |
| `lifecycle` | Application lifecycle events (start, terminate, suspend, resume) | | `mocks_test.go` | Mock implementations of all interfaces for testing |
| `webview` | Webview automation (eval, click, type, screenshot, DOM queries) |
| `mcp` | MCP tool subsystem — exposes all services as Model Context Protocol tools |
### Patterns used throughout ### Patterns used throughout
- **Platform abstraction**: Each sub-service defines a `Platform` interface and `PlatformWindow`/`PlatformTray`/etc. types; tests inject mocks - **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions`
- **Functional options**: `WindowOption` functions (`WithName()`, `WithTitle()`, `WithSize()`, etc.) configure `window.Window` descriptors - **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
- **IPC message bus**: Sub-services communicate via `core.QUERY`, `core.PERFORM`, and `core.ACTION` — display orchestrates and bridges to WebSocket events
- **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard) - **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard)
- **Error handling**: All errors use `coreerr.E(op, msg, err)` from `forge.lthn.ai/core/go-log` (aliased as `coreerr`), never `fmt.Errorf` - **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()`
- **File I/O**: Use `forge.lthn.ai/core/go-io` (`coreio.Local`) for filesystem operations, never `os.ReadFile`/`os.WriteFile`
## Testing ## Testing
- Framework: `testify` (assert + require) - Framework: `testify` (assert + require)
- Each sub-package has its own `*_test.go` with mock platform implementations - Pattern: `newServiceWithMockApp(t)` creates a `Service` with mock Wails app — no real window system needed
- `pkg/window`: `NewManagerWithDir` / `NewStateManagerWithDir` / `NewLayoutManagerWithDir` accept custom config dirs for isolated tests - `newTestCore(t)` creates a real `core.Core` instance for integration-style tests
- `pkg/display`: `newTestCore(t)` creates a real `core.Core` instance for integration-style tests - Some tests use `defer func() { recover() }()` to handle nil panics from mock methods that return nil pointers (e.g., `Dialog().Info()`)
- Sub-services use `mock_platform.go` or `mock_test.go` for platform mocks
## CI/CD ## CI/CD
@ -87,13 +82,9 @@ Both use reusable workflows from `core/go-devops`.
## Dependencies ## Dependencies
- `forge.lthn.ai/core/go` — Core framework with service container and DI - `forge.lthn.ai/core/go` — Core framework with service container and DI
- `forge.lthn.ai/core/go-log` — Structured errors (`coreerr.E()`)
- `forge.lthn.ai/core/go-io` — Filesystem abstraction (`coreio.Local`)
- `forge.lthn.ai/core/config` — Configuration file management
- `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74) - `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74)
- `github.com/gorilla/websocket` — WebSocket for real-time events - `github.com/gorilla/websocket` — WebSocket for real-time events
- `github.com/stretchr/testify` — Test assertions - `github.com/stretchr/testify` — Test assertions
- `github.com/modelcontextprotocol/go-sdk` — MCP tool registration
## Repository migration note ## Repository migration note

View file

@ -1,440 +0,0 @@
# RFC-025: Agent Experience (AX) Design Principles
- **Status:** Draft
- **Authors:** Snider, Cladius
- **Date:** 2026-03-19
- **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent)
## Abstract
Agent Experience (AX) is a design paradigm for software systems where the primary code consumer is an AI agent, not a human developer. AX sits alongside User Experience (UX) and Developer Experience (DX) as the third era of interface design.
This RFC establishes AX as a formal design principle for the Core ecosystem and defines the conventions that follow from it.
## Motivation
As of early 2026, AI agents write, review, and maintain the majority of code in the Core ecosystem. The original author has not manually edited code (outside of Core struct design) since October 2025. Code is processed semantically — agents reason about intent, not characters.
Design patterns inherited from the human-developer era optimise for the wrong consumer:
- **Short names** save keystrokes but increase semantic ambiguity
- **Functional option chains** are fluent for humans but opaque for agents tracing configuration
- **Error-at-every-call-site** produces 50% boilerplate that obscures intent
- **Generic type parameters** force agents to carry type context that the runtime already has
- **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case
AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers.
## The Three Eras
| Era | Primary Consumer | Optimises For | Key Metric |
|-----|-----------------|---------------|------------|
| UX | End users | Discoverability, forgiveness, visual clarity | Task completion time |
| DX | Developers | Typing speed, IDE support, convention familiarity | Time to first commit |
| AX | AI agents | Predictability, composability, semantic navigation | Correct-on-first-pass rate |
AX does not replace UX or DX. End users still need good UX. Developers still need good DX. But when the primary code author and maintainer is an AI agent, the codebase should be designed for that consumer first.
## Principles
### 1. Predictable Names Over Short Names
Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead.
```
Config not Cfg
Service not Srv
Embed not Emb
Error not Err (as a subsystem name; err for local variables is fine)
Options not Opts
```
**Rule:** If a name would require a comment to explain, it is too short.
**Exception:** Industry-standard abbreviations that are universally understood (`HTTP`, `URL`, `ID`, `IPC`, `I18n`) are acceptable. The test: would an agent trained on any mainstream language recognise it without context?
### 2. Comments as Usage Examples
The function signature tells WHAT. The comment shows HOW with real values.
```go
// Detect the project type from files present
setup.Detect("/path/to/project")
// Set up a workspace with auto-detected template
setup.Run(setup.Options{Path: ".", Template: "auto"})
// Scaffold a PHP module workspace
setup.Run(setup.Options{Path: "./my-module", Template: "php"})
```
**Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it.
**Rationale:** Agents learn from examples more effectively than from descriptions. A comment like "Run executes the setup process" adds zero information. A comment like `setup.Run(setup.Options{Path: ".", Template: "auto"})` teaches an agent exactly how to call the function.
### 3. Path Is Documentation
File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README.
```
flow/deploy/to/homelab.yaml — deploy TO the homelab
flow/deploy/from/github.yaml — deploy FROM GitHub
flow/code/review.yaml — code review flow
template/file/go/struct.go.tmpl — Go struct file template
template/dir/workspace/php/ — PHP workspace scaffold
```
**Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed.
**Corollary:** The unified path convention (folder structure = HTTP route = CLI command = test path) is AX-native. One path, every surface.
### 4. Templates Over Freeform
When an agent generates code from a template, the output is constrained to known-good shapes. When an agent writes freeform, the output varies.
```go
// Template-driven — consistent output
lib.RenderFile("php/action", data)
lib.ExtractDir("php", targetDir, data)
// Freeform — variance in output
"write a PHP action class that..."
```
**Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents.
**Scope:** Templates apply to file generation, workspace scaffolding, config generation, and commit messages. They do NOT apply to novel logic — agents should write business logic freeform with the domain knowledge available.
### 5. Declarative Over Imperative
Agents reason better about declarations of intent than sequences of operations.
```yaml
# Declarative — agent sees what should happen
steps:
- name: build
flow: tools/docker-build
with:
context: "{{ .app_dir }}"
image_name: "{{ .image_name }}"
- name: deploy
flow: deploy/with/docker
with:
host: "{{ .host }}"
```
```go
// Imperative — agent must trace execution
cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".")
cmd.Dir = appDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker build: %w", err)
}
```
**Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative.
### 6. Universal Types (Core Primitives)
Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes.
```go
// Universal contract
setup.Run(core.Options{Path: ".", Template: "auto"})
brain.New(core.Options{Name: "openbrain"})
deploy.Run(core.Options{Flow: "deploy/to/homelab"})
// Fractal — Core itself is a Service
core.New(core.Options{
Services: []core.Service{
process.New(core.Options{Name: "process"}),
brain.New(core.Options{Name: "brain"}),
},
})
```
**Core primitive types:**
| Type | Purpose |
|------|---------|
| `core.Options` | Input configuration (what you want) |
| `core.Config` | Runtime settings (what is active) |
| `core.Data` | Embedded or stored content |
| `core.Service` | A managed component with lifecycle |
| `core.Result[T]` | Return value with OK/fail state |
**What this replaces:**
| Go Convention | Core AX | Why |
|--------------|---------|-----|
| `func With*(v) Option` | `core.Options{Field: v}` | Struct literal is parseable; option chain requires tracing |
| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core |
| `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context |
| `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling |
| `_ = err` | Never needed | Core handles all errors internally |
### 7. Directory as Semantics
The directory structure tells an agent the intent before it reads a word. Top-level directories are semantic categories, not organisational bins.
```
plans/
├── code/ # Pure primitives — read for WHAT exists
├── project/ # Products — read for WHAT we're building and WHY
└── rfc/ # Contracts — read for constraints and rules
```
**Rule:** An agent should know what kind of document it's reading from the path alone. `code/core/go/io/RFC.md` = a lib primitive spec. `project/ofm/RFC.md` = a product spec that cross-references code/. `rfc/snider/borg/RFC-BORG-006-SMSG-FORMAT.md` = an immutable contract for the Borg SMSG protocol.
**Corollary:** The three-way split (code/project/rfc) extends principle 3 (Path Is Documentation) from files to entire subtrees. The path IS the metadata.
### 8. Lib Never Imports Consumer
Dependency flows one direction. Libraries define primitives. Consumers compose from them. A new feature in a consumer can never break a library.
```
code/core/go/* → lib tier (stable foundation)
code/core/agent/ → consumer tier (composes from go/*)
code/core/cli/ → consumer tier (composes from go/*)
code/core/gui/ → consumer tier (composes from go/*)
```
**Rule:** If package A is in `go/` and package B is in the consumer tier, B may import A but A must never import B. The repo naming convention enforces this: `go-{name}` = lib, bare `{name}` = consumer.
**Why this matters for agents:** When an agent is dispatched to implement a feature in `core/agent`, it can freely import from `go-io`, `go-scm`, `go-process`. But if an agent is dispatched to `go-io`, it knows its changes are foundational — every consumer depends on it, so the contract must not break.
### 9. Issues Are N+(rounds) Deep
Problems in code and specs are layered. Surface issues mask deeper issues. Fixing the surface reveals the next layer. This is not a failure mode — it is the discovery process.
```
Pass 1: Find 16 issues (surface — naming, imports, obvious errors)
Pass 2: Find 11 issues (structural — contradictions, missing types)
Pass 3: Find 5 issues (architectural — signature mismatches, registration gaps)
Pass 4: Find 4 issues (contract — cross-spec API mismatches)
Pass 5: Find 2 issues (mechanical — path format, nil safety)
Pass N: Findings are trivial → spec/code is complete
```
**Rule:** Iteration is required, not a failure. Each pass sees what the previous pass could not, because the context changed. An agent dispatched with the same task on the same repo will find different things each time — this is correct behaviour.
**Corollary:** The cheapest model should do the most passes (surface work). The frontier model should arrive last, when only deep issues remain. Tiered iteration: grunt model grinds → mid model pre-warms → frontier model polishes.
**Anti-pattern:** One-shot generation expecting valid output. No model, no human, produces correct-on-first-pass for non-trivial work. Expecting it wastes the first pass on surface issues that a cheaper pass would have caught.
### 10. CLI Tests as Artifact Validation
Unit tests verify the code. CLI tests verify the binary. The directory structure IS the command structure — path maps to command, Taskfile runs the test.
```
tests/cli/
├── core/
│ └── lint/
│ ├── Taskfile.yaml ← test `core-lint` (root)
│ ├── run/
│ │ ├── Taskfile.yaml ← test `core-lint run`
│ │ └── fixtures/
│ ├── go/
│ │ ├── Taskfile.yaml ← test `core-lint go`
│ │ └── fixtures/
│ └── security/
│ ├── Taskfile.yaml ← test `core-lint security`
│ └── fixtures/
```
**Rule:** Every CLI command has a matching `tests/cli/{path}/Taskfile.yaml`. The Taskfile runs the compiled binary against fixtures with known inputs and validates the output. If the CLI test passes, the underlying actions work — because CLI commands call actions, MCP tools call actions, API endpoints call actions. Test the CLI, trust the rest.
**Pattern:**
```yaml
# tests/cli/core/lint/go/Taskfile.yaml
version: '3'
tasks:
test:
cmds:
- core-lint go --output json fixtures/ > /tmp/result.json
- jq -e '.findings | length > 0' /tmp/result.json
- jq -e '.summary.passed == false' /tmp/result.json
```
**Why this matters for agents:** An agent can validate its own work by running `task test` in the matching `tests/cli/` directory. No test framework, no mocking, no setup — just the binary, fixtures, and `jq` assertions. The agent builds the binary, runs the test, sees the result. If it fails, the agent can read the fixture, read the output, and fix the code.
**Corollary:** Fixtures are planted bugs. Each fixture file has a known issue that the linter must find. If the linter doesn't find it, the test fails. Fixtures are the spec for what the tool must detect — they ARE the test cases, not descriptions of test cases.
## Applying AX to Existing Patterns
### File Structure
```
# AX-native: path describes content
core/agent/
├── go/ # Go source
├── php/ # PHP source
├── ui/ # Frontend source
├── claude/ # Claude Code plugin
└── codex/ # Codex plugin
# Not AX: generic names requiring README
src/
├── lib/
├── utils/
└── helpers/
```
### Error Handling
```go
// AX-native: errors are infrastructure, not application logic
svc := c.Service("brain")
cfg := c.Config().Get("database.host")
// Errors logged by Core. Code reads like a spec.
// Not AX: errors dominate the code
svc, err := c.ServiceFor[brain.Service]()
if err != nil {
return fmt.Errorf("get brain service: %w", err)
}
cfg, err := c.Config().Get("database.host")
if err != nil {
_ = err // silenced because "it'll be fine"
}
```
### API Design
```go
// AX-native: one shape, every surface
core.New(core.Options{
Name: "my-app",
Services: []core.Service{...},
Config: core.Config{...},
})
// Not AX: multiple patterns for the same thing
core.New(
core.WithName("my-app"),
core.WithService(factory1),
core.WithService(factory2),
core.WithConfig(cfg),
)
```
## The Plans Convention — AX Development Lifecycle
The `plans/` directory structure encodes a development methodology designed for how generative AI actually works: iterative refinement across structured phases, not one-shot generation.
### The Three-Way Split
```
plans/
├── project/ # 1. WHAT and WHY — start here
├── rfc/ # 2. CONSTRAINTS — immutable contracts
└── code/ # 3. HOW — implementation specs
```
Each directory is a phase. Work flows from project → rfc → code. Each transition forces a refinement pass — you cannot write a code spec without discovering gaps in the project spec, and you cannot write an RFC without discovering assumptions in both.
**Three places for data that can't be written simultaneously = three guaranteed iterations of "actually, this needs changing."** Refinement is baked into the structure, not bolted on as a review step.
### Phase 1: Project (Vision)
Start with `project/`. No code exists yet. Define:
- What the product IS and who it serves
- What existing primitives it consumes (cross-ref to `code/`)
- What constraints it operates under (cross-ref to `rfc/`)
This is where creativity lives. Map features to building blocks. Connect systems. The project spec is integrative — it references everything else.
### Phase 2: RFC (Contracts)
Extract the immutable rules into `rfc/`. These are constraints that don't change with implementation:
- Wire formats, protocols, hash algorithms
- Security properties that must hold
- Compatibility guarantees
RFCs are numbered per component (`RFC-BORG-006-SMSG-FORMAT.md`) and never modified after acceptance. If the contract changes, write a new RFC.
### Phase 3: Code (Implementation Specs)
Define the implementation in `code/`. Each component gets an RFC.md that an agent can implement from:
- Struct definitions (the DTOs — see principle 6)
- Method signatures and behaviour
- Error conditions and edge cases
- Cross-references to other code/ specs
The code spec IS the product. Write the spec → dispatch to an agent → review output → iterate.
### Pre-Launch: Alignment Protocol
Before dispatching for implementation, verify spec-model alignment:
```
1. REVIEW — The implementation model (Codex/Jules) reads the spec
and reports missing elements. This surfaces the delta between
the model's training and the spec's assumptions.
"I need X, Y, Z to implement this" is the model saying
"I hear you but I'm missing context" — without asking.
2. ADJUST — Update the spec to close the gaps. Add examples,
clarify ambiguities, provide the context the model needs.
This is shared alignment, not compromise.
3. VERIFY — A different model (or sub-agent) reviews the adjusted
spec without the planner's bias. Fresh eyes on the contract.
"Does this make sense to someone who wasn't in the room?"
4. READY — When the review findings are trivial or deployment-
related (not architectural), the spec is ready to dispatch.
```
### Implementation: Iterative Dispatch
Same prompt, multiple runs. Each pass sees deeper because the context evolved:
```
Round 1: Build features (the obvious gaps)
Round 2: Write tests (verify what was built)
Round 3: Harden security (what can go wrong?)
Round 4: Next RFC section (what's still missing?)
Round N: Findings are trivial → implementation is complete
```
Re-running is not failure. It is the process. Each pass changes the codebase, which changes what the next pass can see. The iteration IS the refinement.
### Post-Implementation: Auto-Documentation
The QA/verify chain produces artefacts that feed forward:
- Test results document the contract (what works, what doesn't)
- Coverage reports surface untested paths
- Diff summaries prep the changelog for the next release
- Doc site updates from the spec (the spec IS the documentation)
The output of one cycle is the input to the next. The plans repo stays current because the specs drive the code, not the other way round.
## Compatibility
AX conventions are valid, idiomatic Go/PHP/TS. They do not require language extensions, code generation, or non-standard tooling. An AX-designed codebase compiles, tests, and deploys with standard toolchains.
The conventions diverge from community patterns (functional options, Must/For, etc.) but do not violate language specifications. This is a style choice, not a fork.
## Adoption
AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite.
Priority order:
1. **Public APIs** (package-level functions, struct constructors)
2. **File structure** (path naming, template locations)
3. **Internal fields** (struct field names, local variables)
## References
- dAppServer unified path convention (2024)
- CoreGO DTO pattern refactor (2026-03-18)
- Core primitives design (2026-03-19)
- Go Proverbs, Rob Pike (2015) — AX provides an updated lens
## Changelog
- 2026-03-19: Initial draft

View file

@ -73,6 +73,7 @@ Response:
| `window_maximize` | Maximize window | | `window_maximize` | Maximize window |
| `window_minimize` | Minimize window | | `window_minimize` | Minimize window |
| `window_focus` | Bring window to front | | `window_focus` | Bring window to front |
| `window_title_set` | Alias for `window_title` |
### WebView Interaction ### WebView Interaction
@ -84,6 +85,7 @@ Response:
| `webview_screenshot` | Capture page | | `webview_screenshot` | Capture page |
| `webview_navigate` | Navigate to URL | | `webview_navigate` | Navigate to URL |
| `webview_console` | Get console messages | | `webview_console` | Get console messages |
| `webview_errors` | Get structured JavaScript errors |
### Screen Management ### Screen Management
@ -93,6 +95,8 @@ Response:
| `screen_primary` | Get primary screen | | `screen_primary` | Get primary screen |
| `screen_at_point` | Get screen at coordinates | | `screen_at_point` | Get screen at coordinates |
| `screen_work_areas` | Get usable screen space | | `screen_work_areas` | Get usable screen space |
| `screen_work_area` | Alias for `screen_work_areas` |
| `screen_for_window` | Get the screen containing a window |
### Layout Management ### Layout Management

View file

@ -1,3 +1,3 @@
module github.com/wailsapp/wails/v3 module github.com/wailsapp/wails/v3
go 1.26.0 go 1.24

View file

@ -1,9 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Wails Assets Placeholder</title>
</head>
<body>
</body>
</html>

View file

@ -0,0 +1 @@
placeholder

19
go.mod
View file

@ -1,31 +1,27 @@
module forge.lthn.ai/core/gui module dappco.re/go/core/gui
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/config v0.1.8
forge.lthn.ai/core/go-webview v0.1.7 dappco.re/go/core v0.3.3
dappco.re/go/core/webview v0.1.7
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 github.com/wailsapp/wails/v3 v3.0.0-alpha.74
) )
replace github.com/wailsapp/wails/v3 => ./stubs/wails replace github.com/wailsapp/wails/v3 => ./stubs/wails/v3
require ( require (
dappco.re/go/core/io v0.2.0 // indirect dappco.re/go/core/io v0.1.7 // indirect
dappco.re/go/core/log v0.1.0 // indirect dappco.re/go/core/log v0.0.4 // indirect
forge.lthn.ai/core/config v0.1.8 // indirect
forge.lthn.ai/core/go v0.3.3 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
@ -41,6 +37,5 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

39
go.sum
View file

@ -1,22 +1,34 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-crypt v0.1.6/go.mod h1:4VZAGqxlbadhSB66sJkdj54/HSJ+bSxVgwWK5kMMYDo=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc= forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek= forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@ -27,18 +39,22 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
@ -47,6 +63,7 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@ -55,6 +72,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@ -63,8 +81,11 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
@ -76,3 +97,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

@ -1 +0,0 @@
Subproject commit bb4fbf95744fafe5acf84e143a419bfffc2159e6

View file

@ -1,14 +1,15 @@
// pkg/browser/register.go
package browser package browser
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the browser service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(browser.Register(wailsBrowser)) // The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }

View file

@ -1,34 +1,42 @@
// pkg/browser/service.go
package browser package browser
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the browser service.
type Options struct{} type Options struct{}
// Service is a core.Service that delegates browser/file-open operations
// to the platform. It is stateless — no queries, no actions.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
s.Core().Action("browser.openURL", func(_ context.Context, opts core.Options) core.Result { func (s *Service) OnStartup(ctx context.Context) error {
if err := s.platform.OpenURL(opts.String("url")); err != nil { s.Core().RegisterTask(s.handleTask)
return core.Result{Value: err, OK: false} return nil
}
return core.Result{OK: true}
})
s.Core().Action("browser.openFile", func(_ context.Context, opts core.Options) core.Result {
if err := s.platform.OpenFile(opts.String("path")); err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenURL:
return nil, true, s.platform.OpenURL(t.URL)
case TaskOpenFile:
return nil, true, s.platform.OpenFile(t.Path)
default:
return nil, false, nil
}
} }

View file

@ -3,9 +3,10 @@ package browser
import ( import (
"context" "context"
core "dappco.re/go/core" "errors"
"testing" "testing"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -29,11 +30,12 @@ func (m *mockPlatform) OpenFile(path string) error {
func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(mp)), core.WithService(Register(mp)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "browser") svc := core.MustServiceFor[*Service](c, "browser")
return svc, c return svc, c
} }
@ -49,48 +51,42 @@ func TestTaskOpenURL_Good(t *testing.T) {
mp := &mockPlatform{} mp := &mockPlatform{}
_, c := newTestBrowserService(t, mp) _, c := newTestBrowserService(t, mp)
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions( _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
core.Option{Key: "url", Value: "https://example.com"}, require.NoError(t, err)
)) assert.True(t, handled)
require.True(t, r.OK)
assert.Equal(t, "https://example.com", mp.lastURL) assert.Equal(t, "https://example.com", mp.lastURL)
} }
func TestTaskOpenURL_Bad_PlatformError(t *testing.T) { func TestTaskOpenURL_Bad_PlatformError(t *testing.T) {
mp := &mockPlatform{urlErr: core.NewError("browser not found")} mp := &mockPlatform{urlErr: errors.New("browser not found")}
_, c := newTestBrowserService(t, mp) _, c := newTestBrowserService(t, mp)
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions( _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
core.Option{Key: "url", Value: "https://example.com"}, assert.True(t, handled)
)) assert.Error(t, err)
assert.False(t, r.OK)
} }
func TestTaskOpenFile_Good(t *testing.T) { func TestTaskOpenFile_Good(t *testing.T) {
mp := &mockPlatform{} mp := &mockPlatform{}
_, c := newTestBrowserService(t, mp) _, c := newTestBrowserService(t, mp)
r := c.Action("browser.openFile").Run(context.Background(), core.NewOptions( _, handled, err := c.PERFORM(TaskOpenFile{Path: "/tmp/readme.txt"})
core.Option{Key: "path", Value: "/tmp/readme.txt"}, require.NoError(t, err)
)) assert.True(t, handled)
require.True(t, r.OK)
assert.Equal(t, "/tmp/readme.txt", mp.lastPath) assert.Equal(t, "/tmp/readme.txt", mp.lastPath)
} }
func TestTaskOpenFile_Bad_PlatformError(t *testing.T) { func TestTaskOpenFile_Bad_PlatformError(t *testing.T) {
mp := &mockPlatform{fileErr: core.NewError("file not found")} mp := &mockPlatform{fileErr: errors.New("file not found")}
_, c := newTestBrowserService(t, mp) _, c := newTestBrowserService(t, mp)
r := c.Action("browser.openFile").Run(context.Background(), core.NewOptions( _, handled, err := c.PERFORM(TaskOpenFile{Path: "/nonexistent"})
core.Option{Key: "path", Value: "/nonexistent"}, assert.True(t, handled)
)) assert.Error(t, err)
assert.False(t, r.OK)
} }
func TestTaskOpenURL_Bad_NoService(t *testing.T) { func TestTaskOpenURL_Bad_NoService(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions( _, handled, _ := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
core.Option{Key: "url", Value: "https://example.com"}, assert.False(t, handled)
))
assert.False(t, r.OK)
} }

View file

@ -9,3 +9,16 @@ type TaskSetText struct{ Text string }
// TaskClear clears the clipboard. Result: bool (success) // TaskClear clears the clipboard. Result: bool (success)
type TaskClear struct{} type TaskClear struct{}
// QueryImage reads an image from the clipboard. Result: ClipboardImageContent
type QueryImage struct{}
// TaskSetImage writes image bytes to the clipboard. Result: bool (success)
type TaskSetImage struct{ Data []byte }
// ClipboardImageContent contains clipboard image data encoded for transport.
type ClipboardImageContent struct {
Base64 string `json:"base64"`
MimeType string `json:"mimeType"`
HasContent bool `json:"hasContent"`
}

View file

@ -1,6 +1,8 @@
// pkg/clipboard/platform.go // pkg/clipboard/platform.go
package clipboard package clipboard
import "encoding/base64"
// Platform abstracts the system clipboard backend. // Platform abstracts the system clipboard backend.
type Platform interface { type Platform interface {
Text() (string, bool) Text() (string, bool)
@ -12,3 +14,22 @@ type ClipboardContent struct {
Text string `json:"text"` Text string `json:"text"`
HasContent bool `json:"hasContent"` HasContent bool `json:"hasContent"`
} }
// imageReader is an optional clipboard capability for image reads.
type imageReader interface {
Image() ([]byte, bool)
}
// imageWriter is an optional clipboard capability for image writes.
type imageWriter interface {
SetImage(data []byte) bool
}
// encodeImageContent converts raw bytes to transport-safe clipboard image content.
func encodeImageContent(data []byte) ClipboardImageContent {
return ClipboardImageContent{
Base64: base64.StdEncoding.EncodeToString(data),
MimeType: "image/png",
HasContent: len(data) > 0,
}
}

View file

@ -4,50 +4,77 @@ package clipboard
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options configures the clipboard service.
// Use: core.WithService(clipboard.Register(platform))
type Options struct{} type Options struct{}
// Service manages clipboard operations via Core queries and tasks.
// Use: svc := &clipboard.Service{}
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
} }
// Register(p) binds the clipboard service to a Core instance. // Register creates a Core service factory for the clipboard backend.
// c.WithService(clipboard.Register(wailsClipboard)) // Use: core.New(core.WithService(clipboard.Register(platform)))
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers clipboard handlers with Core.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("clipboard.setText", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
success := s.platform.SetText(opts.String("text")) return nil
return core.Result{Value: success, OK: true}
})
s.Core().Action("clipboard.clear", func(_ context.Context, _ core.Options) core.Result {
success := s.platform.SetText("")
return core.Result{Value: success, OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents satisfies Core's IPC hook.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryText: case QueryText:
text, ok := s.platform.Text() text, ok := s.platform.Text()
return core.Result{Value: ClipboardContent{Text: text, HasContent: ok && text != ""}, OK: true} return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil
case QueryImage:
if reader, ok := s.platform.(imageReader); ok {
data, _ := reader.Image()
return encodeImageContent(data), true, nil
}
return ClipboardImageContent{MimeType: "image/png"}, true, nil
default: default:
return core.Result{} return nil, false, nil
}
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetText:
return s.platform.SetText(t.Text), true, nil
case TaskClear:
_ = s.platform.SetText("")
if writer, ok := s.platform.(imageWriter); ok {
// Best-effort clear for image-aware clipboard backends.
_ = writer.SetImage(nil)
}
return true, true, nil
case TaskSetImage:
if writer, ok := s.platform.(imageWriter); ok {
return writer.SetImage(t.Data), true, nil
}
return false, true, core.E("clipboard.handleTask", "clipboard image write not supported", nil)
default:
return nil, false, nil
} }
} }

View file

@ -5,7 +5,7 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -13,6 +13,8 @@ import (
type mockPlatform struct { type mockPlatform struct {
text string text string
ok bool ok bool
img []byte
imgOk bool
} }
func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok }
@ -21,14 +23,21 @@ func (m *mockPlatform) SetText(text string) bool {
m.ok = text != "" m.ok = text != ""
return true return true
} }
func (m *mockPlatform) Image() ([]byte, bool) { return m.img, m.imgOk }
func (m *mockPlatform) SetImage(data []byte) bool {
m.img = data
m.imgOk = len(data) > 0
return true
}
func newTestService(t *testing.T) (*Service, *core.Core) { func newTestService(t *testing.T) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(&mockPlatform{text: "hello", ok: true})), core.WithService(Register(&mockPlatform{text: "hello", ok: true})),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "clipboard") svc := core.MustServiceFor[*Service](c, "clipboard")
return svc, c return svc, c
} }
@ -40,40 +49,72 @@ func TestRegister_Good(t *testing.T) {
func TestQueryText_Good(t *testing.T) { func TestQueryText_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryText{}) result, handled, err := c.QUERY(QueryText{})
require.True(t, r.OK) require.NoError(t, err)
content := r.Value.(ClipboardContent) assert.True(t, handled)
content := result.(ClipboardContent)
assert.Equal(t, "hello", content.Text) assert.Equal(t, "hello", content.Text)
assert.True(t, content.HasContent) assert.True(t, content.HasContent)
} }
func TestQueryText_Bad(t *testing.T) { func TestQueryText_Bad(t *testing.T) {
// No clipboard service registered // No clipboard service registered
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
r := c.QUERY(QueryText{}) _, handled, _ := c.QUERY(QueryText{})
assert.False(t, r.OK) assert.False(t, handled)
} }
func TestTaskSetText_Good(t *testing.T) { func TestTaskSetText_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.Action("clipboard.setText").Run(context.Background(), core.NewOptions( result, handled, err := c.PERFORM(TaskSetText{Text: "world"})
core.Option{Key: "text", Value: "world"}, require.NoError(t, err)
)) assert.True(t, handled)
require.True(t, r.OK) assert.Equal(t, true, result)
assert.Equal(t, true, r.Value)
// Verify via query // Verify via query
qr := c.QUERY(QueryText{}) r, _, _ := c.QUERY(QueryText{})
assert.Equal(t, "world", qr.Value.(ClipboardContent).Text) assert.Equal(t, "world", r.(ClipboardContent).Text)
} }
func TestTaskClear_Good(t *testing.T) { func TestTaskClear_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.Action("clipboard.clear").Run(context.Background(), core.NewOptions()) _, handled, err := c.PERFORM(TaskClear{})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify empty // Verify empty
qr := c.QUERY(QueryText{}) r, _, _ := c.QUERY(QueryText{})
assert.Equal(t, "", qr.Value.(ClipboardContent).Text) assert.Equal(t, "", r.(ClipboardContent).Text)
assert.False(t, qr.Value.(ClipboardContent).HasContent) assert.False(t, r.(ClipboardContent).HasContent)
}
func TestQueryImage_Good(t *testing.T) {
mock := &mockPlatform{img: []byte{1, 2, 3}, imgOk: true}
c, err := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
result, handled, err := c.QUERY(QueryImage{})
require.NoError(t, err)
assert.True(t, handled)
image := result.(ClipboardImageContent)
assert.True(t, image.HasContent)
}
func TestTaskSetImage_Good(t *testing.T) {
mock := &mockPlatform{}
c, err := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(TaskSetImage{Data: []byte{9, 8, 7}})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.imgOk)
} }

View file

@ -1,10 +1,15 @@
// pkg/contextmenu/messages.go
package contextmenu package contextmenu
import core "dappco.re/go/core" import "errors"
var ErrorMenuNotFound = core.E("contextmenu", "menu not found", nil) // ErrMenuNotFound is returned when attempting to remove or get a menu
// that does not exist in the registry.
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
// QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found) // --- Queries ---
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
type QueryGet struct { type QueryGet struct {
Name string `json:"name"` Name string `json:"name"`
} }
@ -12,34 +17,26 @@ type QueryGet struct {
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef // QueryList returns all registered context menus. Result: map[string]ContextMenuDef
type QueryList struct{} type QueryList struct{}
// QueryGetAll returns all registered context menus. Equivalent to QueryList. // --- Tasks ---
// Result: map[string]ContextMenuDef
type QueryGetAll struct{}
// TaskAdd registers a named context menu. Replaces if already exists. // TaskAdd registers a context menu. Result: nil
// If a menu with the same name already exists it is replaced (remove + re-add).
type TaskAdd struct { type TaskAdd struct {
Name string `json:"name"` Name string `json:"name"`
Menu ContextMenuDef `json:"menu"` Menu ContextMenuDef `json:"menu"`
} }
// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing. // TaskRemove unregisters a context menu. Result: nil
// Returns ErrMenuNotFound if the menu does not exist.
type TaskRemove struct { type TaskRemove struct {
Name string `json:"name"` Name string `json:"name"`
} }
// TaskUpdate replaces an existing context menu's definition. Error: ErrorMenuNotFound if missing. // --- Actions ---
type TaskUpdate struct {
Name string `json:"name"`
Menu ContextMenuDef `json:"menu"`
}
// TaskDestroy removes a context menu and releases all associated resources.
// Error: ErrorMenuNotFound if missing.
type TaskDestroy struct {
Name string `json:"name"`
}
// ActionItemClicked is broadcast when a context menu item is clicked. // ActionItemClicked is broadcast when a context menu item is clicked.
// The Data field is populated from the CSS --custom-contextmenu-data property
// on the element that triggered the context menu.
type ActionItemClicked struct { type ActionItemClicked struct {
MenuName string `json:"menuName"` MenuName string `json:"menuName"`
ActionID string `json:"actionId"` ActionID string `json:"actionId"`

View file

@ -1,15 +1,16 @@
// pkg/contextmenu/register.go
package contextmenu package contextmenu
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the context menu service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(contextmenu.Register(wailsContextMenu)) // The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
registeredMenus: make(map[string]ContextMenuDef), menus: make(map[string]ContextMenuDef),
}, OK: true} }, nil
} }
} }

View file

@ -3,89 +3,84 @@ package contextmenu
import ( import (
"context" "context"
"fmt"
coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/go/pkg/core"
core "dappco.re/go/core"
) )
// Options holds configuration for the context menu service.
type Options struct{} type Options struct{}
// Service is a core.Service managing context menus via IPC.
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
// and delegates platform-level registration to the Platform interface.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
registeredMenus map[string]ContextMenuDef menus map[string]ContextMenuDef
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("contextmenu.add", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskAdd) return nil
return core.Result{Value: nil, OK: true}.New(s.taskAdd(t))
})
s.Core().Action("contextmenu.remove", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskRemove)
return core.Result{Value: nil, OK: true}.New(s.taskRemove(t))
})
s.Core().Action("contextmenu.update", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskUpdate)
return core.Result{Value: nil, OK: true}.New(s.taskUpdate(t))
})
s.Core().Action("contextmenu.destroy", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskDestroy)
return core.Result{Value: nil, OK: true}.New(s.taskDestroy(t))
})
return core.Result{OK: true}
} }
func (s *Service) OnShutdown(_ context.Context) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
// Destroy all registered menus on shutdown to release platform resources func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
for name := range s.registeredMenus { return nil
_ = s.platform.Remove(name)
}
s.registeredMenus = make(map[string]ContextMenuDef)
return core.Result{OK: true}
}
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
return core.Result{OK: true}
} }
// --- Query Handlers --- // --- Query Handlers ---
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) { switch q := q.(type) {
case QueryGet: case QueryGet:
return core.Result{Value: s.queryGet(q), OK: true} return s.queryGet(q), true, nil
case QueryList: case QueryList:
return core.Result{Value: s.queryList(), OK: true} return s.queryList(), true, nil
case QueryGetAll:
return core.Result{Value: s.queryList(), OK: true}
default: default:
return core.Result{} return nil, false, nil
} }
} }
// queryGet returns a single menu definition by name, or nil if not found.
func (s *Service) queryGet(q QueryGet) *ContextMenuDef { func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
menu, ok := s.registeredMenus[q.Name] menu, ok := s.menus[q.Name]
if !ok { if !ok {
return nil return nil
} }
return &menu return &menu
} }
// queryList returns a copy of all registered menus.
func (s *Service) queryList() map[string]ContextMenuDef { func (s *Service) queryList() map[string]ContextMenuDef {
result := make(map[string]ContextMenuDef, len(s.registeredMenus)) result := make(map[string]ContextMenuDef, len(s.menus))
for k, v := range s.registeredMenus { for k, v := range s.menus {
result[k] = v result[k] = v
} }
return result return result
} }
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskAdd:
return nil, true, s.taskAdd(t)
case TaskRemove:
return nil, true, s.taskRemove(t)
default:
return nil, false, nil
}
}
func (s *Service) taskAdd(t TaskAdd) error { func (s *Service) taskAdd(t TaskAdd) error {
// If menu already exists, remove it first (replace semantics) // If menu already exists, remove it first (replace semantics)
if _, exists := s.registeredMenus[t.Name]; exists { if _, exists := s.menus[t.Name]; exists {
_ = s.platform.Remove(t.Name) _ = s.platform.Remove(t.Name)
delete(s.registeredMenus, t.Name) delete(s.menus, t.Name)
} }
// Register on platform with a callback that broadcasts ActionItemClicked // Register on platform with a callback that broadcasts ActionItemClicked
@ -97,61 +92,23 @@ func (s *Service) taskAdd(t TaskAdd) error {
}) })
}) })
if err != nil { if err != nil {
return coreerr.E("contextmenu.taskAdd", "platform add failed", err) return fmt.Errorf("contextmenu: platform add failed: %w", err)
} }
s.registeredMenus[t.Name] = t.Menu s.menus[t.Name] = t.Menu
return nil return nil
} }
func (s *Service) taskRemove(t TaskRemove) error { func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.registeredMenus[t.Name]; !exists { if _, exists := s.menus[t.Name]; !exists {
return ErrorMenuNotFound return ErrMenuNotFound
} }
err := s.platform.Remove(t.Name) err := s.platform.Remove(t.Name)
if err != nil { if err != nil {
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err) return fmt.Errorf("contextmenu: platform remove failed: %w", err)
} }
delete(s.registeredMenus, t.Name) delete(s.menus, t.Name)
return nil
}
func (s *Service) taskUpdate(t TaskUpdate) error {
if _, exists := s.registeredMenus[t.Name]; !exists {
return ErrorMenuNotFound
}
// Re-register with updated definition — remove then add
if err := s.platform.Remove(t.Name); err != nil {
return coreerr.E("contextmenu.taskUpdate", "platform remove failed", err)
}
err := s.platform.Add(t.Name, t.Menu, func(menuName, actionID, data string) {
_ = s.Core().ACTION(ActionItemClicked{
MenuName: menuName,
ActionID: actionID,
Data: data,
})
})
if err != nil {
return coreerr.E("contextmenu.taskUpdate", "platform add failed", err)
}
s.registeredMenus[t.Name] = t.Menu
return nil
}
func (s *Service) taskDestroy(t TaskDestroy) error {
if _, exists := s.registeredMenus[t.Name]; !exists {
return ErrorMenuNotFound
}
if err := s.platform.Remove(t.Name); err != nil {
return coreerr.E("contextmenu.taskDestroy", "platform remove failed", err)
}
delete(s.registeredMenus, t.Name)
return nil return nil
} }

View file

@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -83,22 +83,16 @@ func (m *mockPlatform) simulateClick(menuName, actionID, data string) {
func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(mp)), core.WithService(Register(mp)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "contextmenu") svc := core.MustServiceFor[*Service](c, "contextmenu")
return svc, c return svc, c
} }
// taskRun runs a named action with a task struct and returns the result.
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
svc, _ := newTestContextMenuService(t, mp) svc, _ := newTestContextMenuService(t, mp)
@ -110,7 +104,7 @@ func TestTaskAdd_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
r := taskRun(c, "contextmenu.add", TaskAdd{ _, handled, err := c.PERFORM(TaskAdd{
Name: "file-menu", Name: "file-menu",
Menu: ContextMenuDef{ Menu: ContextMenuDef{
Name: "file-menu", Name: "file-menu",
@ -120,7 +114,8 @@ func TestTaskAdd_Good(t *testing.T) {
}, },
}, },
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify menu registered on platform // Verify menu registered on platform
_, ok := mp.Get("file-menu") _, ok := mp.Get("file-menu")
@ -132,22 +127,22 @@ func TestTaskAdd_Good_ReplaceExisting(t *testing.T) {
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
// Add initial menu // Add initial menu
_ = taskRun(c, "contextmenu.add", TaskAdd{ _, _, _ = c.PERFORM(TaskAdd{
Name: "ctx", Name: "ctx",
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}}, Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}},
}) })
// Replace with new menu // Replace with new menu
r := taskRun(c, "contextmenu.add", TaskAdd{ _, handled, err := c.PERFORM(TaskAdd{
Name: "ctx", Name: "ctx",
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}}, Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}},
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify registry has new menu // Verify registry has new menu
qr := c.QUERY(QueryGet{Name: "ctx"}) result, _, _ := c.QUERY(QueryGet{Name: "ctx"})
require.True(t, qr.OK) def := result.(*ContextMenuDef)
def := qr.Value.(*ContextMenuDef)
require.Len(t, def.Items, 1) require.Len(t, def.Items, 1)
assert.Equal(t, "B", def.Items[0].Label) assert.Equal(t, "B", def.Items[0].Label)
} }
@ -157,33 +152,33 @@ func TestTaskRemove_Good(t *testing.T) {
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
// Add then remove // Add then remove
_ = taskRun(c, "contextmenu.add", TaskAdd{ _, _, _ = c.PERFORM(TaskAdd{
Name: "test", Name: "test",
Menu: ContextMenuDef{Name: "test"}, Menu: ContextMenuDef{Name: "test"},
}) })
r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "test"}) _, handled, err := c.PERFORM(TaskRemove{Name: "test"})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify removed from registry // Verify removed from registry
qr := c.QUERY(QueryGet{Name: "test"}) result, _, _ := c.QUERY(QueryGet{Name: "test"})
assert.Nil(t, qr.Value) assert.Nil(t, result)
} }
func TestTaskRemove_Bad_NotFound(t *testing.T) { func TestTaskRemove_Bad_NotFound(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "nonexistent"}) _, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
assert.False(t, r.OK) assert.True(t, handled)
err, _ := r.Value.(error) assert.ErrorIs(t, err, ErrMenuNotFound)
assert.ErrorIs(t, err, ErrorMenuNotFound)
} }
func TestQueryGet_Good(t *testing.T) { func TestQueryGet_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{ _, _, _ = c.PERFORM(TaskAdd{
Name: "my-menu", Name: "my-menu",
Menu: ContextMenuDef{ Menu: ContextMenuDef{
Name: "my-menu", Name: "my-menu",
@ -191,9 +186,10 @@ func TestQueryGet_Good(t *testing.T) {
}, },
}) })
r := c.QUERY(QueryGet{Name: "my-menu"}) result, handled, err := c.QUERY(QueryGet{Name: "my-menu"})
require.True(t, r.OK) require.NoError(t, err)
def := r.Value.(*ContextMenuDef) assert.True(t, handled)
def := result.(*ContextMenuDef)
assert.Equal(t, "my-menu", def.Name) assert.Equal(t, "my-menu", def.Name)
assert.Len(t, def.Items, 1) assert.Len(t, def.Items, 1)
} }
@ -202,21 +198,23 @@ func TestQueryGet_Good_NotFound(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
r := c.QUERY(QueryGet{Name: "missing"}) result, handled, err := c.QUERY(QueryGet{Name: "missing"})
require.True(t, r.OK) require.NoError(t, err)
assert.Nil(t, r.Value) assert.True(t, handled)
assert.Nil(t, result)
} }
func TestQueryList_Good(t *testing.T) { func TestQueryList_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}}) _, _, _ = c.PERFORM(TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}})
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}}) _, _, _ = c.PERFORM(TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}})
r := c.QUERY(QueryList{}) result, handled, err := c.QUERY(QueryList{})
require.True(t, r.OK) require.NoError(t, err)
list := r.Value.(map[string]ContextMenuDef) assert.True(t, handled)
list := result.(map[string]ContextMenuDef)
assert.Len(t, list, 2) assert.Len(t, list, 2)
} }
@ -224,9 +222,10 @@ func TestQueryList_Good_Empty(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
r := c.QUERY(QueryList{}) result, handled, err := c.QUERY(QueryList{})
require.True(t, r.OK) require.NoError(t, err)
list := r.Value.(map[string]ContextMenuDef) assert.True(t, handled)
list := result.(map[string]ContextMenuDef)
assert.Len(t, list, 0) assert.Len(t, list, 0)
} }
@ -237,16 +236,16 @@ func TestTaskAdd_Good_ClickBroadcast(t *testing.T) {
// Capture broadcast actions // Capture broadcast actions
var clicked ActionItemClicked var clicked ActionItemClicked
var mu sync.Mutex var mu sync.Mutex
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionItemClicked); ok { if a, ok := msg.(ActionItemClicked); ok {
mu.Lock() mu.Lock()
clicked = a clicked = a
mu.Unlock() mu.Unlock()
} }
return core.Result{OK: true} return nil
}) })
_ = taskRun(c, "contextmenu.add", TaskAdd{ _, _, _ = c.PERFORM(TaskAdd{
Name: "file-menu", Name: "file-menu",
Menu: ContextMenuDef{ Menu: ContextMenuDef{
Name: "file-menu", Name: "file-menu",
@ -270,7 +269,7 @@ func TestTaskAdd_Good_SubmenuItems(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp) _, c := newTestContextMenuService(t, mp)
r := taskRun(c, "contextmenu.add", TaskAdd{ _, handled, err := c.PERFORM(TaskAdd{
Name: "nested", Name: "nested",
Menu: ContextMenuDef{ Menu: ContextMenuDef{
Name: "nested", Name: "nested",
@ -284,197 +283,17 @@ func TestTaskAdd_Good_SubmenuItems(t *testing.T) {
}, },
}, },
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
qr := c.QUERY(QueryGet{Name: "nested"}) result, _, _ := c.QUERY(QueryGet{Name: "nested"})
def := qr.Value.(*ContextMenuDef) def := result.(*ContextMenuDef)
assert.Len(t, def.Items, 3) assert.Len(t, def.Items, 3)
assert.Len(t, def.Items[0].Items, 2) // submenu children assert.Len(t, def.Items[0].Items, 2) // submenu children
} }
func TestQueryList_Bad_NoService(t *testing.T) { func TestQueryList_Bad_NoService(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
r := c.QUERY(QueryList{}) _, handled, _ := c.QUERY(QueryList{})
assert.False(t, r.OK) assert.False(t, handled)
}
// --- TaskUpdate ---
func TestTaskUpdate_Good(t *testing.T) {
// Update replaces items on an existing menu
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{
Name: "edit-menu",
Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{{Label: "Cut", ActionID: "cut"}}},
})
r := taskRun(c, "contextmenu.update", TaskUpdate{
Name: "edit-menu",
Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{
{Label: "Cut", ActionID: "cut"},
{Label: "Copy", ActionID: "copy"},
}},
})
require.True(t, r.OK)
qr := c.QUERY(QueryGet{Name: "edit-menu"})
def := qr.Value.(*ContextMenuDef)
assert.Len(t, def.Items, 2)
assert.Equal(t, "Copy", def.Items[1].Label)
}
func TestTaskUpdate_Bad_NotFound(t *testing.T) {
// Update on a non-existent menu returns ErrorMenuNotFound
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
r := taskRun(c, "contextmenu.update", TaskUpdate{
Name: "ghost",
Menu: ContextMenuDef{Name: "ghost"},
})
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, ErrorMenuNotFound)
}
func TestTaskUpdate_Ugly_PlatformRemoveError(t *testing.T) {
// Platform Remove fails mid-update — error is propagated
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{
Name: "tricky",
Menu: ContextMenuDef{Name: "tricky"},
})
mp.mu.Lock()
mp.removeErr = ErrorMenuNotFound // reuse sentinel as a platform-level error
mp.mu.Unlock()
r := taskRun(c, "contextmenu.update", TaskUpdate{
Name: "tricky",
Menu: ContextMenuDef{Name: "tricky", Items: []MenuItemDef{{Label: "X", ActionID: "x"}}},
})
assert.False(t, r.OK)
}
// --- TaskDestroy ---
func TestTaskDestroy_Good(t *testing.T) {
// Destroy removes the menu and releases platform resources
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "doomed", Menu: ContextMenuDef{Name: "doomed"}})
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "doomed"})
require.True(t, r.OK)
qr := c.QUERY(QueryGet{Name: "doomed"})
assert.Nil(t, qr.Value)
_, ok := mp.Get("doomed")
assert.False(t, ok)
}
func TestTaskDestroy_Bad_NotFound(t *testing.T) {
// Destroy on a non-existent menu returns ErrorMenuNotFound
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "nonexistent"})
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, ErrorMenuNotFound)
}
func TestTaskDestroy_Ugly_PlatformError(t *testing.T) {
// Platform Remove fails — error is propagated but service remains consistent
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "frail", Menu: ContextMenuDef{Name: "frail"}})
mp.mu.Lock()
mp.removeErr = ErrorMenuNotFound
mp.mu.Unlock()
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "frail"})
assert.False(t, r.OK)
}
// --- QueryGetAll ---
func TestQueryGetAll_Good(t *testing.T) {
// QueryGetAll returns all registered menus (equivalent to QueryList)
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "x", Menu: ContextMenuDef{Name: "x"}})
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "y", Menu: ContextMenuDef{Name: "y"}})
r := c.QUERY(QueryGetAll{})
require.True(t, r.OK)
all := r.Value.(map[string]ContextMenuDef)
assert.Len(t, all, 2)
assert.Contains(t, all, "x")
assert.Contains(t, all, "y")
}
func TestQueryGetAll_Bad_Empty(t *testing.T) {
// QueryGetAll on an empty registry returns an empty map
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
r := c.QUERY(QueryGetAll{})
require.True(t, r.OK)
all := r.Value.(map[string]ContextMenuDef)
assert.Len(t, all, 0)
}
func TestQueryGetAll_Ugly_NoService(t *testing.T) {
// No contextmenu service — query is unhandled
c := core.New(core.WithServiceLock())
r := c.QUERY(QueryGetAll{})
assert.False(t, r.OK)
}
// --- OnShutdown ---
func TestOnShutdown_Good_CleansUpMenus(t *testing.T) {
// OnShutdown removes all registered menus from the platform
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "alpha", Menu: ContextMenuDef{Name: "alpha"}})
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "beta", Menu: ContextMenuDef{Name: "beta"}})
require.True(t, c.ServiceShutdown(t.Context()).OK)
assert.Len(t, mp.menus, 0)
}
func TestOnShutdown_Bad_NothingRegistered(t *testing.T) {
// OnShutdown with no menus — no-op, no error
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
assert.True(t, c.ServiceShutdown(t.Context()).OK)
}
func TestOnShutdown_Ugly_PlatformRemoveErrors(t *testing.T) {
// Platform Remove errors during shutdown are silently swallowed
mp := newMockPlatform()
_, c := newTestContextMenuService(t, mp)
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "stubborn", Menu: ContextMenuDef{Name: "stubborn"}})
mp.mu.Lock()
mp.removeErr = ErrorMenuNotFound
mp.mu.Unlock()
// Shutdown must not return an error even if platform Remove fails
assert.True(t, c.ServiceShutdown(t.Context()).OK)
} }

View file

@ -1,75 +1,14 @@
// pkg/dialog/messages.go
package dialog package dialog
// TaskOpenFile presents an open-file dialog with the given options. // TaskOpenFile shows an open file dialog. Result: []string (paths)
// type TaskOpenFile struct{ Opts OpenFileOptions }
// result, _, err := c.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{Title: "Pick file"}})
// paths := result.([]string)
type TaskOpenFile struct{ Options OpenFileOptions }
// TaskOpenFileWithOptions presents an open-file dialog pre-configured from an options struct. // TaskSaveFile shows a save file dialog. Result: string (path)
// Equivalent to TaskOpenFile but mirrors the stub DialogManager.OpenFileWithOptions API. type TaskSaveFile struct{ Opts SaveFileOptions }
//
// result, _, err := c.PERFORM(dialog.TaskOpenFileWithOptions{Options: &dialog.OpenFileOptions{Title: "Select log", AllowMultiple: true}})
type TaskOpenFileWithOptions struct{ Options *OpenFileOptions }
// TaskSaveFile presents a save-file dialog with the given options. // TaskOpenDirectory shows a directory picker. Result: string (path)
// type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
// result, _, err := c.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{Filename: "report.csv"}})
// path := result.(string)
type TaskSaveFile struct{ Options SaveFileOptions }
// TaskSaveFileWithOptions presents a save-file dialog pre-configured from an options struct. // TaskMessageDialog shows a message dialog. Result: string (button clicked)
// Equivalent to TaskSaveFile but mirrors the stub DialogManager.SaveFileWithOptions API. type TaskMessageDialog struct{ Opts MessageDialogOptions }
//
// result, _, err := c.PERFORM(dialog.TaskSaveFileWithOptions{Options: &dialog.SaveFileOptions{Title: "Export data"}})
type TaskSaveFileWithOptions struct{ Options *SaveFileOptions }
// TaskOpenDirectory presents a directory picker dialog.
//
// result, _, err := c.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{Title: "Choose folder"}})
// path := result.(string)
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
// TaskMessageDialog presents a message dialog of the given type.
//
// result, _, err := c.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{Type: dialog.DialogQuestion, Title: "Confirm", Message: "Delete?", Buttons: []string{"Yes", "No"}}})
// clicked := result.(string)
type TaskMessageDialog struct{ Options MessageDialogOptions }
// TaskInfo presents an information message dialog.
//
// result, _, err := c.PERFORM(dialog.TaskInfo{Title: "Done", Message: "File saved successfully."})
// clicked := result.(string)
type TaskInfo struct {
Title string
Message string
Buttons []string
}
// TaskQuestion presents a question message dialog.
//
// result, _, err := c.PERFORM(dialog.TaskQuestion{Title: "Confirm", Message: "Delete file?", Buttons: []string{"Yes", "No"}})
// if result.(string) == "Yes" { deleteFile() }
type TaskQuestion struct {
Title string
Message string
Buttons []string
}
// TaskWarning presents a warning message dialog.
//
// result, _, err := c.PERFORM(dialog.TaskWarning{Title: "Low disk", Message: "Disk space is critically low."})
type TaskWarning struct {
Title string
Message string
Buttons []string
}
// TaskError presents an error message dialog.
//
// result, _, err := c.PERFORM(dialog.TaskError{Title: "Operation failed", Message: err.Error()})
type TaskError struct {
Title string
Message string
Buttons []string
}

View file

@ -3,10 +3,10 @@ package dialog
// Platform abstracts the native dialog backend. // Platform abstracts the native dialog backend.
type Platform interface { type Platform interface {
OpenFile(options OpenFileOptions) ([]string, error) OpenFile(opts OpenFileOptions) ([]string, error)
SaveFile(options SaveFileOptions) (string, error) SaveFile(opts SaveFileOptions) (string, error)
OpenDirectory(options OpenDirectoryOptions) (string, error) OpenDirectory(opts OpenDirectoryOptions) (string, error)
MessageDialog(options MessageDialogOptions) (string, error) MessageDialog(opts MessageDialogOptions) (string, error)
} }
// DialogType represents the type of message dialog. // DialogType represents the type of message dialog.
@ -20,38 +20,27 @@ const (
) )
// OpenFileOptions contains options for the open file dialog. // OpenFileOptions contains options for the open file dialog.
//
// opts := OpenFileOptions{Title: "Select image", Filters: []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}}}
type OpenFileOptions struct { type OpenFileOptions struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
Filters []FileFilter `json:"filters,omitempty"` Filters []FileFilter `json:"filters,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"` AllowMultiple bool `json:"allowMultiple,omitempty"`
CanChooseDirectories bool `json:"canChooseDirectories,omitempty"`
CanChooseFiles bool `json:"canChooseFiles,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
// SaveFileOptions contains options for the save file dialog. // SaveFileOptions contains options for the save file dialog.
//
// opts := SaveFileOptions{Title: "Export", Filename: "report.pdf", ShowHiddenFiles: false}
type SaveFileOptions struct { type SaveFileOptions struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
Filters []FileFilter `json:"filters,omitempty"` Filters []FileFilter `json:"filters,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
// OpenDirectoryOptions contains options for the directory picker. // OpenDirectoryOptions contains options for the directory picker.
//
// opts := OpenDirectoryOptions{Title: "Choose folder", ShowHiddenFiles: true}
type OpenDirectoryOptions struct { type OpenDirectoryOptions struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"` AllowMultiple bool `json:"allowMultiple,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
// MessageDialogOptions contains options for a message dialog. // MessageDialogOptions contains options for a message dialog.

View file

@ -4,96 +4,54 @@ package dialog
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the dialog service.
type Options struct{} type Options struct{}
// Service is a core.Service managing native dialogs via IPC.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
} }
// Register(p) binds the dialog service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// func Register(p Platform) func(*core.Core) (any, error) {
// c.WithService(dialog.Register(wailsDialog)) return func(c *core.Core) (any, error) {
func Register(p Platform) func(*core.Core) core.Result { return &Service{
return func(c *core.Core) core.Result {
return core.Result{Value: &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
s.Core().Action("dialog.openFile", func(_ context.Context, opts core.Options) core.Result { func (s *Service) OnStartup(ctx context.Context) error {
var openOpts OpenFileOptions s.Core().RegisterTask(s.handleTask)
switch v := opts.Get("task").Value.(type) { return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFile: case TaskOpenFile:
openOpts = v.Options paths, err := s.platform.OpenFile(t.Opts)
case TaskOpenFileWithOptions: return paths, true, err
if v.Options != nil {
openOpts = *v.Options
}
}
paths, err := s.platform.OpenFile(openOpts)
return core.Result{}.New(paths, err)
})
s.Core().Action("dialog.saveFile", func(_ context.Context, opts core.Options) core.Result {
var saveOpts SaveFileOptions
switch v := opts.Get("task").Value.(type) {
case TaskSaveFile: case TaskSaveFile:
saveOpts = v.Options path, err := s.platform.SaveFile(t.Opts)
case TaskSaveFileWithOptions: return path, true, err
if v.Options != nil { case TaskOpenDirectory:
saveOpts = *v.Options path, err := s.platform.OpenDirectory(t.Opts)
return path, true, err
case TaskMessageDialog:
button, err := s.platform.MessageDialog(t.Opts)
return button, true, err
default:
return nil, false, nil
} }
} }
path, err := s.platform.SaveFile(saveOpts)
return core.Result{}.New(path, err)
})
s.Core().Action("dialog.openDirectory", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskOpenDirectory)
path, err := s.platform.OpenDirectory(t.Options)
return core.Result{}.New(path, err)
})
s.Core().Action("dialog.message", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskMessageDialog)
button, err := s.platform.MessageDialog(t.Options)
return core.Result{}.New(button, err)
})
s.Core().Action("dialog.info", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskInfo)
button, err := s.platform.MessageDialog(MessageDialogOptions{
Type: DialogInfo, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
})
return core.Result{}.New(button, err)
})
s.Core().Action("dialog.question", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskQuestion)
button, err := s.platform.MessageDialog(MessageDialogOptions{
Type: DialogQuestion, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
})
return core.Result{}.New(button, err)
})
s.Core().Action("dialog.warning", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskWarning)
button, err := s.platform.MessageDialog(MessageDialogOptions{
Type: DialogWarning, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
})
return core.Result{}.New(button, err)
})
s.Core().Action("dialog.error", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskError)
button, err := s.platform.MessageDialog(MessageDialogOptions{
Type: DialogError, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
})
return core.Result{}.New(button, err)
})
return core.Result{OK: true}
}
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
return core.Result{OK: true}
}

View file

@ -5,7 +5,7 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -50,341 +50,74 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
openDirPath: "/tmp/dir", openDirPath: "/tmp/dir",
messageButton: "OK", messageButton: "OK",
} }
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return mock, c return mock, c
} }
func taskRun(c *core.Core, name string, task any) core.Result { func TestRegister_Good(t *testing.T) {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
// --- Good path tests ---
func TestService_Register_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
svc := core.MustServiceFor[*Service](c, "dialog") svc := core.MustServiceFor[*Service](c, "dialog")
assert.NotNil(t, svc) assert.NotNil(t, svc)
} }
func TestService_TaskOpenFile_Good(t *testing.T) { func TestTaskOpenFile_Good(t *testing.T) {
mock, c := newTestService(t) mock, c := newTestService(t)
mock.openFilePaths = []string{"/a.txt", "/b.txt"} mock.openFilePaths = []string{"/a.txt", "/b.txt"}
r := taskRun(c, "dialog.openFile", TaskOpenFile{ result, handled, err := c.PERFORM(TaskOpenFile{
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true}, Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
}) })
require.True(t, r.OK) require.NoError(t, err)
paths := r.Value.([]string) assert.True(t, handled)
paths := result.([]string)
assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths) assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths)
assert.Equal(t, "Pick", mock.lastOpenOpts.Title) assert.Equal(t, "Pick", mock.lastOpenOpts.Title)
assert.True(t, mock.lastOpenOpts.AllowMultiple) assert.True(t, mock.lastOpenOpts.AllowMultiple)
} }
func TestService_TaskOpenFile_FileFilters_Good(t *testing.T) { func TestTaskSaveFile_Good(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = []string{"/img.png"}
filters := []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}}
r := taskRun(c, "dialog.openFile", TaskOpenFile{
Options: OpenFileOptions{
Title: "Select image",
Filters: filters,
},
})
require.True(t, r.OK)
assert.Equal(t, []string{"/img.png"}, r.Value.([]string))
require.Len(t, mock.lastOpenOpts.Filters, 1)
assert.Equal(t, "Images", mock.lastOpenOpts.Filters[0].DisplayName)
assert.Equal(t, "*.png;*.jpg", mock.lastOpenOpts.Filters[0].Pattern)
}
func TestService_TaskOpenFile_MultipleSelection_Good(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = []string{"/a.txt", "/b.txt", "/c.txt"}
r := taskRun(c, "dialog.openFile", TaskOpenFile{
Options: OpenFileOptions{AllowMultiple: true},
})
require.True(t, r.OK)
assert.Equal(t, []string{"/a.txt", "/b.txt", "/c.txt"}, r.Value.([]string))
assert.True(t, mock.lastOpenOpts.AllowMultiple)
}
func TestService_TaskOpenFile_CanChooseOptions_Good(t *testing.T) {
mock, c := newTestService(t)
r := taskRun(c, "dialog.openFile", TaskOpenFile{
Options: OpenFileOptions{
CanChooseFiles: true,
CanChooseDirectories: true,
ShowHiddenFiles: true,
},
})
require.True(t, r.OK)
assert.True(t, mock.lastOpenOpts.CanChooseFiles)
assert.True(t, mock.lastOpenOpts.CanChooseDirectories)
assert.True(t, mock.lastOpenOpts.ShowHiddenFiles)
}
func TestService_TaskOpenFileWithOptions_Good(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = []string{"/log.txt"}
opts := &OpenFileOptions{
Title: "Select log",
AllowMultiple: false,
ShowHiddenFiles: true,
}
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: opts})
require.True(t, r.OK)
assert.Equal(t, []string{"/log.txt"}, r.Value.([]string))
assert.Equal(t, "Select log", mock.lastOpenOpts.Title)
assert.True(t, mock.lastOpenOpts.ShowHiddenFiles)
}
func TestService_TaskOpenFileWithOptions_NilOptions_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
result, handled, err := c.PERFORM(TaskSaveFile{
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: nil}) Opts: SaveFileOptions{Filename: "out.txt"},
require.True(t, r.OK) })
assert.NotNil(t, r.Value) require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "/tmp/save.txt", result)
} }
func TestService_TaskSaveFile_Good(t *testing.T) { func TestTaskOpenDirectory_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := taskRun(c, "dialog.saveFile", TaskSaveFile{ result, handled, err := c.PERFORM(TaskOpenDirectory{
Options: SaveFileOptions{Filename: "out.txt"}, Opts: OpenDirectoryOptions{Title: "Pick Dir"},
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.Equal(t, "/tmp/save.txt", r.Value) assert.True(t, handled)
assert.Equal(t, "/tmp/dir", result)
} }
func TestService_TaskSaveFile_ShowHidden_Good(t *testing.T) { func TestTaskMessageDialog_Good(t *testing.T) {
mock, c := newTestService(t)
r := taskRun(c, "dialog.saveFile", TaskSaveFile{
Options: SaveFileOptions{Filename: "out.txt", ShowHiddenFiles: true},
})
require.True(t, r.OK)
assert.True(t, mock.lastSaveOpts.ShowHiddenFiles)
}
func TestService_TaskSaveFileWithOptions_Good(t *testing.T) {
mock, c := newTestService(t)
mock.saveFilePath = "/exports/data.json"
opts := &SaveFileOptions{
Title: "Export data",
Filename: "data.json",
Filters: []FileFilter{{DisplayName: "JSON", Pattern: "*.json"}},
}
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: opts})
require.True(t, r.OK)
assert.Equal(t, "/exports/data.json", r.Value.(string))
assert.Equal(t, "Export data", mock.lastSaveOpts.Title)
require.Len(t, mock.lastSaveOpts.Filters, 1)
assert.Equal(t, "JSON", mock.lastSaveOpts.Filters[0].DisplayName)
}
func TestService_TaskSaveFileWithOptions_NilOptions_Good(t *testing.T) {
_, c := newTestService(t)
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: nil})
require.True(t, r.OK)
assert.Equal(t, "/tmp/save.txt", r.Value)
}
func TestService_TaskOpenDirectory_Good(t *testing.T) {
mock, c := newTestService(t)
r := taskRun(c, "dialog.openDirectory", TaskOpenDirectory{
Options: OpenDirectoryOptions{Title: "Pick Dir", ShowHiddenFiles: true},
})
require.True(t, r.OK)
assert.Equal(t, "/tmp/dir", r.Value)
assert.Equal(t, "Pick Dir", mock.lastDirOpts.Title)
assert.True(t, mock.lastDirOpts.ShowHiddenFiles)
}
func TestService_TaskMessageDialog_Good(t *testing.T) {
mock, c := newTestService(t) mock, c := newTestService(t)
mock.messageButton = "Yes" mock.messageButton = "Yes"
r := taskRun(c, "dialog.message", TaskMessageDialog{ result, handled, err := c.PERFORM(TaskMessageDialog{
Options: MessageDialogOptions{ Opts: MessageDialogOptions{
Type: DialogQuestion, Title: "Confirm", Type: DialogQuestion, Title: "Confirm",
Message: "Sure?", Buttons: []string{"Yes", "No"}, Message: "Sure?", Buttons: []string{"Yes", "No"},
}, },
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.Equal(t, "Yes", r.Value) assert.True(t, handled)
assert.Equal(t, "Yes", result)
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type) assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
} }
func TestService_TaskInfo_Good(t *testing.T) { func TestTaskOpenFile_Bad(t *testing.T) {
mock, c := newTestService(t) c, _ := core.New(core.WithServiceLock())
mock.messageButton = "OK" _, handled, _ := c.PERFORM(TaskOpenFile{})
assert.False(t, handled)
r := taskRun(c, "dialog.info", TaskInfo{
Title: "Done", Message: "File saved successfully.",
})
require.True(t, r.OK)
assert.Equal(t, "OK", r.Value.(string))
assert.Equal(t, DialogInfo, mock.lastMsgOpts.Type)
assert.Equal(t, "Done", mock.lastMsgOpts.Title)
assert.Equal(t, "File saved successfully.", mock.lastMsgOpts.Message)
}
func TestService_TaskInfo_WithButtons_Good(t *testing.T) {
mock, c := newTestService(t)
mock.messageButton = "Close"
r := taskRun(c, "dialog.info", TaskInfo{
Title: "Notice", Message: "Update available.", Buttons: []string{"Close", "Later"},
})
require.True(t, r.OK)
assert.Equal(t, "Close", r.Value.(string))
assert.Equal(t, []string{"Close", "Later"}, mock.lastMsgOpts.Buttons)
}
func TestService_TaskQuestion_Good(t *testing.T) {
mock, c := newTestService(t)
mock.messageButton = "Yes"
r := taskRun(c, "dialog.question", TaskQuestion{
Title: "Confirm deletion", Message: "Delete file?", Buttons: []string{"Yes", "No"},
})
require.True(t, r.OK)
assert.Equal(t, "Yes", r.Value.(string))
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
assert.Equal(t, "Confirm deletion", mock.lastMsgOpts.Title)
}
func TestService_TaskWarning_Good(t *testing.T) {
mock, c := newTestService(t)
mock.messageButton = "OK"
r := taskRun(c, "dialog.warning", TaskWarning{
Title: "Disk full", Message: "Storage is critically low.", Buttons: []string{"OK"},
})
require.True(t, r.OK)
assert.Equal(t, "OK", r.Value.(string))
assert.Equal(t, DialogWarning, mock.lastMsgOpts.Type)
assert.Equal(t, "Disk full", mock.lastMsgOpts.Title)
}
func TestService_TaskError_Good(t *testing.T) {
mock, c := newTestService(t)
mock.messageButton = "OK"
r := taskRun(c, "dialog.error", TaskError{
Title: "Operation failed", Message: "could not write file: permission denied",
})
require.True(t, r.OK)
assert.Equal(t, "OK", r.Value.(string))
assert.Equal(t, DialogError, mock.lastMsgOpts.Type)
assert.Equal(t, "Operation failed", mock.lastMsgOpts.Title)
assert.Equal(t, "could not write file: permission denied", mock.lastMsgOpts.Message)
}
// --- Bad path tests ---
func TestService_TaskOpenFile_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.openFile").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskOpenFileWithOptions_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.openFile").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskSaveFileWithOptions_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.saveFile").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskInfo_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.info").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskQuestion_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.question").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskWarning_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.warning").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestService_TaskError_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.error").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- Ugly path tests ---
func TestService_TaskOpenFile_Ugly(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = nil
r := taskRun(c, "dialog.openFile", TaskOpenFile{
Options: OpenFileOptions{Title: "Pick"},
})
require.True(t, r.OK)
assert.Nil(t, r.Value.([]string))
}
func TestService_TaskOpenFileWithOptions_MultipleFilters_Ugly(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = []string{"/doc.pdf"}
opts := &OpenFileOptions{
Title: "Select document",
Filters: []FileFilter{
{DisplayName: "PDF", Pattern: "*.pdf"},
{DisplayName: "Word", Pattern: "*.docx"},
{DisplayName: "All files", Pattern: "*.*"},
},
}
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: opts})
require.True(t, r.OK)
assert.Equal(t, []string{"/doc.pdf"}, r.Value.([]string))
assert.Len(t, mock.lastOpenOpts.Filters, 3)
}
func TestService_TaskSaveFileWithOptions_FiltersAndHidden_Ugly(t *testing.T) {
mock, c := newTestService(t)
opts := &SaveFileOptions{
Title: "Save",
Filename: "output.csv",
ShowHiddenFiles: true,
Filters: []FileFilter{{DisplayName: "CSV", Pattern: "*.csv"}},
}
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: opts})
require.True(t, r.OK)
assert.True(t, mock.lastSaveOpts.ShowHiddenFiles)
assert.Equal(t, "output.csv", mock.lastSaveOpts.Filename)
}
func TestService_UnknownTask_Ugly(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("dialog.nonexistent").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
} }

View file

@ -31,6 +31,7 @@ This document tracks the implementation of display server features that enable A
- [x] `window_title_get` - Get current window title (returns window name) - [x] `window_title_get` - Get current window title (returns window name)
- [x] `window_always_on_top` - Pin window above others - [x] `window_always_on_top` - Pin window above others
- [x] `window_background_colour` - Set window background color with alpha (transparency) - [x] `window_background_colour` - Set window background color with alpha (transparency)
- [x] `window_opacity` - Set window opacity
- [x] `window_fullscreen` - Enter/exit fullscreen mode - [x] `window_fullscreen` - Enter/exit fullscreen mode
--- ---
@ -59,13 +60,13 @@ This document tracks the implementation of display server features that enable A
### Smart Layout ### Smart Layout
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid) - [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
- [x] `layout_stack` - Stack windows in cascade pattern - [x] `layout_stack` - Stack windows in cascade pattern
- [ ] `layout_beside_editor` - Position window beside detected IDE window - [x] `layout_beside_editor` - Position window beside detected IDE window
- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement - [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_snap` - Snap window to screen edge/corner/center - [x] `layout_snap` - Snap window to screen edge/corner/center
### AI-Optimized Layout ### AI-Optimized Layout
- [ ] `screen_find_space` - Find empty screen space for new window - [x] `screen_find_space` - Find empty screen space for new window
- [ ] `window_arrange_pair` - Put two windows side-by-side optimally - [x] `window_arrange_pair` - Put two windows side-by-side optimally
- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side" - [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side"
--- ---
@ -114,8 +115,8 @@ This document tracks the implementation of display server features that enable A
- [x] `webview_resources` - List loaded resources (scripts, styles, images) - [x] `webview_resources` - List loaded resources (scripts, styles, images)
### DevTools ### DevTools
- [ ] `webview_devtools_open` - Open DevTools for window - [x] `webview_devtools_open` - Open DevTools for window
- [ ] `webview_devtools_close` - Close DevTools - [x] `webview_devtools_close` - Close DevTools
--- ---
@ -124,8 +125,8 @@ This document tracks the implementation of display server features that enable A
### Clipboard ### Clipboard
- [x] `clipboard_read` - Read clipboard text content - [x] `clipboard_read` - Read clipboard text content
- [x] `clipboard_write` - Write text to clipboard - [x] `clipboard_write` - Write text to clipboard
- [ ] `clipboard_read_image` - Read image from clipboard - [x] `clipboard_read_image` - Read image from clipboard
- [ ] `clipboard_write_image` - Write image to clipboard - [x] `clipboard_write_image` - Write image to clipboard
- [x] `clipboard_has` - Check clipboard content type - [x] `clipboard_has` - Check clipboard content type
- [x] `clipboard_clear` - Clear clipboard contents - [x] `clipboard_clear` - Clear clipboard contents
@ -133,8 +134,8 @@ This document tracks the implementation of display server features that enable A
- [x] `notification_show` - Show native system notification (macOS/Windows/Linux) - [x] `notification_show` - Show native system notification (macOS/Windows/Linux)
- [x] `notification_permission_request` - Request notification permission - [x] `notification_permission_request` - Request notification permission
- [x] `notification_permission_check` - Check notification authorization status - [x] `notification_permission_check` - Check notification authorization status
- [ ] `notification_clear` - Clear notifications - [x] `notification_clear` - Clear notifications
- [ ] `notification_with_actions` - Interactive notifications with buttons - [x] `notification_with_actions` - Interactive notifications with buttons
### Dialogs ### Dialogs
- [x] `dialog_open_file` - Show file open dialog - [x] `dialog_open_file` - Show file open dialog
@ -142,11 +143,11 @@ This document tracks the implementation of display server features that enable A
- [x] `dialog_open_directory` - Show directory picker - [x] `dialog_open_directory` - Show directory picker
- [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show) - [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show)
- [x] `dialog_confirm` - Show confirmation dialog - [x] `dialog_confirm` - Show confirmation dialog
- [~] `dialog_prompt` - Show input prompt dialog (not supported natively in Wails v3) - [x] `dialog_prompt` - Show input prompt dialog with a webview fallback when native support is unavailable
### Theme & Appearance ### Theme & Appearance
- [x] `theme_get` - Get current theme (dark/light) - [x] `theme_get` - Get current theme (dark/light)
- [ ] `theme_set` - Set application theme - [x] `theme_set` - Set application theme
- [x] `theme_system` - Get system theme preference - [x] `theme_system` - Get system theme preference
- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events) - [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events)
@ -173,7 +174,7 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_set_label` - Set tray label text - [x] `tray_set_label` - Set tray label text
- [x] `tray_set_menu` - Set tray menu items (with nested submenus) - [x] `tray_set_menu` - Set tray menu items (with nested submenus)
- [x] `tray_info` - Get tray status info - [x] `tray_info` - Get tray status info
- [ ] `tray_show_message` - Show tray balloon notification - [x] `tray_show_message` - Show tray balloon notification
--- ---
@ -235,7 +236,6 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_info` - Get tray status - [x] `tray_info` - Get tray status
### Phase 8 - Remaining Features (Future) ### Phase 8 - Remaining Features (Future)
- [ ] window_opacity (true opacity if Wails adds support)
- [ ] layout_beside_editor, layout_suggest - [ ] layout_beside_editor, layout_suggest
- [ ] webview_devtools_open, webview_devtools_close - [ ] webview_devtools_open, webview_devtools_close
- [ ] clipboard_read_image, clipboard_write_image - [ ] clipboard_read_image, clipboard_write_image

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,19 @@
// pkg/display/events.go
package display package display
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/window" "forge.lthn.ai/core/gui/pkg/window"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
// EventType represents the type of event. // EventType represents the type of event.
// Use: eventType := display.EventWindowFocus
type EventType string type EventType string
const ( const (
@ -40,14 +42,10 @@ const (
EventContextMenuClick EventType = "contextmenu.item-clicked" EventContextMenuClick EventType = "contextmenu.item-clicked"
EventWebviewConsole EventType = "webview.console" EventWebviewConsole EventType = "webview.console"
EventWebviewException EventType = "webview.exception" EventWebviewException EventType = "webview.exception"
EventCustomEvent EventType = "custom.event"
EventDockProgress EventType = "dock.progress"
EventDockBounce EventType = "dock.bounce"
EventNotificationAction EventType = "notification.action"
EventNotificationDismiss EventType = "notification.dismissed"
) )
// Event represents a display event sent to subscribers. // Event represents a display event sent to subscribers.
// Use: evt := display.Event{Type: display.EventWindowFocus, Window: "editor"}
type Event struct { type Event struct {
Type EventType `json:"type"` Type EventType `json:"type"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
@ -56,12 +54,22 @@ type Event struct {
} }
// Subscription represents a client subscription to events. // Subscription represents a client subscription to events.
// Use: sub := display.Subscription{ID: "sub-1", EventTypes: []display.EventType{display.EventWindowFocus}}
type Subscription struct { type Subscription struct {
ID string `json:"id"` ID string `json:"id"`
EventTypes []EventType `json:"eventTypes"` EventTypes []EventType `json:"eventTypes"`
} }
// EventServerInfo summarises the live WebSocket event server state.
// Use: info := display.EventServerInfo{ConnectedClients: 1, Subscriptions: 3}
type EventServerInfo struct {
ConnectedClients int `json:"connectedClients"`
Subscriptions int `json:"subscriptions"`
BufferedEvents int `json:"bufferedEvents"`
}
// WSEventManager manages WebSocket connections and event subscriptions. // WSEventManager manages WebSocket connections and event subscriptions.
// Use: events := display.NewWSEventManager()
type WSEventManager struct { type WSEventManager struct {
upgrader websocket.Upgrader upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState clients map[*websocket.Conn]*clientState
@ -71,12 +79,14 @@ type WSEventManager struct {
} }
// clientState tracks a client's subscriptions. // clientState tracks a client's subscriptions.
// Use: state := &clientState{subscriptions: map[string]*Subscription{}}
type clientState struct { type clientState struct {
subscriptions map[string]*Subscription subscriptions map[string]*Subscription
mu sync.RWMutex mu sync.RWMutex
} }
// NewWSEventManager creates a new event manager. // NewWSEventManager creates a new event manager.
// Use: events := display.NewWSEventManager()
func NewWSEventManager() *WSEventManager { func NewWSEventManager() *WSEventManager {
em := &WSEventManager{ em := &WSEventManager{
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
@ -134,11 +144,10 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
return return
} }
marshalResult := core.JSONMarshal(event) data, err := json.Marshal(event)
if !marshalResult.OK { if err != nil {
return return
} }
data, _ := marshalResult.Value.([]byte)
conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
@ -179,7 +188,7 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
EventTypes []EventType `json:"eventTypes,omitempty"` EventTypes []EventType `json:"eventTypes,omitempty"`
} }
if unmarshalResult := core.JSONUnmarshal(message, &msg); !unmarshalResult.OK { if err := json.Unmarshal(message, &msg); err != nil {
continue continue
} }
@ -208,7 +217,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
if id == "" { if id == "" {
em.mu.Lock() em.mu.Lock()
em.nextSubID++ em.nextSubID++
id = "sub-" + strconv.Itoa(em.nextSubID) id = fmt.Sprintf("sub-%d", em.nextSubID)
em.mu.Unlock() em.mu.Unlock()
} }
@ -225,10 +234,8 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
"id": id, "id": id,
"eventTypes": eventTypes, "eventTypes": eventTypes,
} }
if marshalResult := core.JSONMarshal(response); marshalResult.OK { data, _ := json.Marshal(response)
responseData, _ := marshalResult.Value.([]byte) conn.WriteMessage(websocket.TextMessage, data)
conn.WriteMessage(websocket.TextMessage, responseData)
}
} }
// unsubscribe removes a subscription for a client. // unsubscribe removes a subscription for a client.
@ -250,10 +257,8 @@ func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
"type": "unsubscribed", "type": "unsubscribed",
"id": id, "id": id,
} }
if marshalResult := core.JSONMarshal(response); marshalResult.OK { data, _ := json.Marshal(response)
responseData, _ := marshalResult.Value.([]byte) conn.WriteMessage(websocket.TextMessage, data)
conn.WriteMessage(websocket.TextMessage, responseData)
}
} }
// listSubscriptions sends a list of active subscriptions to a client. // listSubscriptions sends a list of active subscriptions to a client.
@ -277,10 +282,8 @@ func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
"type": "subscriptions", "type": "subscriptions",
"subscriptions": subs, "subscriptions": subs,
} }
if marshalResult := core.JSONMarshal(response); marshalResult.OK { data, _ := json.Marshal(response)
responseData, _ := marshalResult.Value.([]byte) conn.WriteMessage(websocket.TextMessage, data)
conn.WriteMessage(websocket.TextMessage, responseData)
}
} }
// removeClient removes a client and its subscriptions. // removeClient removes a client and its subscriptions.
@ -317,6 +320,23 @@ func (em *WSEventManager) ConnectedClients() int {
return len(em.clients) return len(em.clients)
} }
// Info returns a snapshot of the WebSocket event server state.
func (em *WSEventManager) Info() EventServerInfo {
em.mu.RLock()
defer em.mu.RUnlock()
info := EventServerInfo{
ConnectedClients: len(em.clients),
BufferedEvents: len(em.eventBuffer),
}
for _, state := range em.clients {
state.mu.RLock()
info.Subscriptions += len(state.subscriptions)
state.mu.RUnlock()
}
return info
}
// Close shuts down the event manager. // Close shuts down the event manager.
func (em *WSEventManager) Close() { func (em *WSEventManager) Close() {
em.mu.Lock() em.mu.Lock()

View file

@ -3,15 +3,16 @@ package display
import "github.com/wailsapp/wails/v3/pkg/application" import "github.com/wailsapp/wails/v3/pkg/application"
// App abstracts the Wails application for the orchestrator. // App abstracts the Wails application for the display orchestrator.
// After Spec D cleanup, only Quit() and Logger() remain — // The service uses Logger() for diagnostics and Quit() for shutdown.
// all other Wails Manager APIs are accessed via IPC. // Use: var app display.App
type App interface { type App interface {
Logger() Logger Logger() Logger
Quit() Quit()
} }
// Logger wraps Wails logging. // Logger wraps Wails logging.
// Use: var logger display.Logger
type Logger interface { type Logger interface {
Info(message string, args ...any) Info(message string, args ...any)
} }

View file

@ -4,9 +4,17 @@ package display
// ActionIDECommand is broadcast when a menu handler triggers an IDE command // ActionIDECommand is broadcast when a menu handler triggers an IDE command
// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls. // (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls.
// Listeners (e.g. editor windows) handle this via HandleIPCEvents. // Listeners (e.g. editor windows) handle this via HandleIPCEvents.
// Use: _ = c.ACTION(display.ActionIDECommand{Command: "save"})
type ActionIDECommand struct { type ActionIDECommand struct {
Command string `json:"command"` // "save", "run", "build" Command string `json:"command"` // "save", "run", "build"
} }
// EventIDECommand is the WS event type for IDE commands. // EventIDECommand is the WS event type for IDE commands.
// Use: eventType := display.EventIDECommand
const EventIDECommand EventType = "ide.command" const EventIDECommand EventType = "ide.command"
// Theme is the display-level theme summary exposed by the service API.
// Use: theme := display.Theme{IsDark: true}
type Theme struct {
IsDark bool `json:"isDark"`
}

View file

@ -23,33 +23,7 @@ type TaskSetBadge struct{ Label string }
// TaskRemoveBadge removes the dock/taskbar badge. Result: nil // TaskRemoveBadge removes the dock/taskbar badge. Result: nil
type TaskRemoveBadge struct{} type TaskRemoveBadge struct{}
// TaskSetProgressBar updates the progress indicator on the dock/taskbar icon.
// Progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator.
// c.PERFORM(dock.TaskSetProgressBar{Progress: 0.75}) // 75% complete
// c.PERFORM(dock.TaskSetProgressBar{Progress: -1.0}) // hide indicator
// Result: nil
type TaskSetProgressBar struct{ Progress float64 }
// TaskBounce requests user attention by animating the dock icon.
// Result: int (requestID for use with TaskStopBounce)
// c.PERFORM(dock.TaskBounce{BounceType: dock.BounceInformational})
type TaskBounce struct{ BounceType BounceType }
// TaskStopBounce cancels a pending attention request.
// c.PERFORM(dock.TaskStopBounce{RequestID: id})
// Result: nil
type TaskStopBounce struct{ RequestID int }
// --- Actions (broadcasts) --- // --- Actions (broadcasts) ---
// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. // ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon.
type ActionVisibilityChanged struct{ Visible bool } type ActionVisibilityChanged struct{ Visible bool }
// ActionProgressChanged is broadcast after a successful TaskSetProgressBar.
type ActionProgressChanged struct{ Progress float64 }
// ActionBounceStarted is broadcast after a successful TaskBounce.
type ActionBounceStarted struct {
RequestID int `json:"requestId"`
BounceType BounceType `json:"bounceType"`
}

View file

@ -1,21 +1,9 @@
// pkg/dock/platform.go // pkg/dock/platform.go
package dock package dock
// BounceType controls how the dock icon attracts attention.
// bounce := dock.BounceInformational — single bounce
// bounce := dock.BounceCritical — continuous bounce until focused
type BounceType int
const (
// BounceInformational performs a single bounce to indicate a background event.
BounceInformational BounceType = iota
// BounceCritical bounces continuously until the application becomes active.
BounceCritical
)
// Platform abstracts the dock/taskbar backend (Wails v3). // Platform abstracts the dock/taskbar backend (Wails v3).
// macOS: dock icon show/hide, badge, progress bar, bounce. // macOS: dock icon show/hide + badge.
// Windows: taskbar badge + progress bar (show/hide and bounce not supported). // Windows: taskbar badge only (show/hide not supported).
// Linux: not supported — adapter returns nil for all operations. // Linux: not supported — adapter returns nil for all operations.
type Platform interface { type Platform interface {
ShowIcon() error ShowIcon() error
@ -23,12 +11,4 @@ type Platform interface {
SetBadge(label string) error SetBadge(label string) error
RemoveBadge() error RemoveBadge() error
IsVisible() bool IsVisible() bool
// SetProgressBar sets a progress indicator on the dock/taskbar icon.
// progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator.
SetProgressBar(progress float64) error
// Bounce requests user attention by animating the dock icon.
// Returns a request ID that can be passed to StopBounce.
Bounce(bounceType BounceType) (int, error)
// StopBounce cancels a pending attention request by its ID.
StopBounce(requestID int) error
} }

View file

@ -1,14 +1,15 @@
// pkg/dock/register.go
package dock package dock
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the dock service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(dock.Register(wailsDock)) // The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }

View file

@ -1,82 +1,72 @@
// pkg/dock/service.go
package dock package dock
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the dock service.
type Options struct{} type Options struct{}
// Service is a core.Service managing dock/taskbar operations via IPC.
// It embeds ServiceRuntime for Core access and delegates to Platform.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("dock.showIcon", func(_ context.Context, _ core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
if err := s.platform.ShowIcon(); err != nil { return nil
return core.Result{Value: err, OK: false}
}
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: true})
return core.Result{OK: true}
})
s.Core().Action("dock.hideIcon", func(_ context.Context, _ core.Options) core.Result {
if err := s.platform.HideIcon(); err != nil {
return core.Result{Value: err, OK: false}
}
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: false})
return core.Result{OK: true}
})
s.Core().Action("dock.setBadge", func(_ context.Context, opts core.Options) core.Result {
if err := s.platform.SetBadge(opts.String("label")); err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{OK: true}
})
s.Core().Action("dock.removeBadge", func(_ context.Context, _ core.Options) core.Result {
if err := s.platform.RemoveBadge(); err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{OK: true}
})
s.Core().Action("dock.setProgressBar", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskSetProgressBar)
if err := s.platform.SetProgressBar(t.Progress); err != nil {
return core.Result{Value: err, OK: false}
}
_ = s.Core().ACTION(ActionProgressChanged{Progress: t.Progress})
return core.Result{OK: true}
})
s.Core().Action("dock.bounce", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskBounce)
requestID, err := s.platform.Bounce(t.BounceType)
if err != nil {
return core.Result{Value: err, OK: false}
}
_ = s.Core().ACTION(ActionBounceStarted{RequestID: requestID, BounceType: t.BounceType})
return core.Result{Value: requestID, OK: true}
})
s.Core().Action("dock.stopBounce", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskStopBounce)
if err := s.platform.StopBounce(t.RequestID); err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { // --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryVisible: case QueryVisible:
return core.Result{Value: s.platform.IsVisible(), OK: true} return s.platform.IsVisible(), true, nil
default: default:
return core.Result{} return nil, false, nil
}
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskShowIcon:
if err := s.platform.ShowIcon(); err != nil {
return nil, true, err
}
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: true})
return nil, true, nil
case TaskHideIcon:
if err := s.platform.HideIcon(); err != nil {
return nil, true, err
}
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: false})
return nil, true, nil
case TaskSetBadge:
if err := s.platform.SetBadge(t.Label); err != nil {
return nil, true, err
}
return nil, true, nil
case TaskRemoveBadge:
if err := s.platform.RemoveBadge(); err != nil {
return nil, true, err
}
return nil, true, nil
default:
return nil, false, nil
} }
} }

View file

@ -5,7 +5,7 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -16,18 +16,10 @@ type mockPlatform struct {
visible bool visible bool
badge string badge string
hasBadge bool hasBadge bool
progress float64
bounceID int
bounceType BounceType
bounceCalled bool
stopBounceCalled bool
showErr error showErr error
hideErr error hideErr error
badgeErr error badgeErr error
removeErr error removeErr error
progressErr error
bounceErr error
stopBounceErr error
} }
func (m *mockPlatform) ShowIcon() error { func (m *mockPlatform) ShowIcon() error {
@ -66,58 +58,21 @@ func (m *mockPlatform) RemoveBadge() error {
func (m *mockPlatform) IsVisible() bool { return m.visible } func (m *mockPlatform) IsVisible() bool { return m.visible }
func (m *mockPlatform) SetProgressBar(progress float64) error {
if m.progressErr != nil {
return m.progressErr
}
m.progress = progress
return nil
}
func (m *mockPlatform) Bounce(bounceType BounceType) (int, error) {
if m.bounceErr != nil {
return 0, m.bounceErr
}
m.bounceCalled = true
m.bounceType = bounceType
m.bounceID++
return m.bounceID, nil
}
func (m *mockPlatform) StopBounce(requestID int) error {
if m.stopBounceErr != nil {
return m.stopBounceErr
}
m.stopBounceCalled = true
return nil
}
// --- Test helpers --- // --- Test helpers ---
func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) { func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
t.Helper() t.Helper()
mock := &mockPlatform{visible: true} mock := &mockPlatform{visible: true}
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "dock") svc := core.MustServiceFor[*Service](c, "dock")
return svc, c, mock return svc, c, mock
} }
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func setBadge(c *core.Core, label string) core.Result {
return c.Action("dock.setBadge").Run(context.Background(), core.NewOptions(
core.Option{Key: "label", Value: label},
))
}
// --- Tests --- // --- Tests ---
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
@ -127,16 +82,18 @@ func TestRegister_Good(t *testing.T) {
func TestQueryVisible_Good(t *testing.T) { func TestQueryVisible_Good(t *testing.T) {
_, c, _ := newTestDockService(t) _, c, _ := newTestDockService(t)
r := c.QUERY(QueryVisible{}) result, handled, err := c.QUERY(QueryVisible{})
require.True(t, r.OK) require.NoError(t, err)
assert.Equal(t, true, r.Value) assert.True(t, handled)
assert.Equal(t, true, result)
} }
func TestQueryVisible_Bad(t *testing.T) { func TestQueryVisible_Bad(t *testing.T) {
// No dock service registered — QUERY returns handled=false // No dock service registered — QUERY returns handled=false
c := core.New(core.WithServiceLock()) c, err := core.New(core.WithServiceLock())
r := c.QUERY(QueryVisible{}) require.NoError(t, err)
assert.False(t, r.OK) _, handled, _ := c.QUERY(QueryVisible{})
assert.False(t, handled)
} }
func TestTaskShowIcon_Good(t *testing.T) { func TestTaskShowIcon_Good(t *testing.T) {
@ -144,15 +101,16 @@ func TestTaskShowIcon_Good(t *testing.T) {
mock.visible = false // Start hidden mock.visible = false // Start hidden
var received *ActionVisibilityChanged var received *ActionVisibilityChanged
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionVisibilityChanged); ok { if a, ok := msg.(ActionVisibilityChanged); ok {
received = &a received = &a
} }
return core.Result{OK: true} return nil
}) })
r := taskRun(c, "dock.showIcon", TaskShowIcon{}) _, handled, err := c.PERFORM(TaskShowIcon{})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.visible) assert.True(t, mock.visible)
require.NotNil(t, received) require.NotNil(t, received)
assert.True(t, received.Visible) assert.True(t, received.Visible)
@ -163,15 +121,16 @@ func TestTaskHideIcon_Good(t *testing.T) {
mock.visible = true // Start visible mock.visible = true // Start visible
var received *ActionVisibilityChanged var received *ActionVisibilityChanged
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionVisibilityChanged); ok { if a, ok := msg.(ActionVisibilityChanged); ok {
received = &a received = &a
} }
return core.Result{OK: true} return nil
}) })
r := taskRun(c, "dock.hideIcon", TaskHideIcon{}) _, handled, err := c.PERFORM(TaskHideIcon{})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.False(t, mock.visible) assert.False(t, mock.visible)
require.NotNil(t, received) require.NotNil(t, received)
assert.False(t, received.Visible) assert.False(t, received.Visible)
@ -179,16 +138,18 @@ func TestTaskHideIcon_Good(t *testing.T) {
func TestTaskSetBadge_Good(t *testing.T) { func TestTaskSetBadge_Good(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
r := setBadge(c, "3") _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "3", mock.badge) assert.Equal(t, "3", mock.badge)
assert.True(t, mock.hasBadge) assert.True(t, mock.hasBadge)
} }
func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) { func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
r := setBadge(c, "") _, handled, err := c.PERFORM(TaskSetBadge{Label: ""})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "", mock.badge) assert.Equal(t, "", mock.badge)
assert.True(t, mock.hasBadge) // Empty string = default system badge indicator assert.True(t, mock.hasBadge) // Empty string = default system badge indicator
} }
@ -196,10 +157,11 @@ func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) {
func TestTaskRemoveBadge_Good(t *testing.T) { func TestTaskRemoveBadge_Good(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
// Set a badge first // Set a badge first
_ = setBadge(c, "5") _, _, _ = c.PERFORM(TaskSetBadge{Label: "5"})
r := taskRun(c, "dock.removeBadge", TaskRemoveBadge{}) _, handled, err := c.PERFORM(TaskRemoveBadge{})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "", mock.badge) assert.Equal(t, "", mock.badge)
assert.False(t, mock.hasBadge) assert.False(t, mock.hasBadge)
} }
@ -208,166 +170,25 @@ func TestTaskShowIcon_Bad(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
mock.showErr = assert.AnError mock.showErr = assert.AnError
r := taskRun(c, "dock.showIcon", TaskShowIcon{}) _, handled, err := c.PERFORM(TaskShowIcon{})
assert.False(t, r.OK) assert.True(t, handled)
assert.Error(t, err)
} }
func TestTaskHideIcon_Bad(t *testing.T) { func TestTaskHideIcon_Bad(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
mock.hideErr = assert.AnError mock.hideErr = assert.AnError
r := taskRun(c, "dock.hideIcon", TaskHideIcon{}) _, handled, err := c.PERFORM(TaskHideIcon{})
assert.False(t, r.OK) assert.True(t, handled)
assert.Error(t, err)
} }
func TestTaskSetBadge_Bad(t *testing.T) { func TestTaskSetBadge_Bad(t *testing.T) {
_, c, mock := newTestDockService(t) _, c, mock := newTestDockService(t)
mock.badgeErr = assert.AnError mock.badgeErr = assert.AnError
r := setBadge(c, "3") _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"})
assert.False(t, r.OK) assert.True(t, handled)
} assert.Error(t, err)
// --- TaskSetProgressBar ---
func TestTaskSetProgressBar_Good(t *testing.T) {
_, c, mock := newTestDockService(t)
var received *ActionProgressChanged
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if a, ok := msg.(ActionProgressChanged); ok {
received = &a
}
return core.Result{OK: true}
})
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: 0.5})
require.True(t, r.OK)
assert.Equal(t, 0.5, mock.progress)
require.NotNil(t, received)
assert.Equal(t, 0.5, received.Progress)
}
func TestTaskSetProgressBar_Hide_Good(t *testing.T) {
// Progress -1.0 hides the indicator
_, c, mock := newTestDockService(t)
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: -1.0})
require.True(t, r.OK)
assert.Equal(t, -1.0, mock.progress)
}
func TestTaskSetProgressBar_Bad(t *testing.T) {
_, c, mock := newTestDockService(t)
mock.progressErr = assert.AnError
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: 0.5})
assert.False(t, r.OK)
}
func TestTaskSetProgressBar_Ugly(t *testing.T) {
// No dock service — action is not registered
c := core.New(core.WithServiceLock())
r := c.Action("dock.setProgressBar").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- TaskBounce ---
func TestTaskBounce_Good(t *testing.T) {
_, c, mock := newTestDockService(t)
var received *ActionBounceStarted
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if a, ok := msg.(ActionBounceStarted); ok {
received = &a
}
return core.Result{OK: true}
})
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
require.True(t, r.OK)
assert.True(t, mock.bounceCalled)
assert.Equal(t, BounceInformational, mock.bounceType)
requestID, ok := r.Value.(int)
require.True(t, ok)
assert.Equal(t, 1, requestID)
require.NotNil(t, received)
assert.Equal(t, BounceInformational, received.BounceType)
}
func TestTaskBounce_Critical_Good(t *testing.T) {
_, c, mock := newTestDockService(t)
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceCritical})
require.True(t, r.OK)
assert.Equal(t, BounceCritical, mock.bounceType)
requestID, ok := r.Value.(int)
require.True(t, ok)
assert.Equal(t, 1, requestID)
}
func TestTaskBounce_Bad(t *testing.T) {
_, c, mock := newTestDockService(t)
mock.bounceErr = assert.AnError
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
assert.False(t, r.OK)
}
func TestTaskBounce_Ugly(t *testing.T) {
// No dock service — action is not registered
c := core.New(core.WithServiceLock())
r := c.Action("dock.bounce").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- TaskStopBounce ---
func TestTaskStopBounce_Good(t *testing.T) {
_, c, mock := newTestDockService(t)
// Start a bounce to get a requestID
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
require.True(t, r.OK)
requestID := r.Value.(int)
r2 := taskRun(c, "dock.stopBounce", TaskStopBounce{RequestID: requestID})
require.True(t, r2.OK)
assert.True(t, mock.stopBounceCalled)
}
func TestTaskStopBounce_Bad(t *testing.T) {
_, c, mock := newTestDockService(t)
mock.stopBounceErr = assert.AnError
r := taskRun(c, "dock.stopBounce", TaskStopBounce{RequestID: 1})
assert.False(t, r.OK)
}
func TestTaskStopBounce_Ugly(t *testing.T) {
// No dock service — action is not registered
c := core.New(core.WithServiceLock())
r := c.Action("dock.stopBounce").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestTaskRemoveBadge_Bad(t *testing.T) {
_, c, mock := newTestDockService(t)
mock.removeErr = assert.AnError
r := taskRun(c, "dock.removeBadge", TaskRemoveBadge{})
assert.False(t, r.OK)
}
func TestQueryVisible_Ugly(t *testing.T) {
// Dock icon initially hidden
mock := &mockPlatform{visible: false}
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
r := c.QUERY(QueryVisible{})
require.True(t, r.OK)
assert.Equal(t, false, r.Value)
} }

View file

@ -16,8 +16,12 @@ type TaskOpenFileManager struct {
Select bool `json:"select"` Select bool `json:"select"`
} }
// QueryFocusFollowsMouse returns whether the platform uses focus-follows-mouse. Result: bool // TaskSetTheme applies an application theme override when supported.
type QueryFocusFollowsMouse struct{} // Theme values: "dark", "light", or "system".
type TaskSetTheme struct {
Theme string `json:"theme,omitempty"`
IsDark bool `json:"isDark,omitempty"`
}
// ActionThemeChanged is broadcast when the system theme changes. // ActionThemeChanged is broadcast when the system theme changes.
type ActionThemeChanged struct { type ActionThemeChanged struct {

View file

@ -7,7 +7,6 @@ type Platform interface {
Info() EnvironmentInfo Info() EnvironmentInfo
AccentColour() string AccentColour() string
OpenFileManager(path string, selectFile bool) error OpenFileManager(path string, selectFile bool) error
HasFocusFollowsMouse() bool
OnThemeChange(handler func(isDark bool)) func() // returns cancel func OnThemeChange(handler func(isDark bool)) func() // returns cancel func
} }

View file

@ -3,73 +3,125 @@ package environment
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the environment service.
type Options struct{} type Options struct{}
// Service is a core.Service providing environment queries and theme change events via IPC.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown cancelTheme func() // cancel function for theme change listener
overrideDark *bool
} }
// Register(p) binds the environment service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(environment.Register(wailsEnvironment)) func Register(p Platform) func(*core.Core) (any, error) {
func Register(p Platform) func(*core.Core) core.Result { return func(c *core.Core) (any, error) {
return func(c *core.Core) core.Result { return &Service{
return core.Result{Value: &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers and the theme change listener.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("environment.openFileManager", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskOpenFileManager)
if err := s.platform.OpenFileManager(t.Path, t.Select); err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{OK: true}
})
// Register theme change callback — broadcasts ActionThemeChanged via IPC // Register theme change callback — broadcasts ActionThemeChanged via IPC
s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) {
_ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark})
}) })
return core.Result{OK: true} return nil
} }
func (s *Service) OnShutdown(_ context.Context) core.Result { // OnShutdown cancels the theme change listener.
func (s *Service) OnShutdown(ctx context.Context) error {
if s.cancelTheme != nil { if s.cancelTheme != nil {
s.cancelTheme() s.cancelTheme()
} }
return core.Result{OK: true} return nil
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered by core.WithService.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryTheme: case QueryTheme:
isDark := s.platform.IsDarkMode() isDark := s.currentTheme()
theme := "light" theme := "light"
if isDark { if isDark {
theme = "dark" theme = "dark"
} }
return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: theme}, OK: true} return ThemeInfo{IsDark: isDark, Theme: theme}, true, nil
case QueryInfo: case QueryInfo:
return core.Result{Value: s.platform.Info(), OK: true} return s.platform.Info(), true, nil
case QueryAccentColour: case QueryAccentColour:
return core.Result{Value: s.platform.AccentColour(), OK: true} return s.platform.AccentColour(), true, nil
case QueryFocusFollowsMouse:
return core.Result{Value: s.platform.HasFocusFollowsMouse(), OK: true}
default: default:
return core.Result{} return nil, false, nil
} }
} }
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFileManager:
return nil, true, s.platform.OpenFileManager(t.Path, t.Select)
case TaskSetTheme:
if err := s.taskSetTheme(t); err != nil {
return nil, true, err
}
return nil, true, nil
default:
return nil, false, nil
}
}
func (s *Service) taskSetTheme(task TaskSetTheme) error {
shouldApplyTheme := false
switch task.Theme {
case "dark":
isDark := true
s.overrideDark = &isDark
shouldApplyTheme = true
case "light":
isDark := false
s.overrideDark = &isDark
shouldApplyTheme = true
case "system":
s.overrideDark = nil
case "":
isDark := task.IsDark
s.overrideDark = &isDark
shouldApplyTheme = true
default:
return fmt.Errorf("invalid theme mode: %s", task.Theme)
}
if shouldApplyTheme {
if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok {
if err := setter.SetTheme(s.currentTheme()); err != nil {
return err
}
}
}
_ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentTheme()})
return nil
}
func (s *Service) currentTheme() bool {
if s.overrideDark != nil {
return *s.overrideDark
}
return s.platform.IsDarkMode()
}

View file

@ -6,8 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
coreerr "dappco.re/go/core/log"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -17,15 +16,15 @@ type mockPlatform struct {
info EnvironmentInfo info EnvironmentInfo
accentColour string accentColour string
openFMErr error openFMErr error
focusFollowsMouse bool
themeHandler func(isDark bool) themeHandler func(isDark bool)
setThemeSeen bool
setThemeDark bool
mu sync.Mutex mu sync.Mutex
} }
func (m *mockPlatform) IsDarkMode() bool { return m.isDark } func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info } func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour } func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) HasFocusFollowsMouse() bool { return m.focusFollowsMouse }
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
return m.openFMErr return m.openFMErr
} }
@ -39,6 +38,12 @@ func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() {
m.mu.Unlock() m.mu.Unlock()
} }
} }
func (m *mockPlatform) SetTheme(isDark bool) error {
m.setThemeSeen = true
m.setThemeDark = isDark
m.isDark = isDark
return nil
}
// simulateThemeChange triggers the stored handler (test helper). // simulateThemeChange triggers the stored handler (test helper).
func (m *mockPlatform) simulateThemeChange(isDark bool) { func (m *mockPlatform) simulateThemeChange(isDark bool) {
@ -60,11 +65,12 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
Platform: PlatformInfo{Name: "macOS", Version: "14.0"}, Platform: PlatformInfo{Name: "macOS", Version: "14.0"},
}, },
} }
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return mock, c return mock, c
} }
@ -76,35 +82,37 @@ func TestRegister_Good(t *testing.T) {
func TestQueryTheme_Good(t *testing.T) { func TestQueryTheme_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryTheme{}) result, handled, err := c.QUERY(QueryTheme{})
require.True(t, r.OK) require.NoError(t, err)
theme := r.Value.(ThemeInfo) assert.True(t, handled)
theme := result.(ThemeInfo)
assert.True(t, theme.IsDark) assert.True(t, theme.IsDark)
assert.Equal(t, "dark", theme.Theme) assert.Equal(t, "dark", theme.Theme)
} }
func TestQueryInfo_Good(t *testing.T) { func TestQueryInfo_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryInfo{}) result, handled, err := c.QUERY(QueryInfo{})
require.True(t, r.OK) require.NoError(t, err)
info := r.Value.(EnvironmentInfo) assert.True(t, handled)
info := result.(EnvironmentInfo)
assert.Equal(t, "darwin", info.OS) assert.Equal(t, "darwin", info.OS)
assert.Equal(t, "arm64", info.Arch) assert.Equal(t, "arm64", info.Arch)
} }
func TestQueryAccentColour_Good(t *testing.T) { func TestQueryAccentColour_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryAccentColour{}) result, handled, err := c.QUERY(QueryAccentColour{})
require.True(t, r.OK) require.NoError(t, err)
assert.Equal(t, "rgb(0,122,255)", r.Value) assert.True(t, handled)
assert.Equal(t, "rgb(0,122,255)", result)
} }
func TestTaskOpenFileManager_Good(t *testing.T) { func TestTaskOpenFileManager_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions( _, handled, err := c.PERFORM(TaskOpenFileManager{Path: "/tmp", Select: true})
core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/tmp", Select: true}}, require.NoError(t, err)
)) assert.True(t, handled)
require.True(t, r.OK)
} }
func TestThemeChange_ActionBroadcast_Good(t *testing.T) { func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
@ -113,13 +121,13 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
// Register a listener that captures the action // Register a listener that captures the action
var received *ActionThemeChanged var received *ActionThemeChanged
var mu sync.Mutex var mu sync.Mutex
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionThemeChanged); ok { if a, ok := msg.(ActionThemeChanged); ok {
mu.Lock() mu.Lock()
received = &a received = &a
mu.Unlock() mu.Unlock()
} }
return core.Result{OK: true} return nil
}) })
// Simulate theme change // Simulate theme change
@ -132,81 +140,32 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
assert.False(t, r.IsDark) assert.False(t, r.IsDark)
} }
// --- GetAccentColor --- func TestTaskSetTheme_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.setThemeSeen)
func TestQueryAccentColour_Bad_Empty(t *testing.T) { result, handled, err := c.QUERY(QueryTheme{})
// accent colour := "" — still returns handled with empty string require.NoError(t, err)
mock := &mockPlatform{ assert.True(t, handled)
isDark: false, theme := result.(ThemeInfo)
accentColour: "", assert.False(t, theme.IsDark)
info: EnvironmentInfo{OS: "linux", Arch: "amd64"}, assert.Equal(t, "light", theme.Theme)
}
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
r := c.QUERY(QueryAccentColour{})
require.True(t, r.OK)
assert.Equal(t, "", r.Value)
} }
func TestQueryAccentColour_Ugly_NoService(t *testing.T) { func TestTaskSetTheme_Compatibility_Good(t *testing.T) {
// No environment service — query is unhandled mock, c := newTestService(t)
c := core.New(core.WithServiceLock()) _, handled, err := c.PERFORM(TaskSetTheme{IsDark: true})
r := c.QUERY(QueryAccentColour{}) require.NoError(t, err)
assert.False(t, r.OK) assert.True(t, handled)
} assert.True(t, mock.setThemeSeen)
// --- OpenFileManager --- result, handled, err := c.QUERY(QueryTheme{})
require.NoError(t, err)
func TestTaskOpenFileManager_Bad_Error(t *testing.T) { assert.True(t, handled)
// platform returns an error on open theme := result.(ThemeInfo)
openErr := coreerr.E("test", "file manager unavailable", nil) assert.True(t, theme.IsDark)
mock := &mockPlatform{openFMErr: openErr} assert.Equal(t, "dark", theme.Theme)
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/missing", Select: false}},
))
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, openErr)
}
func TestTaskOpenFileManager_Ugly_NoService(t *testing.T) {
// No environment service — action is not registered
c := core.New(core.WithServiceLock())
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- HasFocusFollowsMouse ---
func TestQueryFocusFollowsMouse_Good_True(t *testing.T) {
// platform reports focus-follows-mouse enabled (Linux/X11 sloppy focus)
mock := &mockPlatform{focusFollowsMouse: true}
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
r := c.QUERY(QueryFocusFollowsMouse{})
require.True(t, r.OK)
assert.Equal(t, true, r.Value)
}
func TestQueryFocusFollowsMouse_Bad_False(t *testing.T) {
// platform reports focus-follows-mouse disabled (Windows/macOS default)
mock := &mockPlatform{focusFollowsMouse: false}
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
r := c.QUERY(QueryFocusFollowsMouse{})
require.True(t, r.OK)
assert.Equal(t, false, r.Value)
}
func TestQueryFocusFollowsMouse_Ugly_NoService(t *testing.T) {
// No environment service — query is unhandled
c := core.New(core.WithServiceLock())
r := c.QUERY(QueryFocusFollowsMouse{})
assert.False(t, r.OK)
} }

View file

@ -1,51 +0,0 @@
// pkg/events/messages.go
package events
// All IPC message types for the events service.
// Tasks mutate event state; Queries read it; Actions broadcast fired events.
// TaskEmit fires a named custom event with optional data to all registered listeners.
// Result: bool (true if the event was cancelled by a listener)
//
// c.PERFORM(events.TaskEmit{Name: "user:login", Data: userPayload})
type TaskEmit struct {
Name string `json:"name"`
Data any `json:"data,omitempty"`
}
// TaskOn registers a persistent listener for the named custom event via IPC.
// The listener fires an ActionEventFired action for each matching event.
// Result: nil (side-effect only; use Off/Reset to remove)
//
// c.PERFORM(events.TaskOn{Name: "theme:changed"})
type TaskOn struct {
Name string `json:"name"`
}
// TaskOff removes all listeners for the named custom event.
// Result: nil
//
// c.PERFORM(events.TaskOff{Name: "theme:changed"})
type TaskOff struct {
Name string `json:"name"`
}
// QueryListeners returns a snapshot of all registered listener counts per event name.
// Result: []ListenerInfo
//
// result, _, _ := c.QUERY(events.QueryListeners{})
// for _, info := range result.([]events.ListenerInfo) { ... }
type QueryListeners struct{}
// ActionEventFired is broadcast when a registered IPC listener receives an event.
// Consumers subscribe via c.RegisterAction to react to platform events.
//
// c.RegisterAction(func(_ *core.Core, msg core.Message) error {
// if fired, ok := msg.(events.ActionEventFired); ok {
// handleEvent(fired.Event)
// }
// return nil
// })
type ActionEventFired struct {
Event CustomEvent `json:"event"`
}

View file

@ -1,34 +0,0 @@
// pkg/events/platform.go
package events
// Platform abstracts the Wails EventManager for custom events.
//
// platform.Emit("user:login", userPayload)
// cancel := platform.On("theme:changed", func(e *CustomEvent) { applyTheme(e) })
// defer cancel()
type Platform interface {
Emit(name string, data ...any) bool
On(name string, callback func(*CustomEvent)) func()
Off(name string)
OnMultiple(name string, callback func(*CustomEvent), counter int)
Reset()
}
// CustomEvent is a named event carrying arbitrary data, mirroring the Wails type.
//
// platform.On("file:saved", func(e *CustomEvent) {
// path := e.Data.(string)
// })
type CustomEvent struct {
Name string `json:"name"`
Data any `json:"data"`
Sender string `json:"sender,omitempty"`
}
// ListenerInfo describes a registered listener for QueryListeners results.
//
// info := ListenerInfo{EventName: "user:login", Count: 3}
type ListenerInfo struct {
EventName string `json:"eventName"`
Count int `json:"count"`
}

View file

@ -1,18 +0,0 @@
// pkg/events/register.go
package events
import core "dappco.re/go/core"
// Register binds the events service to a Core instance.
//
// core.WithService(events.Register(wailsEventPlatform))
func Register(p Platform) func(*core.Core) core.Result {
return func(c *core.Core) core.Result {
return core.Result{Value: &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
listeners: make(map[string][]func()),
counts: make(map[string]int),
}, OK: true}
}
}

View file

@ -1,103 +0,0 @@
// pkg/events/service.go
package events
import (
"context"
"sync"
coreerr "dappco.re/go/core/log"
core "dappco.re/go/core"
)
// Options holds configuration for the events service (currently empty).
type Options struct{}
// Service bridges Wails custom events into Core IPC.
// Emit/On/Off/OnMultiple/Reset are available as Tasks; QueryListeners reads state.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
mu sync.Mutex
listeners map[string][]func() // IPC-registered cancels per event name
counts map[string]int // listener counts per event name
}
// OnStartup registers query and action handlers.
func (s *Service) OnStartup(_ context.Context) core.Result {
s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("events.emit", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskEmit)
cancelled := s.platform.Emit(t.Name, t.Data)
return core.Result{Value: cancelled, OK: true}
})
s.Core().Action("events.on", func(ctx context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskOn)
if t.Name == "" {
return core.Result{Value: coreerr.E("events.on", "event name must not be empty", nil), OK: false}
}
cancel := s.platform.On(t.Name, func(event *CustomEvent) {
_ = s.Core().ACTION(ActionEventFired{Event: *event})
})
s.mu.Lock()
s.listeners[t.Name] = append(s.listeners[t.Name], cancel)
s.counts[t.Name]++
s.mu.Unlock()
return core.Result{OK: true}
})
s.Core().Action("events.off", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskOff)
s.platform.Off(t.Name)
s.mu.Lock()
for _, cancel := range s.listeners[t.Name] {
cancel()
}
delete(s.listeners, t.Name)
delete(s.counts, t.Name)
s.mu.Unlock()
return core.Result{OK: true}
})
return core.Result{OK: true}
}
// OnShutdown cancels all IPC-registered platform listeners.
func (s *Service) OnShutdown(_ context.Context) core.Result {
s.mu.Lock()
defer s.mu.Unlock()
for _, cancels := range s.listeners {
for _, cancel := range cancels {
cancel()
}
}
s.listeners = make(map[string][]func())
s.counts = make(map[string]int)
return core.Result{OK: true}
}
// HandleIPCEvents satisfies the core.Service interface (no-op for now).
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
return core.Result{OK: true}
}
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
switch q.(type) {
case QueryListeners:
return core.Result{Value: s.listenerSnapshot(), OK: true}
default:
return core.Result{}
}
}
// listenerSnapshot returns a sorted slice of ListenerInfo for all known event names.
//
// snapshot := s.listenerSnapshot()
// for _, info := range snapshot { log(info.EventName, info.Count) }
func (s *Service) listenerSnapshot() []ListenerInfo {
s.mu.Lock()
defer s.mu.Unlock()
snapshot := make([]ListenerInfo, 0, len(s.counts))
for name, count := range s.counts {
snapshot = append(snapshot, ListenerInfo{EventName: name, Count: count})
}
return snapshot
}

View file

@ -1,358 +0,0 @@
// pkg/events/service_test.go
package events
import (
"context"
"sync"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Mock Platform ---
type mockPlatform struct {
mu sync.Mutex
listeners map[string][]*mockListener
emitted []CustomEvent
resetCalled bool
}
type mockListener struct {
callback func(*CustomEvent)
counter int // -1 = persistent
}
func newMockPlatform() *mockPlatform {
return &mockPlatform{
listeners: make(map[string][]*mockListener),
}
}
func (m *mockPlatform) Emit(name string, data ...any) bool {
event := &CustomEvent{Name: name}
if len(data) == 1 {
event.Data = data[0]
} else if len(data) > 1 {
event.Data = data
}
m.mu.Lock()
m.emitted = append(m.emitted, *event)
active := make([]*mockListener, len(m.listeners[name]))
copy(active, m.listeners[name])
m.mu.Unlock()
for _, listener := range active {
listener.callback(event)
}
return false
}
func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() {
listener := &mockListener{callback: callback, counter: -1}
m.mu.Lock()
m.listeners[name] = append(m.listeners[name], listener)
m.mu.Unlock()
return func() {
m.mu.Lock()
defer m.mu.Unlock()
updated := m.listeners[name][:0]
for _, existing := range m.listeners[name] {
if existing != listener {
updated = append(updated, existing)
}
}
m.listeners[name] = updated
}
}
func (m *mockPlatform) Off(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.listeners, name)
}
func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) {
m.mu.Lock()
defer m.mu.Unlock()
m.listeners[name] = append(m.listeners[name], &mockListener{callback: callback, counter: counter})
}
func (m *mockPlatform) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.listeners = make(map[string][]*mockListener)
m.resetCalled = true
}
// simulateEvent fires all registered listeners for the given event name with optional data.
func (m *mockPlatform) simulateEvent(name string, data any) {
event := &CustomEvent{Name: name, Data: data}
m.mu.Lock()
active := make([]*mockListener, len(m.listeners[name]))
copy(active, m.listeners[name])
m.mu.Unlock()
for _, listener := range active {
listener.callback(event)
}
}
// listenerCount returns the total number of registered listeners across all event names.
func (m *mockPlatform) listenerCount() int {
m.mu.Lock()
defer m.mu.Unlock()
total := 0
for _, listeners := range m.listeners {
total += len(listeners)
}
return total
}
// --- Test helpers ---
func newTestService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
t.Helper()
mock := newMockPlatform()
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
svc := core.MustServiceFor[*Service](c, "events")
return svc, c, mock
}
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
// --- Good path tests ---
func TestRegister_Good(t *testing.T) {
svc, _, _ := newTestService(t)
assert.NotNil(t, svc)
}
func TestTaskEmit_Good(t *testing.T) {
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "user:login", Data: "alice"})
require.True(t, r.OK)
assert.Equal(t, false, r.Value) // not cancelled
assert.Len(t, mock.emitted, 1)
assert.Equal(t, "user:login", mock.emitted[0].Name)
assert.Equal(t, "alice", mock.emitted[0].Data)
}
func TestTaskEmit_NoData_Good(t *testing.T) {
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "ping"})
require.True(t, r.OK)
assert.Len(t, mock.emitted, 1)
assert.Nil(t, mock.emitted[0].Data)
}
func TestTaskOn_Good(t *testing.T) {
_, c, mock := newTestService(t)
var received []ActionEventFired
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if fired, ok := msg.(ActionEventFired); ok {
received = append(received, fired)
}
return core.Result{OK: true}
})
r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"})
require.True(t, r.OK)
mock.simulateEvent("theme:changed", "dark")
assert.Len(t, received, 1)
assert.Equal(t, "theme:changed", received[0].Event.Name)
assert.Equal(t, "dark", received[0].Event.Data)
}
func TestTaskOff_Good(t *testing.T) {
_, c, mock := newTestService(t)
// Register via IPC then remove
r := taskRun(c, "events.on", TaskOn{Name: "file:saved"})
require.True(t, r.OK)
assert.Equal(t, 1, mock.listenerCount())
r2 := taskRun(c, "events.off", TaskOff{Name: "file:saved"})
require.True(t, r2.OK)
assert.Equal(t, 0, mock.listenerCount())
}
func TestQueryListeners_Good(t *testing.T) {
_, c, _ := newTestService(t)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "theme:changed"}).OK)
r := c.QUERY(QueryListeners{})
require.True(t, r.OK)
infos := r.Value.([]ListenerInfo)
counts := make(map[string]int)
for _, info := range infos {
counts[info.EventName] = info.Count
}
assert.Equal(t, 2, counts["user:login"])
assert.Equal(t, 1, counts["theme:changed"])
}
func TestQueryListeners_Empty_Good(t *testing.T) {
_, c, _ := newTestService(t)
r := c.QUERY(QueryListeners{})
require.True(t, r.OK)
infos := r.Value.([]ListenerInfo)
assert.Empty(t, infos)
}
func TestOnShutdown_CancelsAll_Good(t *testing.T) {
svc, _, mock := newTestService(t)
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "a:b"}).OK)
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "c:d"}).OK)
assert.Equal(t, 2, mock.listenerCount())
require.True(t, svc.OnShutdown(context.Background()).OK)
assert.Equal(t, 0, mock.listenerCount())
}
func TestActionEventFired_BroadcastOnSimulate_Good(t *testing.T) {
_, c, mock := newTestService(t)
var receivedEvents []CustomEvent
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if fired, ok := msg.(ActionEventFired); ok {
receivedEvents = append(receivedEvents, fired.Event)
}
return core.Result{OK: true}
})
require.True(t, taskRun(c, "events.on", TaskOn{Name: "data:ready"}).OK)
mock.simulateEvent("data:ready", map[string]any{"rows": 42})
require.Len(t, receivedEvents, 1)
assert.Equal(t, "data:ready", receivedEvents[0].Name)
}
// --- Bad path tests ---
func TestTaskOn_EmptyName_Bad(t *testing.T) {
_, c, _ := newTestService(t)
r := taskRun(c, "events.on", TaskOn{Name: ""})
assert.False(t, r.OK)
}
func TestTaskEmit_UnknownEvent_Bad(t *testing.T) {
// Emitting an event with no listeners is valid — returns not-cancelled.
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "no:listeners"})
require.True(t, r.OK)
assert.Equal(t, false, r.Value)
assert.Len(t, mock.emitted, 1) // still recorded as emitted
}
func TestQueryListeners_NoService_Bad(t *testing.T) {
// No events service registered — query is not handled.
c := core.New(core.WithServiceLock())
r := c.QUERY(QueryListeners{})
assert.False(t, r.OK)
}
func TestTaskEmit_NoService_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("events.emit").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- Ugly path tests ---
func TestTaskOff_NeverRegistered_Ugly(t *testing.T) {
// Off on a name that was never registered is a no-op — must not panic.
_, c, _ := newTestService(t)
r := taskRun(c, "events.off", TaskOff{Name: "nonexistent:event"})
assert.True(t, r.OK)
}
func TestTaskOn_MultipleListeners_Ugly(t *testing.T) {
// Multiple IPC listeners for the same event each receive ActionEventFired.
_, c, mock := newTestService(t)
var mu sync.Mutex
var fireCount int
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if _, ok := msg.(ActionEventFired); ok {
mu.Lock()
fireCount++
mu.Unlock()
}
return core.Result{OK: true}
})
taskRun(c, "events.on", TaskOn{Name: "flood"})
taskRun(c, "events.on", TaskOn{Name: "flood"})
taskRun(c, "events.on", TaskOn{Name: "flood"})
mock.simulateEvent("flood", nil)
mu.Lock()
count := fireCount
mu.Unlock()
assert.Equal(t, 3, count)
}
func TestTaskOff_ThenEmit_Ugly(t *testing.T) {
// After Off, simulating the event must not trigger any IPC actions.
_, c, mock := newTestService(t)
var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if _, ok := msg.(ActionEventFired); ok {
received = true
}
return core.Result{OK: true}
})
taskRun(c, "events.on", TaskOn{Name: "transient"})
taskRun(c, "events.off", TaskOff{Name: "transient"})
mock.simulateEvent("transient", "late-data")
assert.False(t, received)
}
func TestQueryListeners_AfterOff_Ugly(t *testing.T) {
// After Off, the event name must not appear in QueryListeners results.
_, c, _ := newTestService(t)
taskRun(c, "events.on", TaskOn{Name: "ephemeral"})
taskRun(c, "events.off", TaskOff{Name: "ephemeral"})
r := c.QUERY(QueryListeners{})
infos := r.Value.([]ListenerInfo)
for _, info := range infos {
assert.NotEqual(t, "ephemeral", info.EventName)
}
}

View file

@ -1,39 +1,40 @@
// pkg/keybinding/messages.go
package keybinding package keybinding
import core "dappco.re/go/core" import "errors"
var ErrorAlreadyRegistered = core.E("keybinding", "accelerator already registered", nil) // ErrAlreadyRegistered is returned when attempting to add a binding
var ErrorNotRegistered = core.E("keybinding", "accelerator not registered", nil) // that already exists. Callers must TaskRemove first to rebind.
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
// BindingInfo describes a registered global key binding. // BindingInfo describes a registered keyboard shortcut.
type BindingInfo struct { type BindingInfo struct {
Accelerator string `json:"accelerator"` Accelerator string `json:"accelerator"`
Description string `json:"description"` Description string `json:"description"`
} }
// QueryList returns all registered key bindings. Result: []BindingInfo // --- Queries ---
// QueryList returns all registered bindings. Result: []BindingInfo
type QueryList struct{} type QueryList struct{}
// TaskAdd registers a global key binding. Error: ErrorAlreadyRegistered if accelerator taken. // --- Tasks ---
// TaskAdd registers a new keyboard shortcut. Result: nil
// Returns ErrAlreadyRegistered if the accelerator is already bound.
type TaskAdd struct { type TaskAdd struct {
Accelerator string `json:"accelerator"` Accelerator string `json:"accelerator"`
Description string `json:"description"` Description string `json:"description"`
} }
// TaskRemove unregisters a global key binding by accelerator. Error: ErrorNotRegistered if not found. // TaskRemove unregisters a keyboard shortcut. Result: nil
type TaskRemove struct { type TaskRemove struct {
Accelerator string `json:"accelerator"` Accelerator string `json:"accelerator"`
} }
// TaskProcess triggers a registered key binding programmatically. // --- Actions ---
// Returns ActionTriggered if the accelerator was handled, ErrorNotRegistered if not found.
//
// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"})
type TaskProcess struct {
Accelerator string `json:"accelerator"`
}
// ActionTriggered is broadcast when a registered key binding fires. // ActionTriggered is broadcast when a registered shortcut is activated.
type ActionTriggered struct { type ActionTriggered struct {
Accelerator string `json:"accelerator"` Accelerator string `json:"accelerator"`
} }

View file

@ -12,12 +12,6 @@ type Platform interface {
// Remove unregisters a previously registered keyboard shortcut. // Remove unregisters a previously registered keyboard shortcut.
Remove(accelerator string) error Remove(accelerator string) error
// Process triggers the registered handler for the given accelerator programmatically.
// Returns true if a handler was found and invoked, false if not registered.
//
// handled := platform.Process("Ctrl+S")
Process(accelerator string) bool
// GetAll returns all currently registered accelerator strings. // GetAll returns all currently registered accelerator strings.
// Used for adapter-level reconciliation only — not read by QueryList. // Used for adapter-level reconciliation only — not read by QueryList.
GetAll() []string GetAll() []string

View file

@ -1,15 +1,16 @@
// pkg/keybinding/register.go
package keybinding package keybinding
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the keybinding service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(keybinding.Register(wailsKeybinding)) // The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
registeredBindings: make(map[string]BindingInfo), bindings: make(map[string]BindingInfo),
}, OK: true} }, nil
} }
} }

View file

@ -3,62 +3,71 @@ package keybinding
import ( import (
"context" "context"
"fmt"
coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/go/pkg/core"
core "dappco.re/go/core"
) )
// Options holds configuration for the keybinding service.
type Options struct{} type Options struct{}
// Service is a core.Service managing keyboard shortcuts via IPC.
// It maintains an in-memory registry of bindings and delegates
// platform-level registration to the Platform interface.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
registeredBindings map[string]BindingInfo bindings map[string]BindingInfo
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("keybinding.add", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskAdd) return nil
return core.Result{Value: nil, OK: true}.New(s.taskAdd(t))
})
s.Core().Action("keybinding.remove", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskRemove)
return core.Result{Value: nil, OK: true}.New(s.taskRemove(t))
})
s.Core().Action("keybinding.process", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskProcess)
return core.Result{Value: nil, OK: true}.New(s.taskProcess(t))
})
return core.Result{OK: true}
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
// --- Query Handlers --- // --- Query Handlers ---
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryList: case QueryList:
return core.Result{Value: s.queryList(), OK: true} return s.queryList(), true, nil
default: default:
return core.Result{} return nil, false, nil
} }
} }
// queryList reads from the in-memory registry (not platform.GetAll()).
func (s *Service) queryList() []BindingInfo { func (s *Service) queryList() []BindingInfo {
result := make([]BindingInfo, 0, len(s.registeredBindings)) result := make([]BindingInfo, 0, len(s.bindings))
for _, info := range s.registeredBindings { for _, info := range s.bindings {
result = append(result, info) result = append(result, info)
} }
return result return result
} }
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskAdd:
return nil, true, s.taskAdd(t)
case TaskRemove:
return nil, true, s.taskRemove(t)
default:
return nil, false, nil
}
}
func (s *Service) taskAdd(t TaskAdd) error { func (s *Service) taskAdd(t TaskAdd) error {
if _, exists := s.registeredBindings[t.Accelerator]; exists { if _, exists := s.bindings[t.Accelerator]; exists {
return ErrorAlreadyRegistered return ErrAlreadyRegistered
} }
// Register on platform with a callback that broadcasts ActionTriggered // Register on platform with a callback that broadcasts ActionTriggered
@ -66,10 +75,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator}) _ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
}) })
if err != nil { if err != nil {
return coreerr.E("keybinding.taskAdd", "platform add failed", err) return fmt.Errorf("keybinding: platform add failed: %w", err)
} }
s.registeredBindings[t.Accelerator] = BindingInfo{ s.bindings[t.Accelerator] = BindingInfo{
Accelerator: t.Accelerator, Accelerator: t.Accelerator,
Description: t.Description, Description: t.Description,
} }
@ -77,32 +86,15 @@ func (s *Service) taskAdd(t TaskAdd) error {
} }
func (s *Service) taskRemove(t TaskRemove) error { func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.registeredBindings[t.Accelerator]; !exists { if _, exists := s.bindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered) return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
} }
err := s.platform.Remove(t.Accelerator) err := s.platform.Remove(t.Accelerator)
if err != nil { if err != nil {
return coreerr.E("keybinding.taskRemove", "platform remove failed", err) return fmt.Errorf("keybinding: platform remove failed: %w", err)
}
delete(s.registeredBindings, t.Accelerator)
return nil
}
// taskProcess triggers the registered handler for the given accelerator programmatically.
// Broadcasts ActionTriggered if handled; returns ErrorNotRegistered if the accelerator is unknown.
//
// c.Action("keybinding.process").Run(ctx, core.NewOptions(core.Option{Key:"task", Value:keybinding.TaskProcess{Accelerator:"Ctrl+S"}}))
func (s *Service) taskProcess(t TaskProcess) error {
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered)
}
handled := s.platform.Process(t.Accelerator)
if !handled {
return coreerr.E("keybinding.taskProcess", "platform did not handle: "+t.Accelerator, nil)
} }
delete(s.bindings, t.Accelerator)
return nil return nil
} }

View file

@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -37,17 +37,6 @@ func (m *mockPlatform) Remove(accelerator string) error {
return nil return nil
} }
func (m *mockPlatform) Process(accelerator string) bool {
m.mu.Lock()
h, ok := m.handlers[accelerator]
m.mu.Unlock()
if ok && h != nil {
h()
return true
}
return false
}
func (m *mockPlatform) GetAll() []string { func (m *mockPlatform) GetAll() []string {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -70,21 +59,16 @@ func (m *mockPlatform) trigger(accelerator string) {
func newTestKeybindingService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { func newTestKeybindingService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(mp)), core.WithService(Register(mp)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "keybinding") svc := core.MustServiceFor[*Service](c, "keybinding")
return svc, c return svc, c
} }
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
svc, _ := newTestKeybindingService(t, mp) svc, _ := newTestKeybindingService(t, mp)
@ -96,10 +80,11 @@ func TestTaskAdd_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
r := taskRun(c, "keybinding.add", TaskAdd{ _, handled, err := c.PERFORM(TaskAdd{
Accelerator: "Ctrl+S", Description: "Save", Accelerator: "Ctrl+S", Description: "Save",
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify binding registered on platform // Verify binding registered on platform
assert.Contains(t, mp.GetAll(), "Ctrl+S") assert.Contains(t, mp.GetAll(), "Ctrl+S")
@ -109,22 +94,22 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
// Second add with same accelerator should fail // Second add with same accelerator should fail
r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"}) _, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
assert.False(t, r.OK) assert.True(t, handled)
err, _ := r.Value.(error) assert.ErrorIs(t, err, ErrAlreadyRegistered)
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
} }
func TestTaskRemove_Good(t *testing.T) { func TestTaskRemove_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"}) _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify removed from platform // Verify removed from platform
assert.NotContains(t, mp.GetAll(), "Ctrl+S") assert.NotContains(t, mp.GetAll(), "Ctrl+S")
@ -134,20 +119,22 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"}) _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"})
assert.False(t, r.OK) assert.True(t, handled)
assert.Error(t, err)
} }
func TestQueryList_Good(t *testing.T) { func TestQueryList_Good(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"})
r := c.QUERY(QueryList{}) result, handled, err := c.QUERY(QueryList{})
require.True(t, r.OK) require.NoError(t, err)
list := r.Value.([]BindingInfo) assert.True(t, handled)
list := result.([]BindingInfo)
assert.Len(t, list, 2) assert.Len(t, list, 2)
} }
@ -155,9 +142,10 @@ func TestQueryList_Good_Empty(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
r := c.QUERY(QueryList{}) result, handled, err := c.QUERY(QueryList{})
require.True(t, r.OK) require.NoError(t, err)
list := r.Value.([]BindingInfo) assert.True(t, handled)
list := result.([]BindingInfo)
assert.Len(t, list, 0) assert.Len(t, list, 0)
} }
@ -168,16 +156,16 @@ func TestTaskAdd_Good_TriggerBroadcast(t *testing.T) {
// Capture broadcast actions // Capture broadcast actions
var triggered ActionTriggered var triggered ActionTriggered
var mu sync.Mutex var mu sync.Mutex
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionTriggered); ok { if a, ok := msg.(ActionTriggered); ok {
mu.Lock() mu.Lock()
triggered = a triggered = a
mu.Unlock() mu.Unlock()
} }
return core.Result{OK: true} return nil
}) })
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
// Simulate shortcut trigger via mock // Simulate shortcut trigger via mock
mp.trigger("Ctrl+S") mp.trigger("Ctrl+S")
@ -191,108 +179,23 @@ func TestTaskAdd_Good_RebindAfterRemove(t *testing.T) {
mp := newMockPlatform() mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp) _, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"}) _, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
// Should succeed after remove // Should succeed after remove
r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"}) _, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
// Verify new description // Verify new description
r2 := c.QUERY(QueryList{}) result, _, _ := c.QUERY(QueryList{})
list := r2.Value.([]BindingInfo) list := result.([]BindingInfo)
assert.Len(t, list, 1) assert.Len(t, list, 1)
assert.Equal(t, "Save v2", list[0].Description) assert.Equal(t, "Save v2", list[0].Description)
} }
func TestQueryList_Bad_NoService(t *testing.T) { func TestQueryList_Bad_NoService(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
r := c.QUERY(QueryList{}) _, handled, _ := c.QUERY(QueryList{})
assert.False(t, r.OK) assert.False(t, handled)
}
// --- TaskProcess tests ---
func TestTaskProcess_Good(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
var triggered ActionTriggered
var mu sync.Mutex
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if a, ok := msg.(ActionTriggered); ok {
mu.Lock()
triggered = a
mu.Unlock()
}
return core.Result{OK: true}
})
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
require.True(t, r.OK)
mu.Lock()
assert.Equal(t, "Ctrl+P", triggered.Accelerator)
mu.Unlock()
}
func TestTaskProcess_Bad_NotRegistered(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
func TestTaskProcess_Ugly_RemovedBinding(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+P"})
// After remove, process should fail with ErrorNotRegistered
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
// --- TaskRemove ErrorNotRegistered sentinel tests ---
func TestTaskRemove_Bad_ErrorSentinel(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"})
assert.False(t, r.OK)
err, _ := r.Value.(error)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
// --- QueryList Ugly: concurrent adds ---
func TestQueryList_Ugly_ConcurrentAdds(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
accelerators := []string{"Ctrl+1", "Ctrl+2", "Ctrl+3", "Ctrl+4", "Ctrl+5"}
var wg sync.WaitGroup
for _, accelerator := range accelerators {
wg.Add(1)
go func(acc string) {
defer wg.Done()
taskRun(c, "keybinding.add", TaskAdd{Accelerator: acc, Description: acc})
}(accelerator)
}
wg.Wait()
r := c.QUERY(QueryList{})
require.True(t, r.OK)
list := r.Value.([]BindingInfo)
assert.Len(t, list, len(accelerators))
} }

View file

@ -1,14 +1,15 @@
// pkg/lifecycle/register.go
package lifecycle package lifecycle
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the lifecycle service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(lifecycle.Register(wailsLifecycle)) // The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }

View file

@ -1,20 +1,28 @@
// pkg/lifecycle/service.go
package lifecycle package lifecycle
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the lifecycle service.
type Options struct{} type Options struct{}
// Service is a core.Service that registers platform lifecycle callbacks
// and broadcasts corresponding IPC Actions. It implements both Startable
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
cancels []func() cancels []func()
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers a platform callback for each EventType and for file-open.
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
func (s *Service) OnStartup(ctx context.Context) error {
// Register fire-and-forget event callbacks
eventActions := map[EventType]func(){ eventActions := map[EventType]func(){
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) }, EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) }, EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
@ -30,22 +38,26 @@ func (s *Service) OnStartup(_ context.Context) core.Result {
s.cancels = append(s.cancels, cancel) s.cancels = append(s.cancels, cancel)
} }
// Register file-open callback (carries data)
cancel := s.platform.OnOpenedWithFile(func(path string) { cancel := s.platform.OnOpenedWithFile(func(path string) {
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path}) _ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
}) })
s.cancels = append(s.cancels, cancel) s.cancels = append(s.cancels, cancel)
return core.Result{OK: true} return nil
} }
func (s *Service) OnShutdown(_ context.Context) core.Result { // OnShutdown cancels all registered platform callbacks.
func (s *Service) OnShutdown(ctx context.Context) error {
for _, cancel := range s.cancels { for _, cancel := range s.cancels {
cancel() cancel()
} }
s.cancels = nil s.cancels = nil
return core.Result{OK: true} return nil
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
return core.Result{OK: true} // Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }

View file

@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -91,11 +91,12 @@ func (m *mockPlatform) handlerCount() int {
func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) { func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
t.Helper() t.Helper()
mock := newMockPlatform() mock := newMockPlatform()
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "lifecycle") svc := core.MustServiceFor[*Service](c, "lifecycle")
return svc, c, mock return svc, c, mock
} }
@ -111,11 +112,11 @@ func TestApplicationStarted_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionApplicationStarted); ok { if _, ok := msg.(ActionApplicationStarted); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventApplicationStarted) mock.simulateEvent(EventApplicationStarted)
@ -126,11 +127,11 @@ func TestDidBecomeActive_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionDidBecomeActive); ok { if _, ok := msg.(ActionDidBecomeActive); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventDidBecomeActive) mock.simulateEvent(EventDidBecomeActive)
@ -141,11 +142,11 @@ func TestDidResignActive_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionDidResignActive); ok { if _, ok := msg.(ActionDidResignActive); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventDidResignActive) mock.simulateEvent(EventDidResignActive)
@ -156,11 +157,11 @@ func TestWillTerminate_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionWillTerminate); ok { if _, ok := msg.(ActionWillTerminate); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventWillTerminate) mock.simulateEvent(EventWillTerminate)
@ -171,11 +172,11 @@ func TestPowerStatusChanged_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionPowerStatusChanged); ok { if _, ok := msg.(ActionPowerStatusChanged); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventPowerStatusChanged) mock.simulateEvent(EventPowerStatusChanged)
@ -186,11 +187,11 @@ func TestSystemSuspend_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionSystemSuspend); ok { if _, ok := msg.(ActionSystemSuspend); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventSystemSuspend) mock.simulateEvent(EventSystemSuspend)
@ -201,11 +202,11 @@ func TestSystemResume_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionSystemResume); ok { if _, ok := msg.(ActionSystemResume); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
mock.simulateEvent(EventSystemResume) mock.simulateEvent(EventSystemResume)
@ -216,11 +217,11 @@ func TestOpenedWithFile_Good(t *testing.T) {
_, c, mock := newTestLifecycleService(t) _, c, mock := newTestLifecycleService(t)
var receivedPath string var receivedPath string
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionOpenedWithFile); ok { if a, ok := msg.(ActionOpenedWithFile); ok {
receivedPath = a.Path receivedPath = a.Path
} }
return core.Result{OK: true} return nil
}) })
mock.simulateFileOpen("/Users/snider/Documents/test.txt") mock.simulateFileOpen("/Users/snider/Documents/test.txt")
@ -234,21 +235,23 @@ func TestOnShutdown_CancelsAll_Good(t *testing.T) {
assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup") assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup")
// Shutdown should cancel all registrations // Shutdown should cancel all registrations
require.True(t, svc.OnShutdown(context.Background()).OK) err := svc.OnShutdown(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown") assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown")
} }
func TestRegister_Bad(t *testing.T) { func TestRegister_Bad(t *testing.T) {
// No lifecycle service registered — actions are not received // No lifecycle service registered — actions are not received
c := core.New(core.WithServiceLock()) c, err := core.New(core.WithServiceLock())
require.NoError(t, err)
var received bool var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionApplicationStarted); ok { if _, ok := msg.(ActionApplicationStarted); ok {
received = true received = true
} }
return core.Result{OK: true} return nil
}) })
// No way to trigger events without the service // No way to trigger events without the service

View file

@ -5,21 +5,27 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestSubsystem_Good_Name(t *testing.T) { func TestSubsystem_Good_Name(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
sub := New(c) sub := New(c)
assert.Equal(t, "display", sub.Name()) assert.Equal(t, "display", sub.Name())
} }
func TestSubsystem_Good_RegisterTools(t *testing.T) { func TestSubsystem_Good_RegisterTools(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
sub := New(c) sub := New(c)
// RegisterTools should not panic with a real mcp.Server // RegisterTools should not panic with a real mcp.Server
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
@ -36,24 +42,241 @@ type mockClipPlatform struct {
func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
type mockNotificationPlatform struct {
sendCalled bool
lastOpts notification.NotificationOptions
}
func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return nil
}
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
type mockEnvironmentPlatform struct {
isDark bool
}
func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo {
return environment.EnvironmentInfo{}
}
func (m *mockEnvironmentPlatform) AccentColour() string { return "" }
func (m *mockEnvironmentPlatform) OpenFileManager(path string, selectFile bool) error {
return nil
}
func (m *mockEnvironmentPlatform) OnThemeChange(handler func(isDark bool)) func() {
return func() {}
}
func (m *mockEnvironmentPlatform) SetTheme(isDark bool) error {
m.isDark = isDark
return nil
}
type mockScreenPlatform struct {
screens []screen.Screen
}
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
for i := range m.screens {
if m.screens[i].IsPrimary {
return &m.screens[i]
}
}
if len(m.screens) == 0 {
return nil
}
return &m.screens[0]
}
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
c := core.New( c, err := core.New(
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})), core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
// Verify the IPC path that clipboard_read tool handler uses // Verify the IPC path that clipboard_read tool handler uses
r := c.QUERY(clipboard.QueryText{}) result, handled, err := c.QUERY(clipboard.QueryText{})
require.True(t, r.OK) require.NoError(t, err)
content, ok := r.Value.(clipboard.ClipboardContent) assert.True(t, handled)
content, ok := result.(clipboard.ClipboardContent)
require.True(t, ok, "expected ClipboardContent type") require.True(t, ok, "expected ClipboardContent type")
assert.Equal(t, "hello", content.Text) assert.Equal(t, "hello", content.Text)
} }
func TestMCP_Bad_NoServices(t *testing.T) { func TestMCP_Good_DialogMessage(t *testing.T) {
c := core.New(core.WithServiceLock()) mock := &mockNotificationPlatform{}
// Without any services, QUERY should return OK=false c, err := core.New(
r := c.QUERY(clipboard.QueryText{}) core.WithService(notification.Register(mock)),
assert.False(t, r.OK) core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
sub := New(c)
_, result, err := sub.dialogMessage(context.Background(), nil, DialogMessageInput{
Title: "Alias",
Message: "Hello",
Kind: "error",
})
require.NoError(t, err)
assert.True(t, result.Success)
assert.True(t, mock.sendCalled)
assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity)
}
func TestMCP_Good_ThemeSetString(t *testing.T) {
mock := &mockEnvironmentPlatform{isDark: true}
c, err := core.New(
core.WithService(environment.Register(mock)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
sub := New(c)
_, result, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"})
require.NoError(t, err)
assert.Equal(t, "light", result.Theme.Theme)
assert.False(t, result.Theme.IsDark)
assert.False(t, mock.isDark)
}
func TestMCP_Good_WindowTitleSetAlias(t *testing.T) {
c, err := core.New(
core.WithService(window.Register(window.NewMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(window.TaskOpenWindow{
Window: &window.Window{Name: "alias-win", Title: "Original", URL: "/"},
})
require.NoError(t, err)
assert.True(t, handled)
sub := New(c)
_, result, err := sub.windowTitleSet(context.Background(), nil, WindowTitleInput{
Name: "alias-win",
Title: "Updated",
})
require.NoError(t, err)
assert.True(t, result.Success)
queried, handled, err := c.QUERY(window.QueryWindowByName{Name: "alias-win"})
require.NoError(t, err)
assert.True(t, handled)
info, ok := queried.(*window.WindowInfo)
require.True(t, ok)
require.NotNil(t, info)
assert.Equal(t, "Updated", info.Title)
}
func TestMCP_Good_ScreenWorkAreaAlias(t *testing.T) {
c, err := core.New(
core.WithService(screen.Register(&mockScreenPlatform{
screens: []screen.Screen{
{
ID: "1",
Name: "Primary",
IsPrimary: true,
WorkArea: screen.Rect{X: 0, Y: 24, Width: 1920, Height: 1056},
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Size: screen.Size{Width: 1920, Height: 1080},
},
},
})),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
sub := New(c)
_, plural, err := sub.screenWorkAreas(context.Background(), nil, ScreenWorkAreasInput{})
require.NoError(t, err)
_, alias, err := sub.screenWorkArea(context.Background(), nil, ScreenWorkAreasInput{})
require.NoError(t, err)
assert.Equal(t, plural, alias)
assert.Len(t, alias.WorkAreas, 1)
assert.Equal(t, 24, alias.WorkAreas[0].Y)
}
func TestMCP_Good_ScreenForWindow(t *testing.T) {
c, err := core.New(
core.WithService(display.Register(nil)),
core.WithService(screen.Register(&mockScreenPlatform{
screens: []screen.Screen{
{
ID: "1",
Name: "Primary",
IsPrimary: true,
WorkArea: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Size: screen.Size{Width: 1920, Height: 1080},
},
{
ID: "2",
Name: "Secondary",
WorkArea: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
Bounds: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
Size: screen.Size{Width: 1280, Height: 1024},
},
},
})),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(window.TaskOpenWindow{
Window: &window.Window{Name: "editor", Title: "Editor", X: 100, Y: 100, Width: 800, Height: 600},
})
require.NoError(t, err)
assert.True(t, handled)
sub := New(c)
_, out, err := sub.screenForWindow(context.Background(), nil, ScreenForWindowInput{Window: "editor"})
require.NoError(t, err)
require.NotNil(t, out.Screen)
assert.Equal(t, "Primary", out.Screen.Name)
}
func TestMCP_Good_WebviewErrors(t *testing.T) {
c, err := core.New(
core.WithService(webview.Register(webview.Options{})),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
require.NoError(t, c.ACTION(webview.ActionException{
Window: "main",
Exception: webview.ExceptionInfo{
Text: "boom",
URL: "https://example.com/app.js",
Line: 12,
Column: 4,
StackTrace: "Error: boom",
},
}))
sub := New(c)
_, out, err := sub.webviewErrors(context.Background(), nil, WebviewErrorsInput{Window: "main"})
require.NoError(t, err)
require.Len(t, out.Errors, 1)
assert.Equal(t, "boom", out.Errors[0].Text)
}
func TestMCP_Bad_NoServices(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
// Without any services, QUERY should return handled=false
_, handled, _ := c.QUERY(clipboard.QueryText{})
assert.False(t, handled)
} }

View file

@ -2,23 +2,25 @@
package mcp package mcp
import ( import (
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
// Subsystem translates MCP tool calls to Core IPC messages for GUI operations. // Subsystem implements the MCP Subsystem interface via structural typing.
// It registers GUI tools that translate MCP tool calls to IPC messages.
type Subsystem struct { type Subsystem struct {
core *core.Core core *core.Core
} }
// New(c) creates a display MCP subsystem backed by a Core instance. // New creates a display MCP subsystem backed by the given Core instance.
// sub := mcp.New(c); sub.RegisterTools(server)
func New(c *core.Core) *Subsystem { func New(c *core.Core) *Subsystem {
return &Subsystem{core: c} return &Subsystem{core: c}
} }
// Name returns the subsystem identifier.
func (s *Subsystem) Name() string { return "display" } func (s *Subsystem) Name() string { return "display" }
// RegisterTools registers all GUI tools with the MCP server.
func (s *Subsystem) RegisterTools(server *mcp.Server) { func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerWebviewTools(server) s.registerWebviewTools(server)
s.registerWindowTools(server) s.registerWindowTools(server)
@ -34,5 +36,4 @@ func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerKeybindingTools(server) s.registerKeybindingTools(server)
s.registerDockTools(server) s.registerDockTools(server)
s.registerLifecycleTools(server) s.registerLifecycleTools(server)
s.registerEventsTools(server)
} }

View file

@ -4,7 +4,7 @@ package mcp
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/gui/pkg/browser"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -18,14 +18,9 @@ type BrowserOpenURLOutput struct {
} }
func (s *Subsystem) browserOpenURL(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenURLInput) (*mcp.CallToolResult, BrowserOpenURLOutput, error) { func (s *Subsystem) browserOpenURL(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenURLInput) (*mcp.CallToolResult, BrowserOpenURLOutput, error) {
r := s.core.Action("browser.openURL").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(browser.TaskOpenURL{URL: input.URL})
core.Option{Key: "url", Value: input.URL}, if err != nil {
)) return nil, BrowserOpenURLOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, BrowserOpenURLOutput{}, e
}
return nil, BrowserOpenURLOutput{}, nil
} }
return nil, BrowserOpenURLOutput{Success: true}, nil return nil, BrowserOpenURLOutput{Success: true}, nil
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/clipboard"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -18,16 +17,13 @@ type ClipboardReadOutput struct {
} }
func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, error) { func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, error) {
r := s.core.QUERY(clipboard.QueryText{}) result, _, err := s.core.QUERY(clipboard.QueryText{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ClipboardReadOutput{}, err
return nil, ClipboardReadOutput{}, e
} }
return nil, ClipboardReadOutput{}, nil content, ok := result.(clipboard.ClipboardContent)
}
content, ok := r.Value.(clipboard.ClipboardContent)
if !ok { if !ok {
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil) return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query")
} }
return nil, ClipboardReadOutput{Content: content.Text}, nil return nil, ClipboardReadOutput{Content: content.Text}, nil
} }
@ -42,16 +38,15 @@ type ClipboardWriteOutput struct {
} }
func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteInput) (*mcp.CallToolResult, ClipboardWriteOutput, error) { func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteInput) (*mcp.CallToolResult, ClipboardWriteOutput, error) {
r := s.core.Action("clipboard.setText").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(clipboard.TaskSetText{Text: input.Text})
core.Option{Key: "task", Value: clipboard.TaskSetText{Text: input.Text}}, if err != nil {
)) return nil, ClipboardWriteOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, ClipboardWriteOutput{}, e
} }
return nil, ClipboardWriteOutput{}, nil success, ok := result.(bool)
if !ok {
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
} }
return nil, ClipboardWriteOutput{Success: true}, nil return nil, ClipboardWriteOutput{Success: success}, nil
} }
// --- clipboard_has --- // --- clipboard_has ---
@ -62,13 +57,13 @@ type ClipboardHasOutput struct {
} }
func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardHasInput) (*mcp.CallToolResult, ClipboardHasOutput, error) { func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardHasInput) (*mcp.CallToolResult, ClipboardHasOutput, error) {
r := s.core.QUERY(clipboard.QueryText{}) result, _, err := s.core.QUERY(clipboard.QueryText{})
if !r.OK { if err != nil {
return nil, ClipboardHasOutput{}, nil return nil, ClipboardHasOutput{}, err
} }
content, ok := r.Value.(clipboard.ClipboardContent) content, ok := result.(clipboard.ClipboardContent)
if !ok { if !ok {
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil) return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query")
} }
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
} }
@ -81,14 +76,51 @@ type ClipboardClearOutput struct {
} }
func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardClearInput) (*mcp.CallToolResult, ClipboardClearOutput, error) { func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardClearInput) (*mcp.CallToolResult, ClipboardClearOutput, error) {
r := s.core.Action("clipboard.clear").Run(context.Background(), core.NewOptions()) result, _, err := s.core.PERFORM(clipboard.TaskClear{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ClipboardClearOutput{}, err
return nil, ClipboardClearOutput{}, e
} }
return nil, ClipboardClearOutput{}, nil success, ok := result.(bool)
if !ok {
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
} }
return nil, ClipboardClearOutput{Success: true}, nil return nil, ClipboardClearOutput{Success: success}, nil
}
// --- clipboard_read_image ---
type ClipboardReadImageInput struct{}
type ClipboardReadImageOutput struct {
Image clipboard.ClipboardImageContent `json:"image"`
}
func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) {
result, _, err := s.core.QUERY(clipboard.QueryImage{})
if err != nil {
return nil, ClipboardReadImageOutput{}, err
}
image, ok := result.(clipboard.ClipboardImageContent)
if !ok {
return nil, ClipboardReadImageOutput{}, fmt.Errorf("unexpected result type from clipboard image query")
}
return nil, ClipboardReadImageOutput{Image: image}, nil
}
// --- clipboard_write_image ---
type ClipboardWriteImageInput struct {
Data []byte `json:"data"`
}
type ClipboardWriteImageOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) {
_, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: input.Data})
if err != nil {
return nil, ClipboardWriteImageOutput{}, err
}
return nil, ClipboardWriteImageOutput{Success: true}, nil
} }
// --- Registration --- // --- Registration ---
@ -98,4 +130,6 @@ func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite)
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas)
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear)
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read an image from the clipboard"}, s.clipboardReadImage)
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write an image to the clipboard"}, s.clipboardWriteImage)
} }

View file

@ -3,9 +3,9 @@ package mcp
import ( import (
"context" "context"
"encoding/json"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/contextmenu" "forge.lthn.ai/core/gui/pkg/contextmenu"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -25,24 +25,17 @@ type ContextMenuAddOutput struct {
func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) { func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) {
// Convert map[string]any to ContextMenuDef via JSON round-trip // Convert map[string]any to ContextMenuDef via JSON round-trip
marshalResult := core.JSONMarshal(input.Menu) menuJSON, err := json.Marshal(input.Menu)
if !marshalResult.OK { if err != nil {
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", marshalResult.Value.(error)) return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
} }
menuJSON := marshalResult.Value.([]byte)
var menuDef contextmenu.ContextMenuDef var menuDef contextmenu.ContextMenuDef
unmarshalResult := core.JSONUnmarshal(menuJSON, &menuDef) if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
if !unmarshalResult.OK { return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", unmarshalResult.Value.(error))
} }
r := s.core.Action("contextmenu.add").Run(context.Background(), core.NewOptions( _, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
core.Option{Key: "task", Value: contextmenu.TaskAdd{Name: input.Name, Menu: menuDef}}, if err != nil {
)) return nil, ContextMenuAddOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, ContextMenuAddOutput{}, e
}
return nil, ContextMenuAddOutput{}, nil
} }
return nil, ContextMenuAddOutput{Success: true}, nil return nil, ContextMenuAddOutput{Success: true}, nil
} }
@ -57,14 +50,9 @@ type ContextMenuRemoveOutput struct {
} }
func (s *Subsystem) contextMenuRemove(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuRemoveInput) (*mcp.CallToolResult, ContextMenuRemoveOutput, error) { func (s *Subsystem) contextMenuRemove(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuRemoveInput) (*mcp.CallToolResult, ContextMenuRemoveOutput, error) {
r := s.core.Action("contextmenu.remove").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(contextmenu.TaskRemove{Name: input.Name})
core.Option{Key: "task", Value: contextmenu.TaskRemove{Name: input.Name}}, if err != nil {
)) return nil, ContextMenuRemoveOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, ContextMenuRemoveOutput{}, e
}
return nil, ContextMenuRemoveOutput{}, nil
} }
return nil, ContextMenuRemoveOutput{Success: true}, nil return nil, ContextMenuRemoveOutput{Success: true}, nil
} }
@ -79,30 +67,25 @@ type ContextMenuGetOutput struct {
} }
func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, error) { func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, error) {
r := s.core.QUERY(contextmenu.QueryGet{Name: input.Name}) result, _, err := s.core.QUERY(contextmenu.QueryGet{Name: input.Name})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ContextMenuGetOutput{}, err
return nil, ContextMenuGetOutput{}, e
} }
return nil, ContextMenuGetOutput{}, nil menu, ok := result.(*contextmenu.ContextMenuDef)
}
menu, ok := r.Value.(*contextmenu.ContextMenuDef)
if !ok { if !ok {
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil) return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
} }
if menu == nil { if menu == nil {
return nil, ContextMenuGetOutput{}, nil return nil, ContextMenuGetOutput{}, nil
} }
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
marshalResult := core.JSONMarshal(menu) menuJSON, err := json.Marshal(menu)
if !marshalResult.OK { if err != nil {
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", marshalResult.Value.(error)) return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
} }
menuJSON := marshalResult.Value.([]byte)
var menuMap map[string]any var menuMap map[string]any
unmarshalResult := core.JSONUnmarshal(menuJSON, &menuMap) if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
if !unmarshalResult.OK { return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", unmarshalResult.Value.(error))
} }
return nil, ContextMenuGetOutput{Menu: menuMap}, nil return nil, ContextMenuGetOutput{Menu: menuMap}, nil
} }
@ -115,27 +98,22 @@ type ContextMenuListOutput struct {
} }
func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, error) { func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, error) {
r := s.core.QUERY(contextmenu.QueryList{}) result, _, err := s.core.QUERY(contextmenu.QueryList{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ContextMenuListOutput{}, err
return nil, ContextMenuListOutput{}, e
} }
return nil, ContextMenuListOutput{}, nil menus, ok := result.(map[string]contextmenu.ContextMenuDef)
}
menus, ok := r.Value.(map[string]contextmenu.ContextMenuDef)
if !ok { if !ok {
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil) return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query")
} }
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
marshalResult := core.JSONMarshal(menus) menusJSON, err := json.Marshal(menus)
if !marshalResult.OK { if err != nil {
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", marshalResult.Value.(error)) return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
} }
menusJSON := marshalResult.Value.([]byte)
var menusMap map[string]any var menusMap map[string]any
unmarshalResult := core.JSONUnmarshal(menusJSON, &menusMap) if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
if !unmarshalResult.OK { return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", unmarshalResult.Value.(error))
} }
return nil, ContextMenuListOutput{Menus: menusMap}, nil return nil, ContextMenuListOutput{Menus: menusMap}, nil
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/dialog" "forge.lthn.ai/core/gui/pkg/dialog"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -17,35 +16,24 @@ type DialogOpenFileInput struct {
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
Filters []dialog.FileFilter `json:"filters,omitempty"` Filters []dialog.FileFilter `json:"filters,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"` AllowMultiple bool `json:"allowMultiple,omitempty"`
CanChooseDirectories bool `json:"canChooseDirectories,omitempty"`
CanChooseFiles bool `json:"canChooseFiles,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
type DialogOpenFileOutput struct { type DialogOpenFileOutput struct {
Paths []string `json:"paths"` Paths []string `json:"paths"`
} }
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) { func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
r := s.core.Action("dialog.openFile").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
core.Option{Key: "task", Value: dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
Title: input.Title, Title: input.Title,
Directory: input.Directory, Directory: input.Directory,
Filters: input.Filters, Filters: input.Filters,
AllowMultiple: input.AllowMultiple, AllowMultiple: input.AllowMultiple,
CanChooseDirectories: input.CanChooseDirectories, }})
CanChooseFiles: input.CanChooseFiles, if err != nil {
ShowHiddenFiles: input.ShowHiddenFiles, return nil, DialogOpenFileOutput{}, err
}}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogOpenFileOutput{}, e
} }
return nil, DialogOpenFileOutput{}, nil paths, ok := result.([]string)
}
paths, ok := r.Value.([]string)
if !ok { if !ok {
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil) return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog")
} }
return nil, DialogOpenFileOutput{Paths: paths}, nil return nil, DialogOpenFileOutput{Paths: paths}, nil
} }
@ -57,31 +45,24 @@ type DialogSaveFileInput struct {
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"` Filename string `json:"filename,omitempty"`
Filters []dialog.FileFilter `json:"filters,omitempty"` Filters []dialog.FileFilter `json:"filters,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
type DialogSaveFileOutput struct { type DialogSaveFileOutput struct {
Path string `json:"path"` Path string `json:"path"`
} }
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) { func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
r := s.core.Action("dialog.saveFile").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
core.Option{Key: "task", Value: dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
Title: input.Title, Title: input.Title,
Directory: input.Directory, Directory: input.Directory,
Filename: input.Filename, Filename: input.Filename,
Filters: input.Filters, Filters: input.Filters,
ShowHiddenFiles: input.ShowHiddenFiles, }})
}}}, if err != nil {
)) return nil, DialogSaveFileOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogSaveFileOutput{}, e
} }
return nil, DialogSaveFileOutput{}, nil path, ok := result.(string)
}
path, ok := r.Value.(string)
if !ok { if !ok {
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil) return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog")
} }
return nil, DialogSaveFileOutput{Path: path}, nil return nil, DialogSaveFileOutput{Path: path}, nil
} }
@ -91,29 +72,22 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
type DialogOpenDirectoryInput struct { type DialogOpenDirectoryInput struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"` Directory string `json:"directory,omitempty"`
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
} }
type DialogOpenDirectoryOutput struct { type DialogOpenDirectoryOutput struct {
Path string `json:"path"` Path string `json:"path"`
} }
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) { func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
r := s.core.Action("dialog.openDirectory").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
core.Option{Key: "task", Value: dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
Title: input.Title, Title: input.Title,
Directory: input.Directory, Directory: input.Directory,
ShowHiddenFiles: input.ShowHiddenFiles, }})
}}}, if err != nil {
)) return nil, DialogOpenDirectoryOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogOpenDirectoryOutput{}, e
} }
return nil, DialogOpenDirectoryOutput{}, nil path, ok := result.(string)
}
path, ok := r.Value.(string)
if !ok { if !ok {
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil) return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog")
} }
return nil, DialogOpenDirectoryOutput{Path: path}, nil return nil, DialogOpenDirectoryOutput{Path: path}, nil
} }
@ -130,22 +104,18 @@ type DialogConfirmOutput struct {
} }
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) { func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
r := s.core.Action("dialog.question").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
core.Option{Key: "task", Value: dialog.TaskQuestion{ Type: dialog.DialogQuestion,
Title: input.Title, Title: input.Title,
Message: input.Message, Message: input.Message,
Buttons: input.Buttons, Buttons: input.Buttons,
}}, }})
)) if err != nil {
if !r.OK { return nil, DialogConfirmOutput{}, err
if e, ok := r.Value.(error); ok {
return nil, DialogConfirmOutput{}, e
} }
return nil, DialogConfirmOutput{}, nil button, ok := result.(string)
}
button, ok := r.Value.(string)
if !ok { if !ok {
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil) return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog")
} }
return nil, DialogConfirmOutput{Button: button}, nil return nil, DialogConfirmOutput{Button: button}, nil
} }
@ -161,131 +131,28 @@ type DialogPromptOutput struct {
} }
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) { func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
r := s.core.Action("dialog.info").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
core.Option{Key: "task", Value: dialog.TaskInfo{ Type: dialog.DialogInfo,
Title: input.Title, Title: input.Title,
Message: input.Message, Message: input.Message,
Buttons: []string{"OK", "Cancel"}, Buttons: []string{"OK", "Cancel"},
}}, }})
)) if err != nil {
if !r.OK { return nil, DialogPromptOutput{}, err
if e, ok := r.Value.(error); ok {
return nil, DialogPromptOutput{}, e
} }
return nil, DialogPromptOutput{}, nil button, ok := result.(string)
}
button, ok := r.Value.(string)
if !ok { if !ok {
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil) return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog")
} }
return nil, DialogPromptOutput{Button: button}, nil return nil, DialogPromptOutput{Button: button}, nil
} }
// --- dialog_info ---
type DialogInfoInput struct {
Title string `json:"title"`
Message string `json:"message"`
Buttons []string `json:"buttons,omitempty"`
}
type DialogInfoOutput struct {
Button string `json:"button"`
}
func (s *Subsystem) dialogInfo(_ context.Context, _ *mcp.CallToolRequest, input DialogInfoInput) (*mcp.CallToolResult, DialogInfoOutput, error) {
r := s.core.Action("dialog.info").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: dialog.TaskInfo{
Title: input.Title,
Message: input.Message,
Buttons: input.Buttons,
}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogInfoOutput{}, e
}
return nil, DialogInfoOutput{}, nil
}
button, ok := r.Value.(string)
if !ok {
return nil, DialogInfoOutput{}, coreerr.E("mcp.dialogInfo", "unexpected result type", nil)
}
return nil, DialogInfoOutput{Button: button}, nil
}
// --- dialog_warning ---
type DialogWarningInput struct {
Title string `json:"title"`
Message string `json:"message"`
Buttons []string `json:"buttons,omitempty"`
}
type DialogWarningOutput struct {
Button string `json:"button"`
}
func (s *Subsystem) dialogWarning(_ context.Context, _ *mcp.CallToolRequest, input DialogWarningInput) (*mcp.CallToolResult, DialogWarningOutput, error) {
r := s.core.Action("dialog.warning").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: dialog.TaskWarning{
Title: input.Title,
Message: input.Message,
Buttons: input.Buttons,
}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogWarningOutput{}, e
}
return nil, DialogWarningOutput{}, nil
}
button, ok := r.Value.(string)
if !ok {
return nil, DialogWarningOutput{}, coreerr.E("mcp.dialogWarning", "unexpected result type", nil)
}
return nil, DialogWarningOutput{Button: button}, nil
}
// --- dialog_error ---
type DialogErrorInput struct {
Title string `json:"title"`
Message string `json:"message"`
Buttons []string `json:"buttons,omitempty"`
}
type DialogErrorOutput struct {
Button string `json:"button"`
}
func (s *Subsystem) dialogError(_ context.Context, _ *mcp.CallToolRequest, input DialogErrorInput) (*mcp.CallToolResult, DialogErrorOutput, error) {
r := s.core.Action("dialog.error").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: dialog.TaskError{
Title: input.Title,
Message: input.Message,
Buttons: input.Buttons,
}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DialogErrorOutput{}, e
}
return nil, DialogErrorOutput{}, nil
}
button, ok := r.Value.(string)
if !ok {
return nil, DialogErrorOutput{}, coreerr.E("mcp.dialogError", "unexpected result type", nil)
}
return nil, DialogErrorOutput{Button: button}, nil
}
// --- Registration --- // --- Registration ---
func (s *Subsystem) registerDialogTools(server *mcp.Server) { func (s *Subsystem) registerDialogTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory) mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a question/confirmation dialog"}, s.dialogConfirm) mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a confirmation dialog"}, s.dialogConfirm)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show an info prompt dialog with OK/Cancel"}, s.dialogPrompt) mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show a prompt dialog"}, s.dialogPrompt)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_info", Description: "Show an information message dialog"}, s.dialogInfo)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_warning", Description: "Show a warning message dialog"}, s.dialogWarning)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_error", Description: "Show an error message dialog"}, s.dialogError)
} }

View file

@ -4,7 +4,6 @@ package mcp
import ( import (
"context" "context"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/dock" "forge.lthn.ai/core/gui/pkg/dock"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -17,12 +16,9 @@ type DockShowOutput struct {
} }
func (s *Subsystem) dockShow(_ context.Context, _ *mcp.CallToolRequest, _ DockShowInput) (*mcp.CallToolResult, DockShowOutput, error) { func (s *Subsystem) dockShow(_ context.Context, _ *mcp.CallToolRequest, _ DockShowInput) (*mcp.CallToolResult, DockShowOutput, error) {
r := s.core.Action("dock.showIcon").Run(context.Background(), core.NewOptions()) _, _, err := s.core.PERFORM(dock.TaskShowIcon{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, DockShowOutput{}, err
return nil, DockShowOutput{}, e
}
return nil, DockShowOutput{}, nil
} }
return nil, DockShowOutput{Success: true}, nil return nil, DockShowOutput{Success: true}, nil
} }
@ -35,12 +31,9 @@ type DockHideOutput struct {
} }
func (s *Subsystem) dockHide(_ context.Context, _ *mcp.CallToolRequest, _ DockHideInput) (*mcp.CallToolResult, DockHideOutput, error) { func (s *Subsystem) dockHide(_ context.Context, _ *mcp.CallToolRequest, _ DockHideInput) (*mcp.CallToolResult, DockHideOutput, error) {
r := s.core.Action("dock.hideIcon").Run(context.Background(), core.NewOptions()) _, _, err := s.core.PERFORM(dock.TaskHideIcon{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, DockHideOutput{}, err
return nil, DockHideOutput{}, e
}
return nil, DockHideOutput{}, nil
} }
return nil, DockHideOutput{Success: true}, nil return nil, DockHideOutput{Success: true}, nil
} }
@ -55,14 +48,9 @@ type DockBadgeOutput struct {
} }
func (s *Subsystem) dockBadge(_ context.Context, _ *mcp.CallToolRequest, input DockBadgeInput) (*mcp.CallToolResult, DockBadgeOutput, error) { func (s *Subsystem) dockBadge(_ context.Context, _ *mcp.CallToolRequest, input DockBadgeInput) (*mcp.CallToolResult, DockBadgeOutput, error) {
r := s.core.Action("dock.setBadge").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(dock.TaskSetBadge{Label: input.Label})
core.Option{Key: "task", Value: dock.TaskSetBadge{Label: input.Label}}, if err != nil {
)) return nil, DockBadgeOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, DockBadgeOutput{}, e
}
return nil, DockBadgeOutput{}, nil
} }
return nil, DockBadgeOutput{Success: true}, nil return nil, DockBadgeOutput{Success: true}, nil
} }

View file

@ -3,8 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/environment" "forge.lthn.ai/core/gui/pkg/environment"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -17,16 +17,13 @@ type ThemeGetOutput struct {
} }
func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeGetInput) (*mcp.CallToolResult, ThemeGetOutput, error) { func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeGetInput) (*mcp.CallToolResult, ThemeGetOutput, error) {
r := s.core.QUERY(environment.QueryTheme{}) result, _, err := s.core.QUERY(environment.QueryTheme{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ThemeGetOutput{}, err
return nil, ThemeGetOutput{}, e
} }
return nil, ThemeGetOutput{}, nil theme, ok := result.(environment.ThemeInfo)
}
theme, ok := r.Value.(environment.ThemeInfo)
if !ok { if !ok {
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil) return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query")
} }
return nil, ThemeGetOutput{Theme: theme}, nil return nil, ThemeGetOutput{Theme: theme}, nil
} }
@ -39,23 +36,46 @@ type ThemeSystemOutput struct {
} }
func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ ThemeSystemInput) (*mcp.CallToolResult, ThemeSystemOutput, error) { func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ ThemeSystemInput) (*mcp.CallToolResult, ThemeSystemOutput, error) {
r := s.core.QUERY(environment.QueryInfo{}) result, _, err := s.core.QUERY(environment.QueryInfo{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ThemeSystemOutput{}, err
return nil, ThemeSystemOutput{}, e
} }
return nil, ThemeSystemOutput{}, nil info, ok := result.(environment.EnvironmentInfo)
}
info, ok := r.Value.(environment.EnvironmentInfo)
if !ok { if !ok {
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil) return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query")
} }
return nil, ThemeSystemOutput{Info: info}, nil return nil, ThemeSystemOutput{Info: info}, nil
} }
// --- theme_set ---
type ThemeSetInput struct {
Theme string `json:"theme"`
}
type ThemeSetOutput struct {
Theme environment.ThemeInfo `json:"theme"`
}
func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) {
_, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme})
if err != nil {
return nil, ThemeSetOutput{}, err
}
result, _, err := s.core.QUERY(environment.QueryTheme{})
if err != nil {
return nil, ThemeSetOutput{}, err
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return nil, ThemeSetOutput{}, fmt.Errorf("unexpected result type from theme query")
}
return nil, ThemeSetOutput{Theme: theme}, nil
}
// --- Registration --- // --- Registration ---
func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet)
mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem)
mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set the application theme override"}, s.themeSet)
} }

View file

@ -1,113 +0,0 @@
// pkg/mcp/tools_events.go
package mcp
import (
"context"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/events"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// --- event_emit ---
type EventEmitInput struct {
Name string `json:"name"`
Data any `json:"data,omitempty"`
}
type EventEmitOutput struct {
Cancelled bool `json:"cancelled"`
}
func (s *Subsystem) eventEmit(_ context.Context, _ *mcp.CallToolRequest, input EventEmitInput) (*mcp.CallToolResult, EventEmitOutput, error) {
r := s.core.Action("events.emit").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: events.TaskEmit{Name: input.Name, Data: input.Data}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, EventEmitOutput{}, e
}
return nil, EventEmitOutput{}, nil
}
cancelled, ok := r.Value.(bool)
if !ok {
return nil, EventEmitOutput{}, coreerr.E("mcp.eventEmit", "unexpected result type", nil)
}
return nil, EventEmitOutput{Cancelled: cancelled}, nil
}
// --- event_on ---
type EventOnInput struct {
Name string `json:"name"`
}
type EventOnOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) eventOn(_ context.Context, _ *mcp.CallToolRequest, input EventOnInput) (*mcp.CallToolResult, EventOnOutput, error) {
r := s.core.Action("events.on").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: events.TaskOn{Name: input.Name}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, EventOnOutput{}, e
}
return nil, EventOnOutput{}, nil
}
return nil, EventOnOutput{Success: true}, nil
}
// --- event_off ---
type EventOffInput struct {
Name string `json:"name"`
}
type EventOffOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) eventOff(_ context.Context, _ *mcp.CallToolRequest, input EventOffInput) (*mcp.CallToolResult, EventOffOutput, error) {
r := s.core.Action("events.off").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: events.TaskOff{Name: input.Name}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, EventOffOutput{}, e
}
return nil, EventOffOutput{}, nil
}
return nil, EventOffOutput{Success: true}, nil
}
// --- event_list ---
type EventListInput struct{}
type EventListOutput struct {
Listeners []events.ListenerInfo `json:"listeners"`
}
func (s *Subsystem) eventList(_ context.Context, _ *mcp.CallToolRequest, _ EventListInput) (*mcp.CallToolResult, EventListOutput, error) {
r := s.core.QUERY(events.QueryListeners{})
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, EventListOutput{}, e
}
return nil, EventListOutput{}, nil
}
listenerInfos, ok := r.Value.([]events.ListenerInfo)
if !ok {
return nil, EventListOutput{}, coreerr.E("mcp.eventList", "unexpected result type", nil)
}
return nil, EventListOutput{Listeners: listenerInfos}, nil
}
// --- Registration ---
func (s *Subsystem) registerEventsTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "event_emit", Description: "Fire a named custom event with optional data"}, s.eventEmit)
mcp.AddTool(server, &mcp.Tool{Name: "event_on", Description: "Register a listener for a named custom event"}, s.eventOn)
mcp.AddTool(server, &mcp.Tool{Name: "event_off", Description: "Remove all listeners for a named custom event"}, s.eventOff)
mcp.AddTool(server, &mcp.Tool{Name: "event_list", Description: "Query all registered event listeners"}, s.eventList)
}

View file

@ -4,7 +4,6 @@ package mcp
import ( import (
"context" "context"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/keybinding" "forge.lthn.ai/core/gui/pkg/keybinding"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -20,14 +19,9 @@ type KeybindingAddOutput struct {
} }
func (s *Subsystem) keybindingAdd(_ context.Context, _ *mcp.CallToolRequest, input KeybindingAddInput) (*mcp.CallToolResult, KeybindingAddOutput, error) { func (s *Subsystem) keybindingAdd(_ context.Context, _ *mcp.CallToolRequest, input KeybindingAddInput) (*mcp.CallToolResult, KeybindingAddOutput, error) {
r := s.core.Action("keybinding.add").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(keybinding.TaskAdd{Accelerator: input.Accelerator, Description: input.Description})
core.Option{Key: "task", Value: keybinding.TaskAdd{Accelerator: input.Accelerator, Description: input.Description}}, if err != nil {
)) return nil, KeybindingAddOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, KeybindingAddOutput{}, e
}
return nil, KeybindingAddOutput{}, nil
} }
return nil, KeybindingAddOutput{Success: true}, nil return nil, KeybindingAddOutput{Success: true}, nil
} }
@ -42,14 +36,9 @@ type KeybindingRemoveOutput struct {
} }
func (s *Subsystem) keybindingRemove(_ context.Context, _ *mcp.CallToolRequest, input KeybindingRemoveInput) (*mcp.CallToolResult, KeybindingRemoveOutput, error) { func (s *Subsystem) keybindingRemove(_ context.Context, _ *mcp.CallToolRequest, input KeybindingRemoveInput) (*mcp.CallToolResult, KeybindingRemoveOutput, error) {
r := s.core.Action("keybinding.remove").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(keybinding.TaskRemove{Accelerator: input.Accelerator})
core.Option{Key: "task", Value: keybinding.TaskRemove{Accelerator: input.Accelerator}}, if err != nil {
)) return nil, KeybindingRemoveOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, KeybindingRemoveOutput{}, e
}
return nil, KeybindingRemoveOutput{}, nil
} }
return nil, KeybindingRemoveOutput{Success: true}, nil return nil, KeybindingRemoveOutput{Success: true}, nil
} }

View file

@ -3,9 +3,10 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window" "forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -20,14 +21,9 @@ type LayoutSaveOutput struct {
} }
func (s *Subsystem) layoutSave(_ context.Context, _ *mcp.CallToolRequest, input LayoutSaveInput) (*mcp.CallToolResult, LayoutSaveOutput, error) { func (s *Subsystem) layoutSave(_ context.Context, _ *mcp.CallToolRequest, input LayoutSaveInput) (*mcp.CallToolResult, LayoutSaveOutput, error) {
r := s.core.Action("window.saveLayout").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSaveLayout{Name: input.Name})
core.Option{Key: "task", Value: window.TaskSaveLayout{Name: input.Name}}, if err != nil {
)) return nil, LayoutSaveOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutSaveOutput{}, e
}
return nil, LayoutSaveOutput{}, nil
} }
return nil, LayoutSaveOutput{Success: true}, nil return nil, LayoutSaveOutput{Success: true}, nil
} }
@ -42,14 +38,9 @@ type LayoutRestoreOutput struct {
} }
func (s *Subsystem) layoutRestore(_ context.Context, _ *mcp.CallToolRequest, input LayoutRestoreInput) (*mcp.CallToolResult, LayoutRestoreOutput, error) { func (s *Subsystem) layoutRestore(_ context.Context, _ *mcp.CallToolRequest, input LayoutRestoreInput) (*mcp.CallToolResult, LayoutRestoreOutput, error) {
r := s.core.Action("window.restoreLayout").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskRestoreLayout{Name: input.Name})
core.Option{Key: "task", Value: window.TaskRestoreLayout{Name: input.Name}}, if err != nil {
)) return nil, LayoutRestoreOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutRestoreOutput{}, e
}
return nil, LayoutRestoreOutput{}, nil
} }
return nil, LayoutRestoreOutput{Success: true}, nil return nil, LayoutRestoreOutput{Success: true}, nil
} }
@ -62,16 +53,13 @@ type LayoutListOutput struct {
} }
func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ LayoutListInput) (*mcp.CallToolResult, LayoutListOutput, error) { func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ LayoutListInput) (*mcp.CallToolResult, LayoutListOutput, error) {
r := s.core.QUERY(window.QueryLayoutList{}) result, _, err := s.core.QUERY(window.QueryLayoutList{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, LayoutListOutput{}, err
return nil, LayoutListOutput{}, e
} }
return nil, LayoutListOutput{}, nil layouts, ok := result.([]window.LayoutInfo)
}
layouts, ok := r.Value.([]window.LayoutInfo)
if !ok { if !ok {
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil) return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query")
} }
return nil, LayoutListOutput{Layouts: layouts}, nil return nil, LayoutListOutput{Layouts: layouts}, nil
} }
@ -86,14 +74,9 @@ type LayoutDeleteOutput struct {
} }
func (s *Subsystem) layoutDelete(_ context.Context, _ *mcp.CallToolRequest, input LayoutDeleteInput) (*mcp.CallToolResult, LayoutDeleteOutput, error) { func (s *Subsystem) layoutDelete(_ context.Context, _ *mcp.CallToolRequest, input LayoutDeleteInput) (*mcp.CallToolResult, LayoutDeleteOutput, error) {
r := s.core.Action("window.deleteLayout").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskDeleteLayout{Name: input.Name})
core.Option{Key: "task", Value: window.TaskDeleteLayout{Name: input.Name}}, if err != nil {
)) return nil, LayoutDeleteOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutDeleteOutput{}, e
}
return nil, LayoutDeleteOutput{}, nil
} }
return nil, LayoutDeleteOutput{Success: true}, nil return nil, LayoutDeleteOutput{Success: true}, nil
} }
@ -108,16 +91,13 @@ type LayoutGetOutput struct {
} }
func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input LayoutGetInput) (*mcp.CallToolResult, LayoutGetOutput, error) { func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input LayoutGetInput) (*mcp.CallToolResult, LayoutGetOutput, error) {
r := s.core.QUERY(window.QueryLayoutGet{Name: input.Name}) result, _, err := s.core.QUERY(window.QueryLayoutGet{Name: input.Name})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, LayoutGetOutput{}, err
return nil, LayoutGetOutput{}, e
} }
return nil, LayoutGetOutput{}, nil layout, ok := result.(*window.Layout)
}
layout, ok := r.Value.(*window.Layout)
if !ok { if !ok {
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil) return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query")
} }
return nil, LayoutGetOutput{Layout: layout}, nil return nil, LayoutGetOutput{Layout: layout}, nil
} }
@ -133,14 +113,9 @@ type LayoutTileOutput struct {
} }
func (s *Subsystem) layoutTile(_ context.Context, _ *mcp.CallToolRequest, input LayoutTileInput) (*mcp.CallToolResult, LayoutTileOutput, error) { func (s *Subsystem) layoutTile(_ context.Context, _ *mcp.CallToolRequest, input LayoutTileInput) (*mcp.CallToolResult, LayoutTileOutput, error) {
r := s.core.Action("window.tileWindows").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskTileWindows{Mode: input.Mode, Windows: input.Windows})
core.Option{Key: "task", Value: window.TaskTileWindows{Mode: input.Mode, Windows: input.Windows}}, if err != nil {
)) return nil, LayoutTileOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutTileOutput{}, e
}
return nil, LayoutTileOutput{}, nil
} }
return nil, LayoutTileOutput{Success: true}, nil return nil, LayoutTileOutput{Success: true}, nil
} }
@ -156,38 +131,154 @@ type LayoutSnapOutput struct {
} }
func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input LayoutSnapInput) (*mcp.CallToolResult, LayoutSnapOutput, error) { func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input LayoutSnapInput) (*mcp.CallToolResult, LayoutSnapOutput, error) {
r := s.core.Action("window.snapWindow").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSnapWindow{Name: input.Name, Position: input.Position})
core.Option{Key: "task", Value: window.TaskSnapWindow{Name: input.Name, Position: input.Position}}, if err != nil {
)) return nil, LayoutSnapOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutSnapOutput{}, e
}
return nil, LayoutSnapOutput{}, nil
} }
return nil, LayoutSnapOutput{Success: true}, nil return nil, LayoutSnapOutput{Success: true}, nil
} }
// --- layout_beside_editor ---
type LayoutBesideEditorInput struct {
Editor string `json:"editor,omitempty"`
Window string `json:"window,omitempty"`
}
type LayoutBesideEditorOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) {
_, _, err := s.core.PERFORM(window.TaskBesideEditor{Editor: input.Editor, Window: input.Window})
if err != nil {
return nil, LayoutBesideEditorOutput{}, err
}
return nil, LayoutBesideEditorOutput{Success: true}, nil
}
// --- layout_suggest ---
type LayoutSuggestInput struct {
WindowCount int `json:"windowCount,omitempty"`
ScreenWidth int `json:"screenWidth,omitempty"`
ScreenHeight int `json:"screenHeight,omitempty"`
}
type LayoutSuggestOutput struct {
Suggestion window.LayoutSuggestion `json:"suggestion"`
}
func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) {
windowCount := input.WindowCount
if windowCount <= 0 {
result, _, err := s.core.QUERY(window.QueryWindowList{})
if err != nil {
return nil, LayoutSuggestOutput{}, err
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, LayoutSuggestOutput{}, fmt.Errorf("unexpected result type from window list query")
}
windowCount = len(windows)
}
screenW, screenH := input.ScreenWidth, input.ScreenHeight
if screenW <= 0 || screenH <= 0 {
screenW, screenH = primaryScreenSize(s.core)
}
result, handled, err := s.core.QUERY(window.QueryLayoutSuggestion{
WindowCount: windowCount,
ScreenWidth: screenW,
ScreenHeight: screenH,
})
if err != nil {
return nil, LayoutSuggestOutput{}, err
}
if !handled {
return nil, LayoutSuggestOutput{}, fmt.Errorf("window service not available")
}
suggestion, ok := result.(window.LayoutSuggestion)
if !ok {
return nil, LayoutSuggestOutput{}, fmt.Errorf("unexpected result type from layout suggestion query")
}
return nil, LayoutSuggestOutput{Suggestion: suggestion}, nil
}
// --- screen_find_space ---
type ScreenFindSpaceInput struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type ScreenFindSpaceOutput struct {
Space window.SpaceInfo `json:"space"`
}
func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) {
screenW, screenH := primaryScreenSize(s.core)
if screenW <= 0 || screenH <= 0 {
screenW, screenH = 1920, 1080
}
result, handled, err := s.core.QUERY(window.QueryFindSpace{
Width: input.Width,
Height: input.Height,
ScreenWidth: screenW,
ScreenHeight: screenH,
})
if err != nil {
return nil, ScreenFindSpaceOutput{}, err
}
if !handled {
return nil, ScreenFindSpaceOutput{}, fmt.Errorf("window service not available")
}
space, ok := result.(window.SpaceInfo)
if !ok {
return nil, ScreenFindSpaceOutput{}, fmt.Errorf("unexpected result type from find space query")
}
if space.ScreenWidth == 0 {
space.ScreenWidth = screenW
}
if space.ScreenHeight == 0 {
space.ScreenHeight = screenH
}
return nil, ScreenFindSpaceOutput{Space: space}, nil
}
// --- window_arrange_pair ---
type WindowArrangePairInput struct {
First string `json:"first"`
Second string `json:"second"`
}
type WindowArrangePairOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) {
_, _, err := s.core.PERFORM(window.TaskArrangePair{First: input.First, Second: input.Second})
if err != nil {
return nil, WindowArrangePairOutput{}, err
}
return nil, WindowArrangePairOutput{Success: true}, nil
}
// --- layout_stack --- // --- layout_stack ---
type LayoutStackInput struct { type LayoutStackInput struct {
Windows []string `json:"windows,omitempty"` Windows []string `json:"windows,omitempty"`
OffsetX int `json:"offsetX"` OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY"` OffsetY int `json:"offsetY,omitempty"`
} }
type LayoutStackOutput struct { type LayoutStackOutput struct {
Success bool `json:"success"` Success bool `json:"success"`
} }
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) { func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
r := s.core.Action("window.stackWindows").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskStackWindows{
core.Option{Key: "task", Value: window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY}}, Windows: input.Windows,
)) OffsetX: input.OffsetX,
if !r.OK { OffsetY: input.OffsetY,
if e, ok := r.Value.(error); ok { })
return nil, LayoutStackOutput{}, e if err != nil {
} return nil, LayoutStackOutput{}, err
return nil, LayoutStackOutput{}, nil
} }
return nil, LayoutStackOutput{Success: true}, nil return nil, LayoutStackOutput{Success: true}, nil
} }
@ -203,14 +294,16 @@ type LayoutWorkflowOutput struct {
} }
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) { func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
r := s.core.Action("window.applyWorkflow").Run(context.Background(), core.NewOptions( workflow, ok := window.ParseWorkflowLayout(input.Workflow)
core.Option{Key: "task", Value: window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows}}, if !ok {
)) return nil, LayoutWorkflowOutput{}, fmt.Errorf("unknown workflow: %s", input.Workflow)
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, LayoutWorkflowOutput{}, e
} }
return nil, LayoutWorkflowOutput{}, nil _, _, err := s.core.PERFORM(window.TaskApplyWorkflow{
Workflow: workflow,
Windows: input.Windows,
})
if err != nil {
return nil, LayoutWorkflowOutput{}, err
} }
return nil, LayoutWorkflowOutput{Success: true}, nil return nil, LayoutWorkflowOutput{Success: true}, nil
} }
@ -225,6 +318,28 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet)
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor)
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal layout for the current screen"}, s.layoutSuggest)
mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty area for a new window"}, s.screenFindSpace)
mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side"}, s.windowArrangePair)
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Cascade windows with an offset"}, s.layoutStack)
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a predefined workflow layout"}, s.layoutWorkflow)
}
func primaryScreenSize(c *core.Core) (int, int) {
result, handled, err := c.QUERY(screen.QueryPrimary{})
if err == nil && handled {
if scr, ok := result.(*screen.Screen); ok && scr != nil {
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
return scr.WorkArea.Width, scr.WorkArea.Height
}
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
return scr.Bounds.Width, scr.Bounds.Height
}
if scr.Size.Width > 0 && scr.Size.Height > 0 {
return scr.Size.Width, scr.Size.Height
}
}
}
return 1920, 1080
} }

View file

@ -17,7 +17,10 @@ type AppQuitOutput struct {
func (s *Subsystem) appQuit(_ context.Context, _ *mcp.CallToolRequest, _ AppQuitInput) (*mcp.CallToolResult, AppQuitOutput, error) { func (s *Subsystem) appQuit(_ context.Context, _ *mcp.CallToolRequest, _ AppQuitInput) (*mcp.CallToolResult, AppQuitOutput, error) {
// Broadcast the will-terminate action which triggers application shutdown // Broadcast the will-terminate action which triggers application shutdown
_ = s.core.ACTION(lifecycle.ActionWillTerminate{}) err := s.core.ACTION(lifecycle.ActionWillTerminate{})
if err != nil {
return nil, AppQuitOutput{}, err
}
return nil, AppQuitOutput{Success: true}, nil return nil, AppQuitOutput{Success: true}, nil
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/notification" "forge.lthn.ai/core/gui/pkg/notification"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -22,22 +21,42 @@ type NotificationShowOutput struct {
} }
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) { func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
r := s.core.Action("notification.send").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title, Title: input.Title,
Message: input.Message, Message: input.Message,
Subtitle: input.Subtitle, Subtitle: input.Subtitle,
}}}, }})
)) if err != nil {
if !r.OK { return nil, NotificationShowOutput{}, err
if e, ok := r.Value.(error); ok {
return nil, NotificationShowOutput{}, e
}
return nil, NotificationShowOutput{}, nil
} }
return nil, NotificationShowOutput{Success: true}, nil return nil, NotificationShowOutput{Success: true}, nil
} }
// --- notification_with_actions ---
type NotificationWithActionsInput struct {
Title string `json:"title"`
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
Actions []notification.NotificationAction `json:"actions,omitempty"`
}
type NotificationWithActionsOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Subtitle: input.Subtitle,
Actions: input.Actions,
}})
if err != nil {
return nil, NotificationWithActionsOutput{}, err
}
return nil, NotificationWithActionsOutput{Success: true}, nil
}
// --- notification_permission_request --- // --- notification_permission_request ---
type NotificationPermissionRequestInput struct{} type NotificationPermissionRequestInput struct{}
@ -46,16 +65,13 @@ type NotificationPermissionRequestOutput struct {
} }
func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionRequestInput) (*mcp.CallToolResult, NotificationPermissionRequestOutput, error) { func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionRequestInput) (*mcp.CallToolResult, NotificationPermissionRequestOutput, error) {
r := s.core.Action("notification.requestPermission").Run(context.Background(), core.NewOptions()) result, _, err := s.core.PERFORM(notification.TaskRequestPermission{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, NotificationPermissionRequestOutput{}, err
return nil, NotificationPermissionRequestOutput{}, e
} }
return nil, NotificationPermissionRequestOutput{}, nil granted, ok := result.(bool)
}
granted, ok := r.Value.(bool)
if !ok { if !ok {
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type", nil) return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request")
} }
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
} }
@ -68,24 +84,71 @@ type NotificationPermissionCheckOutput struct {
} }
func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionCheckInput) (*mcp.CallToolResult, NotificationPermissionCheckOutput, error) { func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionCheckInput) (*mcp.CallToolResult, NotificationPermissionCheckOutput, error) {
r := s.core.QUERY(notification.QueryPermission{}) result, _, err := s.core.QUERY(notification.QueryPermission{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, NotificationPermissionCheckOutput{}, err
return nil, NotificationPermissionCheckOutput{}, e
} }
return nil, NotificationPermissionCheckOutput{}, nil status, ok := result.(notification.PermissionStatus)
}
status, ok := r.Value.(notification.PermissionStatus)
if !ok { if !ok {
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type", nil) return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check")
} }
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
} }
// --- notification_clear ---
type NotificationClearInput struct{}
type NotificationClearOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, _ NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskClear{})
if err != nil {
return nil, NotificationClearOutput{}, err
}
return nil, NotificationClearOutput{Success: true}, nil
}
// --- dialog_message ---
type DialogMessageInput struct {
Title string `json:"title"`
Message string `json:"message"`
Kind string `json:"kind,omitempty"`
}
type DialogMessageOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) {
var severity notification.NotificationSeverity
switch input.Kind {
case "warning":
severity = notification.SeverityWarning
case "error":
severity = notification.SeverityError
default:
severity = notification.SeverityInfo
}
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Severity: severity,
}})
if err != nil {
return nil, DialogMessageOutput{}, err
}
return nil, DialogMessageOutput{Success: true}, nil
}
// --- Registration --- // --- Registration ---
func (s *Subsystem) registerNotificationTools(server *mcp.Server) { func (s *Subsystem) registerNotificationTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow) mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow)
mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with actions"}, s.notificationWithActions)
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest)
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck)
mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear notifications when supported"}, s.notificationClear)
mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog using the notification pipeline"}, s.dialogMessage)
} }

View file

@ -3,10 +3,11 @@ package mcp
import ( import (
"context" "context"
"fmt"
coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -18,16 +19,13 @@ type ScreenListOutput struct {
} }
func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ ScreenListInput) (*mcp.CallToolResult, ScreenListOutput, error) { func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ ScreenListInput) (*mcp.CallToolResult, ScreenListOutput, error) {
r := s.core.QUERY(screen.QueryAll{}) result, _, err := s.core.QUERY(screen.QueryAll{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ScreenListOutput{}, err
return nil, ScreenListOutput{}, e
} }
return nil, ScreenListOutput{}, nil screens, ok := result.([]screen.Screen)
}
screens, ok := r.Value.([]screen.Screen)
if !ok { if !ok {
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil) return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query")
} }
return nil, ScreenListOutput{Screens: screens}, nil return nil, ScreenListOutput{Screens: screens}, nil
} }
@ -42,16 +40,13 @@ type ScreenGetOutput struct {
} }
func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input ScreenGetInput) (*mcp.CallToolResult, ScreenGetOutput, error) { func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input ScreenGetInput) (*mcp.CallToolResult, ScreenGetOutput, error) {
r := s.core.QUERY(screen.QueryByID{ID: input.ID}) result, _, err := s.core.QUERY(screen.QueryByID{ID: input.ID})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ScreenGetOutput{}, err
return nil, ScreenGetOutput{}, e
} }
return nil, ScreenGetOutput{}, nil scr, ok := result.(*screen.Screen)
}
scr, ok := r.Value.(*screen.Screen)
if !ok { if !ok {
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil) return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query")
} }
return nil, ScreenGetOutput{Screen: scr}, nil return nil, ScreenGetOutput{Screen: scr}, nil
} }
@ -64,16 +59,13 @@ type ScreenPrimaryOutput struct {
} }
func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ ScreenPrimaryInput) (*mcp.CallToolResult, ScreenPrimaryOutput, error) { func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ ScreenPrimaryInput) (*mcp.CallToolResult, ScreenPrimaryOutput, error) {
r := s.core.QUERY(screen.QueryPrimary{}) result, _, err := s.core.QUERY(screen.QueryPrimary{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ScreenPrimaryOutput{}, err
return nil, ScreenPrimaryOutput{}, e
} }
return nil, ScreenPrimaryOutput{}, nil scr, ok := result.(*screen.Screen)
}
scr, ok := r.Value.(*screen.Screen)
if !ok { if !ok {
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil) return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query")
} }
return nil, ScreenPrimaryOutput{Screen: scr}, nil return nil, ScreenPrimaryOutput{Screen: scr}, nil
} }
@ -89,16 +81,13 @@ type ScreenAtPointOutput struct {
} }
func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, input ScreenAtPointInput) (*mcp.CallToolResult, ScreenAtPointOutput, error) { func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, input ScreenAtPointInput) (*mcp.CallToolResult, ScreenAtPointOutput, error) {
r := s.core.QUERY(screen.QueryAtPoint{X: input.X, Y: input.Y}) result, _, err := s.core.QUERY(screen.QueryAtPoint{X: input.X, Y: input.Y})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ScreenAtPointOutput{}, err
return nil, ScreenAtPointOutput{}, e
} }
return nil, ScreenAtPointOutput{}, nil scr, ok := result.(*screen.Screen)
}
scr, ok := r.Value.(*screen.Screen)
if !ok { if !ok {
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil) return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query")
} }
return nil, ScreenAtPointOutput{Screen: scr}, nil return nil, ScreenAtPointOutput{Screen: scr}, nil
} }
@ -111,45 +100,41 @@ type ScreenWorkAreasOutput struct {
} }
func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) { func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
r := s.core.QUERY(screen.QueryWorkAreas{}) result, _, err := s.core.QUERY(screen.QueryWorkAreas{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, ScreenWorkAreasOutput{}, err
return nil, ScreenWorkAreasOutput{}, e
} }
return nil, ScreenWorkAreasOutput{}, nil areas, ok := result.([]screen.Rect)
}
areas, ok := r.Value.([]screen.Rect)
if !ok { if !ok {
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil) return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query")
} }
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
} }
// --- screen_work_area ---
func (s *Subsystem) screenWorkArea(ctx context.Context, req *mcp.CallToolRequest, input ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
return s.screenWorkAreas(ctx, req, input)
}
// --- screen_for_window --- // --- screen_for_window ---
type ScreenForWindowInput struct { type ScreenForWindowInput struct {
Name string `json:"name"` Window string `json:"window"`
} }
type ScreenForWindowOutput struct { type ScreenForWindowOutput struct {
Screen *screen.Screen `json:"screen"` Screen *screen.Screen `json:"screen"`
} }
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) { func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) svc, err := core.ServiceFor[*display.Service](s.core, "display")
if !r.OK { if err != nil {
return nil, ScreenForWindowOutput{}, nil return nil, ScreenForWindowOutput{}, err
} }
info, _ := r.Value.(*window.WindowInfo) scr, err := svc.GetScreenForWindow(input.Window)
if info == nil { if err != nil {
return nil, ScreenForWindowOutput{}, nil return nil, ScreenForWindowOutput{}, err
} }
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
r2 := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY})
if !r2.OK {
return nil, ScreenForWindowOutput{}, nil
}
scr, _ := r2.Value.(*screen.Screen)
return nil, ScreenForWindowOutput{Screen: scr}, nil return nil, ScreenForWindowOutput{Screen: scr}, nil
} }
@ -161,5 +146,6 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Alias for screen_work_areas"}, s.screenWorkArea)
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/systray" "forge.lthn.ai/core/gui/pkg/systray"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -20,14 +19,9 @@ type TraySetIconOutput struct {
} }
func (s *Subsystem) traySetIcon(_ context.Context, _ *mcp.CallToolRequest, input TraySetIconInput) (*mcp.CallToolResult, TraySetIconOutput, error) { func (s *Subsystem) traySetIcon(_ context.Context, _ *mcp.CallToolRequest, input TraySetIconInput) (*mcp.CallToolResult, TraySetIconOutput, error) {
r := s.core.Action("systray.setIcon").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(systray.TaskSetTrayIcon{Data: input.Data})
core.Option{Key: "task", Value: systray.TaskSetTrayIcon{Data: input.Data}}, if err != nil {
)) return nil, TraySetIconOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, TraySetIconOutput{}, e
}
return nil, TraySetIconOutput{}, nil
} }
return nil, TraySetIconOutput{Success: true}, nil return nil, TraySetIconOutput{Success: true}, nil
} }
@ -42,8 +36,10 @@ type TraySetTooltipOutput struct {
} }
func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) { func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) {
// Tooltip is set via the tray menu items; for now this is a no-op placeholder _, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip})
_ = input.Tooltip if err != nil {
return nil, TraySetTooltipOutput{}, err
}
return nil, TraySetTooltipOutput{Success: true}, nil return nil, TraySetTooltipOutput{Success: true}, nil
} }
@ -57,8 +53,10 @@ type TraySetLabelOutput struct {
} }
func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) { func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) {
// Label is part of the tray configuration; placeholder for now _, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label})
_ = input.Label if err != nil {
return nil, TraySetLabelOutput{}, err
}
return nil, TraySetLabelOutput{Success: true}, nil return nil, TraySetLabelOutput{Success: true}, nil
} }
@ -70,20 +68,35 @@ type TrayInfoOutput struct {
} }
func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayInfoInput) (*mcp.CallToolResult, TrayInfoOutput, error) { func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayInfoInput) (*mcp.CallToolResult, TrayInfoOutput, error) {
r := s.core.QUERY(systray.QueryConfig{}) result, _, err := s.core.QUERY(systray.QueryConfig{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, TrayInfoOutput{}, err
return nil, TrayInfoOutput{}, e
} }
return nil, TrayInfoOutput{}, nil config, ok := result.(map[string]any)
}
config, ok := r.Value.(map[string]any)
if !ok { if !ok {
return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil) return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query")
} }
return nil, TrayInfoOutput{Config: config}, nil return nil, TrayInfoOutput{Config: config}, nil
} }
// --- tray_show_message ---
type TrayShowMessageInput struct {
Title string `json:"title"`
Message string `json:"message"`
}
type TrayShowMessageOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) {
_, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message})
if err != nil {
return nil, TrayShowMessageOutput{}, err
}
return nil, TrayShowMessageOutput{Success: true}, nil
}
// --- Registration --- // --- Registration ---
func (s *Subsystem) registerTrayTools(server *mcp.Server) { func (s *Subsystem) registerTrayTools(server *mcp.Server) {
@ -91,4 +104,5 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip)
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel)
mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo) mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo)
mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage)
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/webview" "forge.lthn.ai/core/gui/pkg/webview"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -23,16 +22,11 @@ type WebviewEvalOutput struct {
} }
func (s *Subsystem) webviewEval(_ context.Context, _ *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) { func (s *Subsystem) webviewEval(_ context.Context, _ *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) {
r := s.core.Action("webview.evaluate").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(webview.TaskEvaluate{Window: input.Window, Script: input.Script})
core.Option{Key: "task", Value: webview.TaskEvaluate{Window: input.Window, Script: input.Script}}, if err != nil {
)) return nil, WebviewEvalOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewEvalOutput{}, e
} }
return nil, WebviewEvalOutput{}, nil return nil, WebviewEvalOutput{Result: result, Window: input.Window}, nil
}
return nil, WebviewEvalOutput{Result: r.Value, Window: input.Window}, nil
} }
// --- webview_click --- // --- webview_click ---
@ -47,14 +41,9 @@ type WebviewClickOutput struct {
} }
func (s *Subsystem) webviewClick(_ context.Context, _ *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, error) { func (s *Subsystem) webviewClick(_ context.Context, _ *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, error) {
r := s.core.Action("webview.click").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskClick{Window: input.Window, Selector: input.Selector})
core.Option{Key: "task", Value: webview.TaskClick{Window: input.Window, Selector: input.Selector}}, if err != nil {
)) return nil, WebviewClickOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewClickOutput{}, e
}
return nil, WebviewClickOutput{}, nil
} }
return nil, WebviewClickOutput{Success: true}, nil return nil, WebviewClickOutput{Success: true}, nil
} }
@ -72,14 +61,9 @@ type WebviewTypeOutput struct {
} }
func (s *Subsystem) webviewType(_ context.Context, _ *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, error) { func (s *Subsystem) webviewType(_ context.Context, _ *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, error) {
r := s.core.Action("webview.type").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskType{Window: input.Window, Selector: input.Selector, Text: input.Text})
core.Option{Key: "task", Value: webview.TaskType{Window: input.Window, Selector: input.Selector, Text: input.Text}}, if err != nil {
)) return nil, WebviewTypeOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewTypeOutput{}, e
}
return nil, WebviewTypeOutput{}, nil
} }
return nil, WebviewTypeOutput{Success: true}, nil return nil, WebviewTypeOutput{Success: true}, nil
} }
@ -96,14 +80,9 @@ type WebviewNavigateOutput struct {
} }
func (s *Subsystem) webviewNavigate(_ context.Context, _ *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, error) { func (s *Subsystem) webviewNavigate(_ context.Context, _ *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, error) {
r := s.core.Action("webview.navigate").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskNavigate{Window: input.Window, URL: input.URL})
core.Option{Key: "task", Value: webview.TaskNavigate{Window: input.Window, URL: input.URL}}, if err != nil {
)) return nil, WebviewNavigateOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewNavigateOutput{}, e
}
return nil, WebviewNavigateOutput{}, nil
} }
return nil, WebviewNavigateOutput{Success: true}, nil return nil, WebviewNavigateOutput{Success: true}, nil
} }
@ -120,22 +99,41 @@ type WebviewScreenshotOutput struct {
} }
func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, error) { func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, error) {
r := s.core.Action("webview.screenshot").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(webview.TaskScreenshot{Window: input.Window})
core.Option{Key: "task", Value: webview.TaskScreenshot{Window: input.Window}}, if err != nil {
)) return nil, WebviewScreenshotOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewScreenshotOutput{}, e
} }
return nil, WebviewScreenshotOutput{}, nil sr, ok := result.(webview.ScreenshotResult)
}
sr, ok := r.Value.(webview.ScreenshotResult)
if !ok { if !ok {
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil) return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot")
} }
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
} }
// --- webview_screenshot_element ---
type WebviewScreenshotElementInput struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
type WebviewScreenshotElementOutput struct {
Base64 string `json:"base64"`
MimeType string `json:"mimeType"`
}
func (s *Subsystem) webviewScreenshotElement(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotElementInput) (*mcp.CallToolResult, WebviewScreenshotElementOutput, error) {
result, _, err := s.core.PERFORM(webview.TaskScreenshotElement{Window: input.Window, Selector: input.Selector})
if err != nil {
return nil, WebviewScreenshotElementOutput{}, err
}
sr, ok := result.(webview.ScreenshotResult)
if !ok {
return nil, WebviewScreenshotElementOutput{}, fmt.Errorf("unexpected result type from webview element screenshot")
}
return nil, WebviewScreenshotElementOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
}
// --- webview_scroll --- // --- webview_scroll ---
type WebviewScrollInput struct { type WebviewScrollInput struct {
@ -149,14 +147,9 @@ type WebviewScrollOutput struct {
} }
func (s *Subsystem) webviewScroll(_ context.Context, _ *mcp.CallToolRequest, input WebviewScrollInput) (*mcp.CallToolResult, WebviewScrollOutput, error) { func (s *Subsystem) webviewScroll(_ context.Context, _ *mcp.CallToolRequest, input WebviewScrollInput) (*mcp.CallToolResult, WebviewScrollOutput, error) {
r := s.core.Action("webview.scroll").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskScroll{Window: input.Window, X: input.X, Y: input.Y})
core.Option{Key: "task", Value: webview.TaskScroll{Window: input.Window, X: input.X, Y: input.Y}}, if err != nil {
)) return nil, WebviewScrollOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewScrollOutput{}, e
}
return nil, WebviewScrollOutput{}, nil
} }
return nil, WebviewScrollOutput{Success: true}, nil return nil, WebviewScrollOutput{Success: true}, nil
} }
@ -173,14 +166,9 @@ type WebviewHoverOutput struct {
} }
func (s *Subsystem) webviewHover(_ context.Context, _ *mcp.CallToolRequest, input WebviewHoverInput) (*mcp.CallToolResult, WebviewHoverOutput, error) { func (s *Subsystem) webviewHover(_ context.Context, _ *mcp.CallToolRequest, input WebviewHoverInput) (*mcp.CallToolResult, WebviewHoverOutput, error) {
r := s.core.Action("webview.hover").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskHover{Window: input.Window, Selector: input.Selector})
core.Option{Key: "task", Value: webview.TaskHover{Window: input.Window, Selector: input.Selector}}, if err != nil {
)) return nil, WebviewHoverOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewHoverOutput{}, e
}
return nil, WebviewHoverOutput{}, nil
} }
return nil, WebviewHoverOutput{Success: true}, nil return nil, WebviewHoverOutput{Success: true}, nil
} }
@ -198,14 +186,9 @@ type WebviewSelectOutput struct {
} }
func (s *Subsystem) webviewSelect(_ context.Context, _ *mcp.CallToolRequest, input WebviewSelectInput) (*mcp.CallToolResult, WebviewSelectOutput, error) { func (s *Subsystem) webviewSelect(_ context.Context, _ *mcp.CallToolRequest, input WebviewSelectInput) (*mcp.CallToolResult, WebviewSelectOutput, error) {
r := s.core.Action("webview.select").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskSelect{Window: input.Window, Selector: input.Selector, Value: input.Value})
core.Option{Key: "task", Value: webview.TaskSelect{Window: input.Window, Selector: input.Selector, Value: input.Value}}, if err != nil {
)) return nil, WebviewSelectOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewSelectOutput{}, e
}
return nil, WebviewSelectOutput{}, nil
} }
return nil, WebviewSelectOutput{Success: true}, nil return nil, WebviewSelectOutput{Success: true}, nil
} }
@ -223,14 +206,9 @@ type WebviewCheckOutput struct {
} }
func (s *Subsystem) webviewCheck(_ context.Context, _ *mcp.CallToolRequest, input WebviewCheckInput) (*mcp.CallToolResult, WebviewCheckOutput, error) { func (s *Subsystem) webviewCheck(_ context.Context, _ *mcp.CallToolRequest, input WebviewCheckInput) (*mcp.CallToolResult, WebviewCheckOutput, error) {
r := s.core.Action("webview.check").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskCheck{Window: input.Window, Selector: input.Selector, Checked: input.Checked})
core.Option{Key: "task", Value: webview.TaskCheck{Window: input.Window, Selector: input.Selector, Checked: input.Checked}}, if err != nil {
)) return nil, WebviewCheckOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewCheckOutput{}, e
}
return nil, WebviewCheckOutput{}, nil
} }
return nil, WebviewCheckOutput{Success: true}, nil return nil, WebviewCheckOutput{Success: true}, nil
} }
@ -248,14 +226,9 @@ type WebviewUploadOutput struct {
} }
func (s *Subsystem) webviewUpload(_ context.Context, _ *mcp.CallToolRequest, input WebviewUploadInput) (*mcp.CallToolResult, WebviewUploadOutput, error) { func (s *Subsystem) webviewUpload(_ context.Context, _ *mcp.CallToolRequest, input WebviewUploadInput) (*mcp.CallToolResult, WebviewUploadOutput, error) {
r := s.core.Action("webview.uploadFile").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskUploadFile{Window: input.Window, Selector: input.Selector, Paths: input.Paths})
core.Option{Key: "task", Value: webview.TaskUploadFile{Window: input.Window, Selector: input.Selector, Paths: input.Paths}}, if err != nil {
)) return nil, WebviewUploadOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewUploadOutput{}, e
}
return nil, WebviewUploadOutput{}, nil
} }
return nil, WebviewUploadOutput{Success: true}, nil return nil, WebviewUploadOutput{Success: true}, nil
} }
@ -273,14 +246,9 @@ type WebviewViewportOutput struct {
} }
func (s *Subsystem) webviewViewport(_ context.Context, _ *mcp.CallToolRequest, input WebviewViewportInput) (*mcp.CallToolResult, WebviewViewportOutput, error) { func (s *Subsystem) webviewViewport(_ context.Context, _ *mcp.CallToolRequest, input WebviewViewportInput) (*mcp.CallToolResult, WebviewViewportOutput, error) {
r := s.core.Action("webview.setViewport").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskSetViewport{Window: input.Window, Width: input.Width, Height: input.Height})
core.Option{Key: "task", Value: webview.TaskSetViewport{Window: input.Window, Width: input.Width, Height: input.Height}}, if err != nil {
)) return nil, WebviewViewportOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewViewportOutput{}, e
}
return nil, WebviewViewportOutput{}, nil
} }
return nil, WebviewViewportOutput{Success: true}, nil return nil, WebviewViewportOutput{Success: true}, nil
} }
@ -298,16 +266,13 @@ type WebviewConsoleOutput struct {
} }
func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, error) { func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, error) {
r := s.core.QUERY(webview.QueryConsole{Window: input.Window, Level: input.Level, Limit: input.Limit}) result, _, err := s.core.QUERY(webview.QueryConsole{Window: input.Window, Level: input.Level, Limit: input.Limit})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewConsoleOutput{}, err
return nil, WebviewConsoleOutput{}, e
} }
return nil, WebviewConsoleOutput{}, nil msgs, ok := result.([]webview.ConsoleMessage)
}
msgs, ok := r.Value.([]webview.ConsoleMessage)
if !ok { if !ok {
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil) return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query")
} }
return nil, WebviewConsoleOutput{Messages: msgs}, nil return nil, WebviewConsoleOutput{Messages: msgs}, nil
} }
@ -323,18 +288,70 @@ type WebviewConsoleClearOutput struct {
} }
func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleClearInput) (*mcp.CallToolResult, WebviewConsoleClearOutput, error) { func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleClearInput) (*mcp.CallToolResult, WebviewConsoleClearOutput, error) {
r := s.core.Action("webview.clearConsole").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(webview.TaskClearConsole{Window: input.Window})
core.Option{Key: "task", Value: webview.TaskClearConsole{Window: input.Window}}, if err != nil {
)) return nil, WebviewConsoleClearOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WebviewConsoleClearOutput{}, e
}
return nil, WebviewConsoleClearOutput{}, nil
} }
return nil, WebviewConsoleClearOutput{Success: true}, nil return nil, WebviewConsoleClearOutput{Success: true}, nil
} }
// --- webview_errors ---
type WebviewErrorsInput struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
type WebviewErrorsOutput struct {
Errors []webview.ExceptionInfo `json:"errors"`
}
func (s *Subsystem) webviewErrors(_ context.Context, _ *mcp.CallToolRequest, input WebviewErrorsInput) (*mcp.CallToolResult, WebviewErrorsOutput, error) {
result, _, err := s.core.QUERY(webview.QueryExceptions{Window: input.Window, Limit: input.Limit})
if err != nil {
return nil, WebviewErrorsOutput{}, err
}
errors, ok := result.([]webview.ExceptionInfo)
if !ok {
return nil, WebviewErrorsOutput{}, fmt.Errorf("unexpected result type from webview errors query")
}
return nil, WebviewErrorsOutput{Errors: errors}, nil
}
// --- webview_devtools_open ---
type WebviewDevToolsOpenInput struct {
Window string `json:"window"`
}
type WebviewDevToolsOpenOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewDevToolsOpen(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsOpenInput) (*mcp.CallToolResult, WebviewDevToolsOpenOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskOpenDevTools{Window: input.Window})
if err != nil {
return nil, WebviewDevToolsOpenOutput{}, err
}
return nil, WebviewDevToolsOpenOutput{Success: true}, nil
}
// --- webview_devtools_close ---
type WebviewDevToolsCloseInput struct {
Window string `json:"window"`
}
type WebviewDevToolsCloseOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewDevToolsClose(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsCloseInput) (*mcp.CallToolResult, WebviewDevToolsCloseOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskCloseDevTools{Window: input.Window})
if err != nil {
return nil, WebviewDevToolsCloseOutput{}, err
}
return nil, WebviewDevToolsCloseOutput{Success: true}, nil
}
// --- webview_query --- // --- webview_query ---
type WebviewQueryInput struct { type WebviewQueryInput struct {
@ -347,20 +364,23 @@ type WebviewQueryOutput struct {
} }
func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) { func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
r := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector}) result, _, err := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewQueryOutput{}, err
return nil, WebviewQueryOutput{}, e
} }
return nil, WebviewQueryOutput{}, nil el, ok := result.(*webview.ElementInfo)
}
el, ok := r.Value.(*webview.ElementInfo)
if !ok { if !ok {
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil) return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query")
} }
return nil, WebviewQueryOutput{Element: el}, nil return nil, WebviewQueryOutput{Element: el}, nil
} }
// --- webview_element_info ---
func (s *Subsystem) webviewElementInfo(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
return s.webviewQuery(nil, nil, input)
}
// --- webview_query_all --- // --- webview_query_all ---
type WebviewQueryAllInput struct { type WebviewQueryAllInput struct {
@ -373,16 +393,13 @@ type WebviewQueryAllOutput struct {
} }
func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryAllInput) (*mcp.CallToolResult, WebviewQueryAllOutput, error) { func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryAllInput) (*mcp.CallToolResult, WebviewQueryAllOutput, error) {
r := s.core.QUERY(webview.QuerySelectorAll{Window: input.Window, Selector: input.Selector}) result, _, err := s.core.QUERY(webview.QuerySelectorAll{Window: input.Window, Selector: input.Selector})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewQueryAllOutput{}, err
return nil, WebviewQueryAllOutput{}, e
} }
return nil, WebviewQueryAllOutput{}, nil els, ok := result.([]*webview.ElementInfo)
}
els, ok := r.Value.([]*webview.ElementInfo)
if !ok { if !ok {
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil) return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all")
} }
return nil, WebviewQueryAllOutput{Elements: els}, nil return nil, WebviewQueryAllOutput{Elements: els}, nil
} }
@ -399,20 +416,210 @@ type WebviewDOMTreeOutput struct {
} }
func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) { func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) {
r := s.core.QUERY(webview.QueryDOMTree{Window: input.Window, Selector: input.Selector}) result, _, err := s.core.QUERY(webview.QueryDOMTree{Window: input.Window, Selector: input.Selector})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewDOMTreeOutput{}, err
return nil, WebviewDOMTreeOutput{}, e
} }
return nil, WebviewDOMTreeOutput{}, nil html, ok := result.(string)
}
html, ok := r.Value.(string)
if !ok { if !ok {
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil) return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query")
} }
return nil, WebviewDOMTreeOutput{HTML: html}, nil return nil, WebviewDOMTreeOutput{HTML: html}, nil
} }
// --- webview_source ---
func (s *Subsystem) webviewSource(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) {
return s.webviewDOMTree(nil, nil, input)
}
// --- webview_computed_style ---
type WebviewComputedStyleInput struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
type WebviewComputedStyleOutput struct {
Style map[string]string `json:"style"`
}
func (s *Subsystem) webviewComputedStyle(_ context.Context, _ *mcp.CallToolRequest, input WebviewComputedStyleInput) (*mcp.CallToolResult, WebviewComputedStyleOutput, error) {
result, _, err := s.core.QUERY(webview.QueryComputedStyle{Window: input.Window, Selector: input.Selector})
if err != nil {
return nil, WebviewComputedStyleOutput{}, err
}
style, ok := result.(map[string]string)
if !ok {
return nil, WebviewComputedStyleOutput{}, fmt.Errorf("unexpected result type from webview computed style query")
}
return nil, WebviewComputedStyleOutput{Style: style}, nil
}
// --- webview_performance ---
type WebviewPerformanceInput struct {
Window string `json:"window"`
}
type WebviewPerformanceOutput struct {
Metrics webview.PerformanceMetrics `json:"metrics"`
}
func (s *Subsystem) webviewPerformance(_ context.Context, _ *mcp.CallToolRequest, input WebviewPerformanceInput) (*mcp.CallToolResult, WebviewPerformanceOutput, error) {
result, _, err := s.core.QUERY(webview.QueryPerformance{Window: input.Window})
if err != nil {
return nil, WebviewPerformanceOutput{}, err
}
metrics, ok := result.(webview.PerformanceMetrics)
if !ok {
return nil, WebviewPerformanceOutput{}, fmt.Errorf("unexpected result type from webview performance query")
}
return nil, WebviewPerformanceOutput{Metrics: metrics}, nil
}
// --- webview_resources ---
type WebviewResourcesInput struct {
Window string `json:"window"`
}
type WebviewResourcesOutput struct {
Resources []webview.ResourceEntry `json:"resources"`
}
func (s *Subsystem) webviewResources(_ context.Context, _ *mcp.CallToolRequest, input WebviewResourcesInput) (*mcp.CallToolResult, WebviewResourcesOutput, error) {
result, _, err := s.core.QUERY(webview.QueryResources{Window: input.Window})
if err != nil {
return nil, WebviewResourcesOutput{}, err
}
resources, ok := result.([]webview.ResourceEntry)
if !ok {
return nil, WebviewResourcesOutput{}, fmt.Errorf("unexpected result type from webview resources query")
}
return nil, WebviewResourcesOutput{Resources: resources}, nil
}
// --- webview_network ---
type WebviewNetworkInput struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
type WebviewNetworkOutput struct {
Requests []webview.NetworkEntry `json:"requests"`
}
func (s *Subsystem) webviewNetwork(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInput) (*mcp.CallToolResult, WebviewNetworkOutput, error) {
result, _, err := s.core.QUERY(webview.QueryNetwork{Window: input.Window, Limit: input.Limit})
if err != nil {
return nil, WebviewNetworkOutput{}, err
}
requests, ok := result.([]webview.NetworkEntry)
if !ok {
return nil, WebviewNetworkOutput{}, fmt.Errorf("unexpected result type from webview network query")
}
return nil, WebviewNetworkOutput{Requests: requests}, nil
}
// --- webview_network_inject ---
type WebviewNetworkInjectInput struct {
Window string `json:"window"`
}
type WebviewNetworkInjectOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewNetworkInject(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInjectInput) (*mcp.CallToolResult, WebviewNetworkInjectOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskInjectNetworkLogging{Window: input.Window})
if err != nil {
return nil, WebviewNetworkInjectOutput{}, err
}
return nil, WebviewNetworkInjectOutput{Success: true}, nil
}
// --- webview_network_clear ---
type WebviewNetworkClearInput struct {
Window string `json:"window"`
}
type WebviewNetworkClearOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewNetworkClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkClearInput) (*mcp.CallToolResult, WebviewNetworkClearOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskClearNetworkLog{Window: input.Window})
if err != nil {
return nil, WebviewNetworkClearOutput{}, err
}
return nil, WebviewNetworkClearOutput{Success: true}, nil
}
// --- webview_highlight ---
type WebviewHighlightInput struct {
Window string `json:"window"`
Selector string `json:"selector"`
Colour string `json:"colour,omitempty"`
}
type WebviewHighlightOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewHighlight(_ context.Context, _ *mcp.CallToolRequest, input WebviewHighlightInput) (*mcp.CallToolResult, WebviewHighlightOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskHighlight{Window: input.Window, Selector: input.Selector, Colour: input.Colour})
if err != nil {
return nil, WebviewHighlightOutput{}, err
}
return nil, WebviewHighlightOutput{Success: true}, nil
}
// --- webview_print ---
type WebviewPrintInput struct {
Window string `json:"window"`
}
type WebviewPrintOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewPrint(_ context.Context, _ *mcp.CallToolRequest, input WebviewPrintInput) (*mcp.CallToolResult, WebviewPrintOutput, error) {
_, _, err := s.core.PERFORM(webview.TaskPrint{Window: input.Window})
if err != nil {
return nil, WebviewPrintOutput{}, err
}
return nil, WebviewPrintOutput{Success: true}, nil
}
// --- webview_pdf ---
type WebviewPDFInput struct {
Window string `json:"window"`
}
type WebviewPDFOutput struct {
Base64 string `json:"base64"`
MimeType string `json:"mimeType"`
}
func (s *Subsystem) webviewPDF(_ context.Context, _ *mcp.CallToolRequest, input WebviewPDFInput) (*mcp.CallToolResult, WebviewPDFOutput, error) {
result, _, err := s.core.PERFORM(webview.TaskExportPDF{Window: input.Window})
if err != nil {
return nil, WebviewPDFOutput{}, err
}
pdf, ok := result.(webview.PDFResult)
if !ok {
return nil, WebviewPDFOutput{}, fmt.Errorf("unexpected result type from webview pdf task")
}
return nil, WebviewPDFOutput{Base64: pdf.Base64, MimeType: pdf.MimeType}, nil
}
// --- webview_url --- // --- webview_url ---
type WebviewURLInput struct { type WebviewURLInput struct {
@ -424,16 +631,13 @@ type WebviewURLOutput struct {
} }
func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input WebviewURLInput) (*mcp.CallToolResult, WebviewURLOutput, error) { func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input WebviewURLInput) (*mcp.CallToolResult, WebviewURLOutput, error) {
r := s.core.QUERY(webview.QueryURL{Window: input.Window}) result, _, err := s.core.QUERY(webview.QueryURL{Window: input.Window})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewURLOutput{}, err
return nil, WebviewURLOutput{}, e
} }
return nil, WebviewURLOutput{}, nil url, ok := result.(string)
}
url, ok := r.Value.(string)
if !ok { if !ok {
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil) return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query")
} }
return nil, WebviewURLOutput{URL: url}, nil return nil, WebviewURLOutput{URL: url}, nil
} }
@ -449,16 +653,13 @@ type WebviewTitleOutput struct {
} }
func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, input WebviewTitleInput) (*mcp.CallToolResult, WebviewTitleOutput, error) { func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, input WebviewTitleInput) (*mcp.CallToolResult, WebviewTitleOutput, error) {
r := s.core.QUERY(webview.QueryTitle{Window: input.Window}) result, _, err := s.core.QUERY(webview.QueryTitle{Window: input.Window})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WebviewTitleOutput{}, err
return nil, WebviewTitleOutput{}, e
} }
return nil, WebviewTitleOutput{}, nil title, ok := result.(string)
}
title, ok := r.Value.(string)
if !ok { if !ok {
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil) return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query")
} }
return nil, WebviewTitleOutput{Title: title}, nil return nil, WebviewTitleOutput{Title: title}, nil
} }
@ -471,6 +672,7 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "webview_type", Description: "Type text into an element in a webview"}, s.webviewType) mcp.AddTool(server, &mcp.Tool{Name: "webview_type", Description: "Type text into an element in a webview"}, s.webviewType)
mcp.AddTool(server, &mcp.Tool{Name: "webview_navigate", Description: "Navigate a webview to a URL"}, s.webviewNavigate) mcp.AddTool(server, &mcp.Tool{Name: "webview_navigate", Description: "Navigate a webview to a URL"}, s.webviewNavigate)
mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot", Description: "Capture a webview screenshot as base64 PNG"}, s.webviewScreenshot) mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot", Description: "Capture a webview screenshot as base64 PNG"}, s.webviewScreenshot)
mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot_element", Description: "Capture a specific element as base64 PNG"}, s.webviewScreenshotElement)
mcp.AddTool(server, &mcp.Tool{Name: "webview_scroll", Description: "Scroll a webview to an absolute position"}, s.webviewScroll) mcp.AddTool(server, &mcp.Tool{Name: "webview_scroll", Description: "Scroll a webview to an absolute position"}, s.webviewScroll)
mcp.AddTool(server, &mcp.Tool{Name: "webview_hover", Description: "Hover over an element in a webview"}, s.webviewHover) mcp.AddTool(server, &mcp.Tool{Name: "webview_hover", Description: "Hover over an element in a webview"}, s.webviewHover)
mcp.AddTool(server, &mcp.Tool{Name: "webview_select", Description: "Select an option in a select element"}, s.webviewSelect) mcp.AddTool(server, &mcp.Tool{Name: "webview_select", Description: "Select an option in a select element"}, s.webviewSelect)
@ -479,9 +681,24 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "webview_viewport", Description: "Set the webview viewport dimensions"}, s.webviewViewport) mcp.AddTool(server, &mcp.Tool{Name: "webview_viewport", Description: "Set the webview viewport dimensions"}, s.webviewViewport)
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole) mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear) mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear)
mcp.AddTool(server, &mcp.Tool{Name: "webview_errors", Description: "Get captured JavaScript exceptions from a webview"}, s.webviewErrors)
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery) mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo)
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll) mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)
mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree) mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree)
mcp.AddTool(server, &mcp.Tool{Name: "webview_source", Description: "Get page HTML source"}, s.webviewSource)
mcp.AddTool(server, &mcp.Tool{Name: "webview_computed_style", Description: "Get computed styles for an element"}, s.webviewComputedStyle)
mcp.AddTool(server, &mcp.Tool{Name: "webview_performance", Description: "Get page performance metrics"}, s.webviewPerformance)
mcp.AddTool(server, &mcp.Tool{Name: "webview_resources", Description: "List loaded page resources"}, s.webviewResources)
mcp.AddTool(server, &mcp.Tool{Name: "webview_network", Description: "Get captured network requests"}, s.webviewNetwork)
mcp.AddTool(server, &mcp.Tool{Name: "webview_network_inject", Description: "Inject fetch/XHR network logging"}, s.webviewNetworkInject)
mcp.AddTool(server, &mcp.Tool{Name: "webview_network_clear", Description: "Clear captured network requests"}, s.webviewNetworkClear)
mcp.AddTool(server, &mcp.Tool{Name: "webview_highlight", Description: "Visually highlight an element"}, s.webviewHighlight)
mcp.AddTool(server, &mcp.Tool{Name: "webview_print", Description: "Open the browser print dialog"}, s.webviewPrint)
mcp.AddTool(server, &mcp.Tool{Name: "webview_pdf", Description: "Export the current page as a PDF"}, s.webviewPDF)
mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL) mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL)
mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle) mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle)
mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open devtools for a webview window"}, s.webviewDevToolsOpen)
mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_close", Description: "Close devtools for a webview window"}, s.webviewDevToolsClose)
} }

View file

@ -3,9 +3,8 @@ package mcp
import ( import (
"context" "context"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/window" "forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -18,16 +17,13 @@ type WindowListOutput struct {
} }
func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ WindowListInput) (*mcp.CallToolResult, WindowListOutput, error) { func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ WindowListInput) (*mcp.CallToolResult, WindowListOutput, error) {
r := s.core.QUERY(window.QueryWindowList{}) result, _, err := s.core.QUERY(window.QueryWindowList{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WindowListOutput{}, err
return nil, WindowListOutput{}, e
} }
return nil, WindowListOutput{}, nil windows, ok := result.([]window.WindowInfo)
}
windows, ok := r.Value.([]window.WindowInfo)
if !ok { if !ok {
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type", nil) return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query")
} }
return nil, WindowListOutput{Windows: windows}, nil return nil, WindowListOutput{Windows: windows}, nil
} }
@ -42,16 +38,13 @@ type WindowGetOutput struct {
} }
func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input WindowGetInput) (*mcp.CallToolResult, WindowGetOutput, error) { func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input WindowGetInput) (*mcp.CallToolResult, WindowGetOutput, error) {
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WindowGetOutput{}, err
return nil, WindowGetOutput{}, e
} }
return nil, WindowGetOutput{}, nil info, ok := result.(*window.WindowInfo)
}
info, ok := r.Value.(*window.WindowInfo)
if !ok { if !ok {
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type", nil) return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query")
} }
return nil, WindowGetOutput{Window: info}, nil return nil, WindowGetOutput{Window: info}, nil
} }
@ -64,16 +57,13 @@ type WindowFocusedOutput struct {
} }
func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ WindowFocusedInput) (*mcp.CallToolResult, WindowFocusedOutput, error) { func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ WindowFocusedInput) (*mcp.CallToolResult, WindowFocusedOutput, error) {
r := s.core.QUERY(window.QueryWindowList{}) result, _, err := s.core.QUERY(window.QueryWindowList{})
if !r.OK { if err != nil {
if e, ok := r.Value.(error); ok { return nil, WindowFocusedOutput{}, err
return nil, WindowFocusedOutput{}, e
} }
return nil, WindowFocusedOutput{}, nil windows, ok := result.([]window.WindowInfo)
}
windows, ok := r.Value.([]window.WindowInfo)
if !ok { if !ok {
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type", nil) return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
} }
for _, w := range windows { for _, w := range windows {
if w.Focused { if w.Focused {
@ -99,8 +89,7 @@ type WindowCreateOutput struct {
} }
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) { func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
r := s.core.Action("window.open").Run(context.Background(), core.NewOptions( result, _, err := s.core.PERFORM(window.TaskOpenWindow{
core.Option{Key: "task", Value: window.TaskOpenWindow{
Window: &window.Window{ Window: &window.Window{
Name: input.Name, Name: input.Name,
Title: input.Title, Title: input.Title,
@ -110,17 +99,13 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu
X: input.X, X: input.X,
Y: input.Y, Y: input.Y,
}, },
}}, })
)) if err != nil {
if !r.OK { return nil, WindowCreateOutput{}, err
if e, ok := r.Value.(error); ok {
return nil, WindowCreateOutput{}, e
} }
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "window.open failed", nil) info, ok := result.(window.WindowInfo)
}
info, ok := r.Value.(window.WindowInfo)
if !ok { if !ok {
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type", nil) return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task")
} }
return nil, WindowCreateOutput{Window: info}, nil return nil, WindowCreateOutput{Window: info}, nil
} }
@ -135,14 +120,9 @@ type WindowCloseOutput struct {
} }
func (s *Subsystem) windowClose(_ context.Context, _ *mcp.CallToolRequest, input WindowCloseInput) (*mcp.CallToolResult, WindowCloseOutput, error) { func (s *Subsystem) windowClose(_ context.Context, _ *mcp.CallToolRequest, input WindowCloseInput) (*mcp.CallToolResult, WindowCloseOutput, error) {
r := s.core.Action("window.close").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskCloseWindow{Name: input.Name})
core.Option{Key: "task", Value: window.TaskCloseWindow{Name: input.Name}}, if err != nil {
)) return nil, WindowCloseOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowCloseOutput{}, e
}
return nil, WindowCloseOutput{}, nil
} }
return nil, WindowCloseOutput{Success: true}, nil return nil, WindowCloseOutput{Success: true}, nil
} }
@ -159,14 +139,9 @@ type WindowPositionOutput struct {
} }
func (s *Subsystem) windowPosition(_ context.Context, _ *mcp.CallToolRequest, input WindowPositionInput) (*mcp.CallToolResult, WindowPositionOutput, error) { func (s *Subsystem) windowPosition(_ context.Context, _ *mcp.CallToolRequest, input WindowPositionInput) (*mcp.CallToolResult, WindowPositionOutput, error) {
r := s.core.Action("window.setPosition").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
core.Option{Key: "task", Value: window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y}}, if err != nil {
)) return nil, WindowPositionOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowPositionOutput{}, e
}
return nil, WindowPositionOutput{}, nil
} }
return nil, WindowPositionOutput{Success: true}, nil return nil, WindowPositionOutput{Success: true}, nil
} }
@ -183,14 +158,9 @@ type WindowSizeOutput struct {
} }
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) { func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
r := s.core.Action("window.setSize").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
core.Option{Key: "task", Value: window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height}}, if err != nil {
)) return nil, WindowSizeOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowSizeOutput{}, e
}
return nil, WindowSizeOutput{}, nil
} }
return nil, WindowSizeOutput{Success: true}, nil return nil, WindowSizeOutput{Success: true}, nil
} }
@ -209,23 +179,13 @@ type WindowBoundsOutput struct {
} }
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) { func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
r := s.core.Action("window.setPosition").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
core.Option{Key: "task", Value: window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y}}, if err != nil {
)) return nil, WindowBoundsOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowBoundsOutput{}, e
} }
return nil, WindowBoundsOutput{}, nil _, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
} if err != nil {
r = s.core.Action("window.setSize").Run(context.Background(), core.NewOptions( return nil, WindowBoundsOutput{}, err
core.Option{Key: "task", Value: window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height}},
))
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowBoundsOutput{}, e
}
return nil, WindowBoundsOutput{}, nil
} }
return nil, WindowBoundsOutput{Success: true}, nil return nil, WindowBoundsOutput{Success: true}, nil
} }
@ -240,14 +200,9 @@ type WindowMaximizeOutput struct {
} }
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) { func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
r := s.core.Action("window.maximise").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskMaximise{Name: input.Name})
core.Option{Key: "task", Value: window.TaskMaximise{Name: input.Name}}, if err != nil {
)) return nil, WindowMaximizeOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowMaximizeOutput{}, e
}
return nil, WindowMaximizeOutput{}, nil
} }
return nil, WindowMaximizeOutput{Success: true}, nil return nil, WindowMaximizeOutput{Success: true}, nil
} }
@ -262,14 +217,9 @@ type WindowMinimizeOutput struct {
} }
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) { func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
r := s.core.Action("window.minimise").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskMinimise{Name: input.Name})
core.Option{Key: "task", Value: window.TaskMinimise{Name: input.Name}}, if err != nil {
)) return nil, WindowMinimizeOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowMinimizeOutput{}, e
}
return nil, WindowMinimizeOutput{}, nil
} }
return nil, WindowMinimizeOutput{Success: true}, nil return nil, WindowMinimizeOutput{Success: true}, nil
} }
@ -284,14 +234,9 @@ type WindowRestoreOutput struct {
} }
func (s *Subsystem) windowRestore(_ context.Context, _ *mcp.CallToolRequest, input WindowRestoreInput) (*mcp.CallToolResult, WindowRestoreOutput, error) { func (s *Subsystem) windowRestore(_ context.Context, _ *mcp.CallToolRequest, input WindowRestoreInput) (*mcp.CallToolResult, WindowRestoreOutput, error) {
r := s.core.Action("window.restore").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskRestore{Name: input.Name})
core.Option{Key: "task", Value: window.TaskRestore{Name: input.Name}}, if err != nil {
)) return nil, WindowRestoreOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowRestoreOutput{}, e
}
return nil, WindowRestoreOutput{}, nil
} }
return nil, WindowRestoreOutput{Success: true}, nil return nil, WindowRestoreOutput{Success: true}, nil
} }
@ -306,14 +251,9 @@ type WindowFocusOutput struct {
} }
func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input WindowFocusInput) (*mcp.CallToolResult, WindowFocusOutput, error) { func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input WindowFocusInput) (*mcp.CallToolResult, WindowFocusOutput, error) {
r := s.core.Action("window.focus").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskFocus{Name: input.Name})
core.Option{Key: "task", Value: window.TaskFocus{Name: input.Name}}, if err != nil {
)) return nil, WindowFocusOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowFocusOutput{}, e
}
return nil, WindowFocusOutput{}, nil
} }
return nil, WindowFocusOutput{Success: true}, nil return nil, WindowFocusOutput{Success: true}, nil
} }
@ -329,18 +269,19 @@ type WindowTitleOutput struct {
} }
func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) { func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
r := s.core.Action("window.setTitle").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetTitle{Name: input.Name, Title: input.Title})
core.Option{Key: "task", Value: window.TaskSetTitle{Name: input.Name, Title: input.Title}}, if err != nil {
)) return nil, WindowTitleOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowTitleOutput{}, e
}
return nil, WindowTitleOutput{}, nil
} }
return nil, WindowTitleOutput{Success: true}, nil return nil, WindowTitleOutput{Success: true}, nil
} }
// --- window_title_set ---
func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
return s.windowTitle(ctx, req, input)
}
// --- window_title_get --- // --- window_title_get ---
type WindowTitleGetInput struct { type WindowTitleGetInput struct {
@ -351,11 +292,11 @@ type WindowTitleGetOutput struct {
} }
func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) { func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) {
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if !r.OK { if err != nil {
return nil, WindowTitleGetOutput{}, nil return nil, WindowTitleGetOutput{}, err
} }
info, _ := r.Value.(*window.WindowInfo) info, _ := result.(*window.WindowInfo)
if info == nil { if info == nil {
return nil, WindowTitleGetOutput{}, nil return nil, WindowTitleGetOutput{}, nil
} }
@ -373,14 +314,9 @@ type WindowVisibilityOutput struct {
} }
func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, input WindowVisibilityInput) (*mcp.CallToolResult, WindowVisibilityOutput, error) { func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, input WindowVisibilityInput) (*mcp.CallToolResult, WindowVisibilityOutput, error) {
r := s.core.Action("window.setVisibility").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetVisibility{Name: input.Name, Visible: input.Visible})
core.Option{Key: "task", Value: window.TaskSetVisibility{Name: input.Name, Visible: input.Visible}}, if err != nil {
)) return nil, WindowVisibilityOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowVisibilityOutput{}, e
}
return nil, WindowVisibilityOutput{}, nil
} }
return nil, WindowVisibilityOutput{Success: true}, nil return nil, WindowVisibilityOutput{Success: true}, nil
} }
@ -396,14 +332,9 @@ type WindowAlwaysOnTopOutput struct {
} }
func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) { func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) {
r := s.core.Action("window.setAlwaysOnTop").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop})
core.Option{Key: "task", Value: window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop}}, if err != nil {
)) return nil, WindowAlwaysOnTopOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowAlwaysOnTopOutput{}, e
}
return nil, WindowAlwaysOnTopOutput{}, nil
} }
return nil, WindowAlwaysOnTopOutput{Success: true}, nil return nil, WindowAlwaysOnTopOutput{Success: true}, nil
} }
@ -422,20 +353,33 @@ type WindowBackgroundColourOutput struct {
} }
func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) { func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) {
r := s.core.Action("window.setBackgroundColour").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{
core.Option{Key: "task", Value: window.TaskSetBackgroundColour{
Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha, Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha,
}}, })
)) if err != nil {
if !r.OK { return nil, WindowBackgroundColourOutput{}, err
if e, ok := r.Value.(error); ok {
return nil, WindowBackgroundColourOutput{}, e
}
return nil, WindowBackgroundColourOutput{}, nil
} }
return nil, WindowBackgroundColourOutput{Success: true}, nil return nil, WindowBackgroundColourOutput{Success: true}, nil
} }
// --- window_opacity ---
type WindowOpacityInput struct {
Name string `json:"name"`
Opacity float32 `json:"opacity"`
}
type WindowOpacityOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowOpacity(_ context.Context, _ *mcp.CallToolRequest, input WindowOpacityInput) (*mcp.CallToolResult, WindowOpacityOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetOpacity{Name: input.Name, Opacity: input.Opacity})
if err != nil {
return nil, WindowOpacityOutput{}, err
}
return nil, WindowOpacityOutput{Success: true}, nil
}
// --- window_fullscreen --- // --- window_fullscreen ---
type WindowFullscreenInput struct { type WindowFullscreenInput struct {
@ -447,14 +391,9 @@ type WindowFullscreenOutput struct {
} }
func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowFullscreenInput) (*mcp.CallToolResult, WindowFullscreenOutput, error) { func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowFullscreenInput) (*mcp.CallToolResult, WindowFullscreenOutput, error) {
r := s.core.Action("window.fullscreen").Run(context.Background(), core.NewOptions( _, _, err := s.core.PERFORM(window.TaskFullscreen{Name: input.Name, Fullscreen: input.Fullscreen})
core.Option{Key: "task", Value: window.TaskFullscreen{Name: input.Name, Fullscreen: input.Fullscreen}}, if err != nil {
)) return nil, WindowFullscreenOutput{}, err
if !r.OK {
if e, ok := r.Value.(error); ok {
return nil, WindowFullscreenOutput{}, e
}
return nil, WindowFullscreenOutput{}, nil
} }
return nil, WindowFullscreenOutput{Success: true}, nil return nil, WindowFullscreenOutput{Success: true}, nil
} }
@ -474,10 +413,13 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize) mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize)
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_set", Description: "Alias for window_title"}, s.windowTitleSet)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
mcp.AddTool(server, &mcp.Tool{Name: "window_opacity", Description: "Set a window opacity"}, s.windowOpacity)
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
} }

View file

@ -2,6 +2,7 @@
package menu package menu
// MenuItem describes a menu item for construction (structure only — no handlers). // MenuItem describes a menu item for construction (structure only — no handlers).
// Use: item := menu.MenuItem{Label: "Quit", OnClick: func() {}}
type MenuItem struct { type MenuItem struct {
Label string Label string
Accelerator string Accelerator string
@ -15,16 +16,19 @@ type MenuItem struct {
} }
// Manager builds application menus via a Platform backend. // Manager builds application menus via a Platform backend.
// Use: mgr := menu.NewManager(platform)
type Manager struct { type Manager struct {
platform Platform platform Platform
} }
// NewManager creates a menu Manager. // NewManager creates a menu Manager.
// Use: mgr := menu.NewManager(platform)
func NewManager(platform Platform) *Manager { func NewManager(platform Platform) *Manager {
return &Manager{platform: platform} return &Manager{platform: platform}
} }
// Build constructs a PlatformMenu from a tree of MenuItems. // Build constructs a PlatformMenu from a tree of MenuItems.
// Use: built := mgr.Build([]menu.MenuItem{{Label: "File"}})
func (m *Manager) Build(items []MenuItem) PlatformMenu { func (m *Manager) Build(items []MenuItem) PlatformMenu {
menu := m.platform.NewMenu() menu := m.platform.NewMenu()
m.buildItems(menu, items) m.buildItems(menu, items)
@ -60,12 +64,14 @@ func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) {
} }
// SetApplicationMenu builds and sets the application menu. // SetApplicationMenu builds and sets the application menu.
// Use: mgr.SetApplicationMenu([]menu.MenuItem{{Label: "Quit"}})
func (m *Manager) SetApplicationMenu(items []MenuItem) { func (m *Manager) SetApplicationMenu(items []MenuItem) {
menu := m.Build(items) menu := m.Build(items)
m.platform.SetApplicationMenu(menu) m.platform.SetApplicationMenu(menu)
} }
// Platform returns the underlying platform. // Platform returns the underlying platform.
// Use: backend := mgr.Platform()
func (m *Manager) Platform() Platform { func (m *Manager) Platform() Platform {
return m.platform return m.platform
} }

View file

@ -1,9 +1,17 @@
// pkg/menu/messages.go
package menu package menu
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{} type QueryConfig struct{}
// QueryGetAppMenu returns the current app menu item descriptors.
// Result: []MenuItem
type QueryGetAppMenu struct{} type QueryGetAppMenu struct{}
// TaskSetAppMenu sets the application menu. OnClick closures work because
// core/go IPC is in-process (no serialisation boundary).
type TaskSetAppMenu struct{ Items []MenuItem } type TaskSetAppMenu struct{ Items []MenuItem }
type TaskSaveConfig struct{ Config map[string]any } // TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }

View file

@ -1,3 +1,4 @@
// pkg/menu/mock_platform.go
package menu package menu
// MockPlatform is an exported mock for cross-package integration tests. // MockPlatform is an exported mock for cross-package integration tests.
@ -10,9 +11,13 @@ func (m *MockPlatform) SetApplicationMenu(menu PlatformMenu) {}
type exportedMockPlatformMenu struct{} type exportedMockPlatformMenu struct{}
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem { return &exportedMockPlatformMenuItem{} } func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem {
return &exportedMockPlatformMenuItem{}
}
func (m *exportedMockPlatformMenu) AddSeparator() {} func (m *exportedMockPlatformMenu) AddSeparator() {}
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu { return &exportedMockPlatformMenu{} } func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu {
return &exportedMockPlatformMenu{}
}
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {} func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
type exportedMockPlatformMenuItem struct{} type exportedMockPlatformMenuItem struct{}

View file

@ -2,12 +2,14 @@
package menu package menu
// Platform abstracts the menu backend. // Platform abstracts the menu backend.
// Use: var platform menu.Platform = backend
type Platform interface { type Platform interface {
NewMenu() PlatformMenu NewMenu() PlatformMenu
SetApplicationMenu(menu PlatformMenu) SetApplicationMenu(menu PlatformMenu)
} }
// PlatformMenu is a live menu handle. // PlatformMenu is a live menu handle.
// Use: var root menu.PlatformMenu = platform.NewMenu()
type PlatformMenu interface { type PlatformMenu interface {
Add(label string) PlatformMenuItem Add(label string) PlatformMenuItem
AddSeparator() AddSeparator()
@ -17,6 +19,7 @@ type PlatformMenu interface {
} }
// PlatformMenuItem is a single menu item. // PlatformMenuItem is a single menu item.
// Use: var item menu.PlatformMenuItem = root.Add("Quit")
type PlatformMenuItem interface { type PlatformMenuItem interface {
SetAccelerator(accel string) PlatformMenuItem SetAccelerator(accel string) PlatformMenuItem
SetTooltip(text string) PlatformMenuItem SetTooltip(text string) PlatformMenuItem
@ -26,6 +29,7 @@ type PlatformMenuItem interface {
} }
// MenuRole is a predefined platform menu role. // MenuRole is a predefined platform menu role.
// Use: role := menu.RoleFileMenu
type MenuRole int type MenuRole int
const ( const (

View file

@ -1,15 +1,15 @@
// pkg/menu/register.go
package menu package menu
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the menu service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(menu.Register(wailsMenu)) func Register(p Platform) func(*core.Core) (any, error) {
func Register(p Platform) func(*core.Core) core.Result { return func(c *core.Core) (any, error) {
return func(c *core.Core) core.Result { return &Service{
return core.Result{Value: &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
manager: NewManager(p), manager: NewManager(p),
}, OK: true} }, nil
} }
} }

View file

@ -1,63 +1,76 @@
// pkg/menu/service.go
package menu package menu
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the menu service.
type Options struct{} type Options struct{}
// Service is a core.Service managing application menus via IPC.
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
manager *Manager manager *Manager
platform Platform platform Platform
menuItems []MenuItem items []MenuItem // last-set menu items for QueryGetAppMenu
showDevTools bool showDevTools bool
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup queries config and registers IPC handlers.
r := s.Core().QUERY(QueryConfig{}) func (s *Service) OnStartup(ctx context.Context) error {
if r.OK { cfg, handled, _ := s.Core().QUERY(QueryConfig{})
if menuConfig, ok := r.Value.(map[string]any); ok { if handled {
s.applyConfig(menuConfig) if mCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(mCfg)
} }
} }
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("menu.setAppMenu", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskSetAppMenu) return nil
s.menuItems = t.Items
s.manager.SetApplicationMenu(t.Items)
return core.Result{OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) applyConfig(configData map[string]any) { func (s *Service) applyConfig(cfg map[string]any) {
if v, ok := configData["show_dev_tools"]; ok { if v, ok := cfg["show_dev_tools"]; ok {
if show, ok := v.(bool); ok { if show, ok := v.(bool); ok {
s.showDevTools = show s.showDevTools = show
} }
} }
} }
// ShowDevTools returns whether developer tools menu items should be shown.
func (s *Service) ShowDevTools() bool { func (s *Service) ShowDevTools() bool {
return s.showDevTools return s.showDevTools
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered and registered by core.WithService.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryGetAppMenu: case QueryGetAppMenu:
return core.Result{Value: s.menuItems, OK: true} return s.items, true, nil
default: default:
return core.Result{} return nil, false, nil
} }
} }
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetAppMenu:
s.items = t.Items
s.manager.SetApplicationMenu(t.Items)
return nil, true, nil
default:
return nil, false, nil
}
}
// Manager returns the underlying menu Manager.
func (s *Service) Manager() *Manager { func (s *Service) Manager() *Manager {
return s.manager return s.manager
} }

View file

@ -4,28 +4,23 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func newTestMenuService(t *testing.T) (*Service, *core.Core) { func newTestMenuService(t *testing.T) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(newMockPlatform())), core.WithService(Register(newMockPlatform())),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "menu") svc := core.MustServiceFor[*Service](c, "menu")
return svc, c return svc, c
} }
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
svc, _ := newTestMenuService(t) svc, _ := newTestMenuService(t)
assert.NotNil(t, svc) assert.NotNil(t, svc)
@ -42,25 +37,28 @@ func TestTaskSetAppMenu_Good(t *testing.T) {
{Label: "Quit"}, {Label: "Quit"},
}}, }},
} }
r := taskRun(c, "menu.setAppMenu", TaskSetAppMenu{Items: items}) _, handled, err := c.PERFORM(TaskSetAppMenu{Items: items})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
} }
func TestQueryGetAppMenu_Good(t *testing.T) { func TestQueryGetAppMenu_Good(t *testing.T) {
_, c := newTestMenuService(t) _, c := newTestMenuService(t)
items := []MenuItem{{Label: "File"}, {Label: "Edit"}} items := []MenuItem{{Label: "File"}, {Label: "Edit"}}
taskRun(c, "menu.setAppMenu", TaskSetAppMenu{Items: items}) _, _, _ = c.PERFORM(TaskSetAppMenu{Items: items})
r := c.QUERY(QueryGetAppMenu{}) result, handled, err := c.QUERY(QueryGetAppMenu{})
require.True(t, r.OK) require.NoError(t, err)
menuItems := r.Value.([]MenuItem) assert.True(t, handled)
menuItems := result.([]MenuItem)
assert.Len(t, menuItems, 2) assert.Len(t, menuItems, 2)
assert.Equal(t, "File", menuItems[0].Label) assert.Equal(t, "File", menuItems[0].Label)
} }
func TestTaskSetAppMenu_Bad(t *testing.T) { func TestTaskSetAppMenu_Bad(t *testing.T) {
c := core.New(core.WithServiceLock()) c, err := core.New(core.WithServiceLock())
r := c.Action("menu.setAppMenu").Run(context.Background(), core.NewOptions()) require.NoError(t, err)
assert.False(t, r.OK) _, handled, _ := c.PERFORM(TaskSetAppMenu{})
assert.False(t, handled)
} }

View file

@ -1,33 +1,17 @@
// pkg/notification/messages.go
package notification package notification
// QueryPermission returns current notification permission status. Result: PermissionStatus // QueryPermission checks notification authorisation. Result: PermissionStatus
type QueryPermission struct{} type QueryPermission struct{}
// TaskSend sends a native notification, falling back to dialog on failure. // TaskSend sends a notification. Falls back to dialog if platform fails.
type TaskSend struct{ Options NotificationOptions } type TaskSend struct{ Opts NotificationOptions }
// TaskRequestPermission requests notification permission from the OS. Result: bool (granted) // TaskRequestPermission requests notification authorisation. Result: bool (granted)
type TaskRequestPermission struct{} type TaskRequestPermission struct{}
// TaskRevokePermission revokes previously granted notification permission. Result: nil // TaskClear clears pending notifications when the backend supports it.
type TaskRevokePermission struct{} type TaskClear struct{}
// TaskRegisterCategory registers a notification category with its actions. // ActionNotificationClicked is broadcast when a notification is clicked.
// c.PERFORM(notification.TaskRegisterCategory{Category: notification.NotificationCategory{ID: "message", Actions: actions}})
type TaskRegisterCategory struct{ Category NotificationCategory }
// ActionNotificationClicked is broadcast when the user clicks a notification body.
type ActionNotificationClicked struct{ ID string } type ActionNotificationClicked struct{ ID string }
// ActionNotificationActionTriggered is broadcast when the user activates a notification action button.
// c.RegisterAction(func(_ *core.Core, msg core.Message) error {
// if a, ok := msg.(notification.ActionNotificationActionTriggered); ok { ... }
// return nil
// })
type ActionNotificationActionTriggered struct {
NotificationID string `json:"notificationId"`
ActionID string `json:"actionId"`
}
// ActionNotificationDismissed is broadcast when the user dismisses a notification.
type ActionNotificationDismissed struct{ ID string }

View file

@ -3,10 +3,15 @@ package notification
// Platform abstracts the native notification backend. // Platform abstracts the native notification backend.
type Platform interface { type Platform interface {
Send(options NotificationOptions) error Send(opts NotificationOptions) error
RequestPermission() (bool, error) RequestPermission() (bool, error)
CheckPermission() (bool, error) CheckPermission() (bool, error)
RevokePermission() error }
// NotificationAction represents an interactive notification action.
type NotificationAction struct {
ID string `json:"id"`
Label string `json:"label"`
} }
// NotificationSeverity indicates the severity for dialog fallback. // NotificationSeverity indicates the severity for dialog fallback.
@ -18,21 +23,6 @@ const (
SeverityError SeverityError
) )
// NotificationAction is a button that can be attached to a notification.
// id := "reply"; action := NotificationAction{ID: id, Title: "Reply", Destructive: false}
type NotificationAction struct {
ID string `json:"id"`
Title string `json:"title"`
Destructive bool `json:"destructive,omitempty"`
}
// NotificationCategory groups actions under a named category/channel.
// category := NotificationCategory{ID: "message", Actions: []NotificationAction{{ID: "reply", Title: "Reply"}}}
type NotificationCategory struct {
ID string `json:"id"`
Actions []NotificationAction `json:"actions,omitempty"`
}
// NotificationOptions contains options for sending a notification. // NotificationOptions contains options for sending a notification.
type NotificationOptions struct { type NotificationOptions struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@ -40,7 +30,6 @@ type NotificationOptions struct {
Message string `json:"message"` Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"` Subtitle string `json:"subtitle,omitempty"`
Severity NotificationSeverity `json:"severity,omitempty"` Severity NotificationSeverity `json:"severity,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"` Actions []NotificationAction `json:"actions,omitempty"`
} }
@ -48,3 +37,11 @@ type NotificationOptions struct {
type PermissionStatus struct { type PermissionStatus struct {
Granted bool `json:"granted"` Granted bool `json:"granted"`
} }
type clearer interface {
Clear() error
}
type actionSender interface {
SendWithActions(opts NotificationOptions) error
}

View file

@ -3,88 +3,102 @@ package notification
import ( import (
"context" "context"
"strconv" "fmt"
"time" "time"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/dialog" "forge.lthn.ai/core/gui/pkg/dialog"
) )
// Options configures the notification service.
// Use: core.WithService(notification.Register(platform))
type Options struct{} type Options struct{}
// Service manages notifications via Core tasks and queries.
// Use: svc := &notification.Service{}
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
categories map[string]NotificationCategory
} }
func Register(p Platform) func(*core.Core) core.Result { // Register creates a Core service factory for the notification backend.
return func(c *core.Core) core.Result { // Use: core.New(core.WithService(notification.Register(platform)))
return core.Result{Value: &Service{ func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
categories: make(map[string]NotificationCategory), }, nil
}, OK: true}
} }
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers notification handlers with Core.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.Core().Action("notification.send", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskSend) return nil
return core.Result{Value: nil, OK: true}.New(s.send(t.Options))
})
s.Core().Action("notification.requestPermission", func(_ context.Context, _ core.Options) core.Result {
granted, err := s.platform.RequestPermission()
return core.Result{}.New(granted, err)
})
s.Core().Action("notification.revokePermission", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission())
})
s.Core().Action("notification.registerCategory", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskRegisterCategory)
s.categories[t.Category.ID] = t.Category
return core.Result{OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents satisfies Core's IPC hook.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) { switch q.(type) {
case QueryPermission: case QueryPermission:
granted, err := s.platform.CheckPermission() granted, err := s.platform.CheckPermission()
if err != nil { return PermissionStatus{Granted: granted}, true, err
return core.Result{Value: err, OK: false}
}
return core.Result{Value: PermissionStatus{Granted: granted}, OK: true}
default: default:
return core.Result{} return nil, false, nil
} }
} }
// send attempts native notification, falls back to dialog via IPC. func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
func (s *Service) send(options NotificationOptions) error { switch t := t.(type) {
// Generate ID if not provided case TaskSend:
if options.ID == "" { return nil, true, s.sendNotification(t.Opts)
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) case TaskRequestPermission:
granted, err := s.platform.RequestPermission()
return granted, true, err
case TaskClear:
if clr, ok := s.platform.(clearer); ok {
return nil, true, clr.Clear()
}
return nil, true, nil
default:
return nil, false, nil
}
} }
if err := s.platform.Send(options); err != nil { // sendNotification attempts a native notification and falls back to a dialog via IPC.
// Fallback: show as dialog via IPC func (s *Service) sendNotification(opts NotificationOptions) error {
return s.fallbackDialog(options) // Generate an ID when the caller does not provide one.
if opts.ID == "" {
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
}
if len(opts.Actions) > 0 {
if sender, ok := s.platform.(actionSender); ok {
if err := sender.SendWithActions(opts); err == nil {
return nil
}
}
}
if err := s.platform.Send(opts); err != nil {
// Fall back to a dialog when the native notification fails.
return s.showFallbackDialog(opts)
} }
return nil return nil
} }
// fallbackDialog shows a dialog via IPC when native notifications fail. // showFallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) fallbackDialog(options NotificationOptions) error { func (s *Service) showFallbackDialog(opts NotificationOptions) error {
// Map severity to dialog type // Map severity to dialog type.
var dt dialog.DialogType var dt dialog.DialogType
switch options.Severity { switch opts.Severity {
case SeverityWarning: case SeverityWarning:
dt = dialog.DialogWarning dt = dialog.DialogWarning
case SeverityError: case SeverityError:
@ -93,25 +107,18 @@ func (s *Service) fallbackDialog(options NotificationOptions) error {
dt = dialog.DialogInfo dt = dialog.DialogInfo
} }
message := options.Message msg := opts.Message
if options.Subtitle != "" { if opts.Subtitle != "" {
message = options.Subtitle + "\n\n" + message msg = opts.Subtitle + "\n\n" + msg
} }
r := s.Core().Action("dialog.message").Run(context.Background(), core.NewOptions( _, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
core.Option{Key: "task", Value: dialog.TaskMessageDialog{ Opts: dialog.MessageDialogOptions{
Options: dialog.MessageDialogOptions{
Type: dt, Type: dt,
Title: options.Title, Title: opts.Title,
Message: message, Message: msg,
Buttons: []string{"OK"}, Buttons: []string{"OK"},
}, },
}}, })
))
if !r.OK {
if err, ok := r.Value.(error); ok {
return err return err
} }
}
return nil
}

View file

@ -3,9 +3,10 @@ package notification
import ( import (
"context" "context"
core "dappco.re/go/core" "errors"
"testing" "testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/dialog" "forge.lthn.ai/core/gui/pkg/dialog"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -15,10 +16,9 @@ type mockPlatform struct {
sendErr error sendErr error
permGranted bool permGranted bool
permErr error permErr error
revokeErr error
revokeCalled bool
lastOpts NotificationOptions lastOpts NotificationOptions
sendCalled bool sendCalled bool
clearCalled bool
} }
func (m *mockPlatform) Send(opts NotificationOptions) error { func (m *mockPlatform) Send(opts NotificationOptions) error {
@ -26,12 +26,14 @@ func (m *mockPlatform) Send(opts NotificationOptions) error {
m.lastOpts = opts m.lastOpts = opts
return m.sendErr return m.sendErr
} }
func (m *mockPlatform) SendWithActions(opts NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return m.sendErr
}
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
func (m *mockPlatform) RevokePermission() error { func (m *mockPlatform) Clear() error { m.clearCalled = true; return nil }
m.revokeCalled = true
return m.revokeErr
}
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test). // mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
type mockDialogPlatform struct { type mockDialogPlatform struct {
@ -53,20 +55,15 @@ func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (st
func newTestService(t *testing.T) (*mockPlatform, *core.Core) { func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
t.Helper() t.Helper()
mock := &mockPlatform{permGranted: true} mock := &mockPlatform{permGranted: true}
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return mock, c return mock, c
} }
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
svc := core.MustServiceFor[*Service](c, "notification") svc := core.MustServiceFor[*Service](c, "notification")
@ -75,186 +72,78 @@ func TestRegister_Good(t *testing.T) {
func TestTaskSend_Good(t *testing.T) { func TestTaskSend_Good(t *testing.T) {
mock, c := newTestService(t) mock, c := newTestService(t)
r := taskRun(c, "notification.send", TaskSend{ _, handled, err := c.PERFORM(TaskSend{
Options: NotificationOptions{Title: "Test", Message: "Hello"}, Opts: NotificationOptions{Title: "Test", Message: "Hello"},
}) })
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.sendCalled) assert.True(t, mock.sendCalled)
assert.Equal(t, "Test", mock.lastOpts.Title) assert.Equal(t, "Test", mock.lastOpts.Title)
} }
func TestTaskSend_Fallback_Good(t *testing.T) { func TestTaskSend_Fallback_Good(t *testing.T) {
// Platform fails -> falls back to dialog via IPC // Platform fails -> falls back to dialog via IPC
mockNotify := &mockPlatform{sendErr: core.NewError("no permission")} mockNotify := &mockPlatform{sendErr: errors.New("no permission")}
mockDlg := &mockDialogPlatform{} mockDlg := &mockDialogPlatform{}
c := core.New( c, err := core.New(
core.WithService(dialog.Register(mockDlg)), core.WithService(dialog.Register(mockDlg)),
core.WithService(Register(mockNotify)), core.WithService(Register(mockNotify)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
r := taskRun(c, "notification.send", TaskSend{ _, handled, err := c.PERFORM(TaskSend{
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning}, Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
}) })
assert.True(t, r.OK) // fallback succeeds even though platform failed assert.True(t, handled)
assert.NoError(t, err) // fallback succeeds even though platform failed
assert.True(t, mockDlg.messageCalled) assert.True(t, mockDlg.messageCalled)
assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type) assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type)
} }
func TestQueryPermission_Good(t *testing.T) { func TestQueryPermission_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryPermission{}) result, handled, err := c.QUERY(QueryPermission{})
require.True(t, r.OK) require.NoError(t, err)
status := r.Value.(PermissionStatus) assert.True(t, handled)
status := result.(PermissionStatus)
assert.True(t, status.Granted) assert.True(t, status.Granted)
} }
func TestTaskRequestPermission_Good(t *testing.T) { func TestTaskRequestPermission_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.Action("notification.requestPermission").Run(context.Background(), core.NewOptions()) result, handled, err := c.PERFORM(TaskRequestPermission{})
require.True(t, r.OK) require.NoError(t, err)
assert.Equal(t, true, r.Value) assert.True(t, handled)
assert.Equal(t, true, result)
} }
func TestTaskSend_Bad(t *testing.T) { func TestTaskSend_Bad(t *testing.T) {
c := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
r := c.Action("notification.send").Run(context.Background(), core.NewOptions()) _, handled, _ := c.PERFORM(TaskSend{})
assert.False(t, r.OK) assert.False(t, handled)
} }
// --- TaskRevokePermission ---
func TestTaskRevokePermission_Good(t *testing.T) {
mock, c := newTestService(t)
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
require.True(t, r.OK)
assert.True(t, mock.revokeCalled)
}
func TestTaskRevokePermission_Bad(t *testing.T) {
mock, c := newTestService(t)
mock.revokeErr = core.NewError("cannot revoke")
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
func TestTaskRevokePermission_Ugly(t *testing.T) {
// No service registered — action is not registered
c := core.New(core.WithServiceLock())
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- TaskRegisterCategory ---
func TestTaskRegisterCategory_Good(t *testing.T) {
_, c := newTestService(t)
category := NotificationCategory{
ID: "message",
Actions: []NotificationAction{
{ID: "reply", Title: "Reply"},
{ID: "delete", Title: "Delete", Destructive: true},
},
}
r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: category})
require.True(t, r.OK)
svc := core.MustServiceFor[*Service](c, "notification")
stored, ok := svc.categories["message"]
require.True(t, ok)
assert.Equal(t, 2, len(stored.Actions))
assert.Equal(t, "reply", stored.Actions[0].ID)
assert.True(t, stored.Actions[1].Destructive)
}
func TestTaskRegisterCategory_Bad(t *testing.T) {
// No service registered — action is not registered
c := core.New(core.WithServiceLock())
r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: NotificationCategory{ID: "x"}})
assert.False(t, r.OK)
}
func TestTaskRegisterCategory_Ugly(t *testing.T) {
// Re-registering a category replaces the previous one
_, c := newTestService(t)
first := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "a", Title: "A"}}}
second := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "b", Title: "B"}, {ID: "c", Title: "C"}}}
require.True(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: first}).OK)
require.True(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: second}).OK)
svc := core.MustServiceFor[*Service](c, "notification")
assert.Equal(t, 2, len(svc.categories["chat"].Actions))
assert.Equal(t, "b", svc.categories["chat"].Actions[0].ID)
}
// --- NotificationOptions with Actions ---
func TestTaskSend_WithActions_Good(t *testing.T) { func TestTaskSend_WithActions_Good(t *testing.T) {
mock, c := newTestService(t) mock, c := newTestService(t)
options := NotificationOptions{ _, handled, err := c.PERFORM(TaskSend{
Title: "Team Chat", Opts: NotificationOptions{
Message: "New message from Alice", Title: "Test",
CategoryID: "message", Message: "Hello",
Actions: []NotificationAction{ Actions: []NotificationAction{{ID: "ok", Label: "OK"}},
{ID: "reply", Title: "Reply"},
{ID: "dismiss", Title: "Dismiss"},
}, },
}
r := taskRun(c, "notification.send", TaskSend{Options: options})
require.True(t, r.OK)
assert.Equal(t, "message", mock.lastOpts.CategoryID)
assert.Equal(t, 2, len(mock.lastOpts.Actions))
}
// --- ActionNotificationActionTriggered ---
func TestActionNotificationActionTriggered_Good(t *testing.T) {
// ActionNotificationActionTriggered is broadcast by external code; confirm it can be received
_, c := newTestService(t)
var received *ActionNotificationActionTriggered
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if a, ok := msg.(ActionNotificationActionTriggered); ok {
received = &a
}
return core.Result{OK: true}
}) })
_ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "n1", ActionID: "reply"}) require.NoError(t, err)
require.NotNil(t, received) assert.True(t, handled)
assert.Equal(t, "n1", received.NotificationID) assert.True(t, mock.sendCalled)
assert.Equal(t, "reply", received.ActionID) assert.Len(t, mock.lastOpts.Actions, 1)
} }
func TestActionNotificationDismissed_Good(t *testing.T) { func TestTaskClear_Good(t *testing.T) {
_, c := newTestService(t) mock, c := newTestService(t)
var received *ActionNotificationDismissed _, handled, err := c.PERFORM(TaskClear{})
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { require.NoError(t, err)
if a, ok := msg.(ActionNotificationDismissed); ok { assert.True(t, handled)
received = &a assert.True(t, mock.clearCalled)
}
return core.Result{OK: true}
})
_ = c.ACTION(ActionNotificationDismissed{ID: "n2"})
require.NotNil(t, received)
assert.Equal(t, "n2", received.ID)
}
func TestQueryPermission_Bad(t *testing.T) {
// No service — QUERY returns handled=false
c := core.New(core.WithServiceLock())
r := c.QUERY(QueryPermission{})
assert.False(t, r.OK)
}
func TestQueryPermission_Ugly(t *testing.T) {
// Platform returns error — QUERY returns OK=false (framework does not propagate Value for failed queries)
mock := &mockPlatform{permErr: core.NewError("platform error")}
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
r := c.QUERY(QueryPermission{})
assert.False(t, r.OK)
} }

View file

@ -2,25 +2,25 @@
package screen package screen
// QueryAll returns all screens. Result: []Screen // QueryAll returns all screens. Result: []Screen
// Use: result, _, err := c.QUERY(screen.QueryAll{})
type QueryAll struct{} type QueryAll struct{}
// QueryPrimary returns the primary screen. Result: *Screen (nil if not found) // QueryPrimary returns the primary screen. Result: *Screen (nil if not found)
// Use: result, _, err := c.QUERY(screen.QueryPrimary{})
type QueryPrimary struct{} type QueryPrimary struct{}
// QueryByID returns a screen by ID. Result: *Screen (nil if not found) // QueryByID returns a screen by ID. Result: *Screen (nil if not found)
// Use: result, _, err := c.QUERY(screen.QueryByID{ID: "display-1"})
type QueryByID struct{ ID string } type QueryByID struct{ ID string }
// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none) // QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none)
// Use: result, _, err := c.QUERY(screen.QueryAtPoint{X: 100, Y: 100})
type QueryAtPoint struct{ X, Y int } type QueryAtPoint struct{ X, Y int }
// QueryWorkAreas returns work areas for all screens. Result: []Rect // QueryWorkAreas returns work areas for all screens. Result: []Rect
// Use: result, _, err := c.QUERY(screen.QueryWorkAreas{})
type QueryWorkAreas struct{} type QueryWorkAreas struct{}
// QueryCurrent returns the most recently active screen. Result: *Screen (nil if no screens registered) // ActionScreensChanged is broadcast when displays change.
// // Use: _ = c.ACTION(screen.ActionScreensChanged{Screens: screens})
// result, _, _ := c.QUERY(screen.QueryCurrent{})
// current := result.(*screen.Screen)
type QueryCurrent struct{}
// ActionScreensChanged is broadcast when displays change (future).
type ActionScreensChanged struct{ Screens []Screen } type ActionScreensChanged struct{ Screens []Screen }

View file

@ -2,33 +2,27 @@
package screen package screen
// Platform abstracts the screen/display backend. // Platform abstracts the screen/display backend.
// // Use: var p screen.Platform
// core.WithService(screen.Register(wailsPlatform))
type Platform interface { type Platform interface {
GetAll() []Screen GetAll() []Screen
GetPrimary() *Screen GetPrimary() *Screen
// GetCurrent returns the most recently active screen, or the primary if unset.
// current := platform.GetCurrent()
GetCurrent() *Screen
} }
// Screen describes a display/monitor. // Screen describes a display/monitor.
// Use: scr := screen.Screen{ID: "display-1"}
type Screen struct { type Screen struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ScaleFactor float64 `json:"scaleFactor"` ScaleFactor float64 `json:"scaleFactor"`
Size Size `json:"size"` Size Size `json:"size"`
Bounds Rect `json:"bounds"` Bounds Rect `json:"bounds"`
PhysicalBounds Rect `json:"physicalBounds"`
WorkArea Rect `json:"workArea"` WorkArea Rect `json:"workArea"`
PhysicalWorkArea Rect `json:"physicalWorkArea"`
IsPrimary bool `json:"isPrimary"` IsPrimary bool `json:"isPrimary"`
Rotation float64 `json:"rotation"` Rotation float64 `json:"rotation"`
} }
// Rect represents a rectangle with position and dimensions. // Rect represents a rectangle with position and dimensions.
// // Use: rect := screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
// if bounds.Contains(Point{X: cursor.X, Y: cursor.Y}) { highlightWindow() }
type Rect struct { type Rect struct {
X int `json:"x"` X int `json:"x"`
Y int `json:"y"` Y int `json:"y"`
@ -36,166 +30,9 @@ type Rect struct {
Height int `json:"height"` Height int `json:"height"`
} }
// Origin returns the top-left corner of the rectangle.
//
// pt := bounds.Origin() // Point{X: bounds.X, Y: bounds.Y}
func (r Rect) Origin() Point {
return Point{X: r.X, Y: r.Y}
}
// Corner returns the exclusive bottom-right corner (X+Width, Y+Height).
//
// end := bounds.Corner() // Point{X: bounds.X+bounds.Width, Y: bounds.Y+bounds.Height}
func (r Rect) Corner() Point {
return Point{X: r.X + r.Width, Y: r.Y + r.Height}
}
// InsideCorner returns the inclusive bottom-right corner (X+Width-1, Y+Height-1).
//
// last := bounds.InsideCorner()
func (r Rect) InsideCorner() Point {
return Point{X: r.X + r.Width - 1, Y: r.Y + r.Height - 1}
}
// IsEmpty reports whether the rectangle has non-positive area.
//
// if r.IsEmpty() { return }
func (r Rect) IsEmpty() bool {
return r.Width <= 0 || r.Height <= 0
}
// Contains reports whether point pt lies within the rectangle.
//
// if workArea.Contains(windowOrigin) { snapToScreen() }
func (r Rect) Contains(pt Point) bool {
return pt.X >= r.X && pt.X < r.X+r.Width && pt.Y >= r.Y && pt.Y < r.Y+r.Height
}
// RectSize returns the dimensions of the rectangle as a Size value.
//
// sz := bounds.RectSize() // Size{Width: bounds.Width, Height: bounds.Height}
func (r Rect) RectSize() Size {
return Size{Width: r.Width, Height: r.Height}
}
// Intersect returns the overlapping region of r and other, or an empty Rect if they do not overlap.
//
// overlap := a.Intersect(b)
// if !overlap.IsEmpty() { handleOverlap(overlap) }
func (r Rect) Intersect(other Rect) Rect {
if r.IsEmpty() || other.IsEmpty() {
return Rect{}
}
maxLeft := max(r.X, other.X)
maxTop := max(r.Y, other.Y)
minRight := min(r.X+r.Width, other.X+other.Width)
minBottom := min(r.Y+r.Height, other.Y+other.Height)
if minRight > maxLeft && minBottom > maxTop {
return Rect{X: maxLeft, Y: maxTop, Width: minRight - maxLeft, Height: minBottom - maxTop}
}
return Rect{}
}
// Point is a two-dimensional coordinate.
//
// centre := Point{X: bounds.X + bounds.Width/2, Y: bounds.Y + bounds.Height/2}
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
// Size represents dimensions. // Size represents dimensions.
// Use: size := screen.Size{Width: 1920, Height: 1080}
type Size struct { type Size struct {
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
} }
// Alignment describes which edge of a parent screen a child screen is placed against.
type Alignment int
const (
AlignTop Alignment = iota // child is above parent
AlignRight // child is to the right of parent
AlignBottom // child is below parent
AlignLeft // child is to the left of parent
)
// OffsetReference specifies whether the placement offset is measured from the
// beginning (top/left) or end (bottom/right) of the parent edge.
type OffsetReference int
const (
OffsetBegin OffsetReference = iota // offset from top or left
OffsetEnd // offset from bottom or right
)
// ScreenPlacement positions a screen relative to a parent screen.
//
// placement := screen.NewPlacement(parent, AlignRight, 0, OffsetBegin)
// placement.Apply()
type ScreenPlacement struct {
screen *Screen
parent *Screen
alignment Alignment
offset int
offsetReference OffsetReference
}
// NewPlacement creates a ScreenPlacement that positions screen relative to parent.
//
// p := NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin)
// p.Apply()
func NewPlacement(screen, parent *Screen, alignment Alignment, offset int, reference OffsetReference) ScreenPlacement {
return ScreenPlacement{
screen: screen,
parent: parent,
alignment: alignment,
offset: offset,
offsetReference: reference,
}
}
// Apply moves screen.Bounds so that it sits against the specified edge of parent.
//
// NewPlacement(s, p, AlignRight, 0, OffsetBegin).Apply()
func (p ScreenPlacement) Apply() {
parentBounds := p.parent.Bounds
screenBounds := p.screen.Bounds
newX := parentBounds.X
newY := parentBounds.Y
offset := p.offset
if p.alignment == AlignTop || p.alignment == AlignBottom {
if p.offsetReference == OffsetEnd {
offset = parentBounds.Width - offset - screenBounds.Width
}
offset = min(offset, parentBounds.Width)
offset = max(offset, -screenBounds.Width)
newX += offset
if p.alignment == AlignTop {
newY -= screenBounds.Height
} else {
newY += parentBounds.Height
}
} else {
if p.offsetReference == OffsetEnd {
offset = parentBounds.Height - offset - screenBounds.Height
}
offset = min(offset, parentBounds.Height)
offset = max(offset, -screenBounds.Height)
newY += offset
if p.alignment == AlignLeft {
newX -= screenBounds.Width
} else {
newX += parentBounds.Width
}
}
workAreaOffsetX := p.screen.WorkArea.X - p.screen.Bounds.X
workAreaOffsetY := p.screen.WorkArea.Y - p.screen.Bounds.Y
p.screen.Bounds.X = newX
p.screen.Bounds.Y = newY
p.screen.WorkArea.X = newX + workAreaOffsetX
p.screen.WorkArea.Y = newY + workAreaOffsetY
}

View file

@ -4,52 +4,58 @@ package screen
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
) )
// Options holds configuration for the screen service.
// Use: svc, err := screen.Register(platform)(core.New())
type Options struct{} type Options struct{}
// Service is a core.Service providing screen/display queries via IPC.
// Use: svc, err := screen.Register(platform)(core.New())
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
platform Platform platform Platform
} }
// Register(p) binds the screen service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(screen.Register(wailsScreen)) // Use: core.WithService(screen.Register(platform))
func Register(p Platform) func(*core.Core) core.Result { func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) core.Result { return func(c *core.Core) (any, error) {
return core.Result{Value: &Service{ return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
}, OK: true} }, nil
} }
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
return core.Result{OK: true} return nil
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents is auto-discovered by core.WithService.
return core.Result{OK: true} // Use: _ = svc.HandleIPCEvents(core, msg)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) { switch q := q.(type) {
case QueryAll: case QueryAll:
return core.Result{Value: s.platform.GetAll(), OK: true} return s.platform.GetAll(), true, nil
case QueryPrimary: case QueryPrimary:
return core.Result{Value: s.platform.GetPrimary(), OK: true} return s.platform.GetPrimary(), true, nil
case QueryByID: case QueryByID:
return core.Result{Value: s.queryByID(q.ID), OK: true} return s.queryByID(q.ID), true, nil
case QueryAtPoint: case QueryAtPoint:
return core.Result{Value: s.queryAtPoint(q.X, q.Y), OK: true} return s.queryAtPoint(q.X, q.Y), true, nil
case QueryWorkAreas: case QueryWorkAreas:
return core.Result{Value: s.queryWorkAreas(), OK: true} return s.queryWorkAreas(), true, nil
case QueryCurrent:
return core.Result{Value: s.platform.GetCurrent(), OK: true}
default: default:
return core.Result{} return nil, false, nil
} }
} }

View file

@ -5,14 +5,13 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type mockPlatform struct { type mockPlatform struct {
screens []Screen screens []Screen
current *Screen
} }
func (m *mockPlatform) GetAll() []Screen { return m.screens } func (m *mockPlatform) GetAll() []Screen { return m.screens }
@ -24,12 +23,6 @@ func (m *mockPlatform) GetPrimary() *Screen {
} }
return nil return nil
} }
func (m *mockPlatform) GetCurrent() *Screen {
if m.current != nil {
return m.current
}
return m.GetPrimary()
}
func newTestService(t *testing.T) (*mockPlatform, *core.Core) { func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
t.Helper() t.Helper()
@ -49,11 +42,12 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
}, },
}, },
} }
c := core.New( c, err := core.New(
core.WithService(Register(mock)), core.WithService(Register(mock)),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return mock, c return mock, c
} }
@ -65,17 +59,19 @@ func TestRegister_Good(t *testing.T) {
func TestQueryAll_Good(t *testing.T) { func TestQueryAll_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryAll{}) result, handled, err := c.QUERY(QueryAll{})
require.True(t, r.OK) require.NoError(t, err)
screens := r.Value.([]Screen) assert.True(t, handled)
screens := result.([]Screen)
assert.Len(t, screens, 2) assert.Len(t, screens, 2)
} }
func TestQueryPrimary_Good(t *testing.T) { func TestQueryPrimary_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryPrimary{}) result, handled, err := c.QUERY(QueryPrimary{})
require.True(t, r.OK) require.NoError(t, err)
scr := r.Value.(*Screen) assert.True(t, handled)
scr := result.(*Screen)
require.NotNil(t, scr) require.NotNil(t, scr)
assert.Equal(t, "Built-in", scr.Name) assert.Equal(t, "Built-in", scr.Name)
assert.True(t, scr.IsPrimary) assert.True(t, scr.IsPrimary)
@ -83,228 +79,54 @@ func TestQueryPrimary_Good(t *testing.T) {
func TestQueryByID_Good(t *testing.T) { func TestQueryByID_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryByID{ID: "2"}) result, handled, err := c.QUERY(QueryByID{ID: "2"})
require.True(t, r.OK) require.NoError(t, err)
scr := r.Value.(*Screen) assert.True(t, handled)
scr := result.(*Screen)
require.NotNil(t, scr) require.NotNil(t, scr)
assert.Equal(t, "External", scr.Name) assert.Equal(t, "External", scr.Name)
} }
func TestQueryByID_Bad(t *testing.T) { func TestQueryByID_Bad(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryByID{ID: "99"}) result, handled, err := c.QUERY(QueryByID{ID: "99"})
require.True(t, r.OK) require.NoError(t, err)
assert.Nil(t, r.Value) assert.True(t, handled)
assert.Nil(t, result)
} }
func TestQueryAtPoint_Good(t *testing.T) { func TestQueryAtPoint_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
// Point on primary screen // Point on primary screen
r := c.QUERY(QueryAtPoint{X: 100, Y: 100}) result, handled, err := c.QUERY(QueryAtPoint{X: 100, Y: 100})
require.True(t, r.OK) require.NoError(t, err)
scr := r.Value.(*Screen) assert.True(t, handled)
scr := result.(*Screen)
require.NotNil(t, scr) require.NotNil(t, scr)
assert.Equal(t, "Built-in", scr.Name) assert.Equal(t, "Built-in", scr.Name)
// Point on external screen // Point on external screen
r2 := c.QUERY(QueryAtPoint{X: 3000, Y: 500}) result, _, _ = c.QUERY(QueryAtPoint{X: 3000, Y: 500})
scr = r2.Value.(*Screen) scr = result.(*Screen)
require.NotNil(t, scr) require.NotNil(t, scr)
assert.Equal(t, "External", scr.Name) assert.Equal(t, "External", scr.Name)
} }
func TestQueryAtPoint_Bad(t *testing.T) { func TestQueryAtPoint_Bad(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryAtPoint{X: -1000, Y: -1000}) result, handled, err := c.QUERY(QueryAtPoint{X: -1000, Y: -1000})
require.True(t, r.OK) require.NoError(t, err)
assert.Nil(t, r.Value) assert.True(t, handled)
assert.Nil(t, result)
} }
func TestQueryWorkAreas_Good(t *testing.T) { func TestQueryWorkAreas_Good(t *testing.T) {
_, c := newTestService(t) _, c := newTestService(t)
r := c.QUERY(QueryWorkAreas{}) result, handled, err := c.QUERY(QueryWorkAreas{})
require.True(t, r.OK) require.NoError(t, err)
areas := r.Value.([]Rect) assert.True(t, handled)
areas := result.([]Rect)
assert.Len(t, areas, 2) assert.Len(t, areas, 2)
assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset
} }
// --- QueryCurrent ---
func TestQueryCurrent_Good(t *testing.T) {
// current falls back to primary when not explicitly set
_, c := newTestService(t)
r := c.QUERY(QueryCurrent{})
require.True(t, r.OK)
scr := r.Value.(*Screen)
require.NotNil(t, scr)
assert.True(t, scr.IsPrimary)
assert.Equal(t, "Built-in", scr.Name)
}
func TestQueryCurrent_Bad(t *testing.T) {
// no screens at all → GetCurrent returns nil
mock := &mockPlatform{screens: []Screen{}}
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
r := c.QUERY(QueryCurrent{})
require.True(t, r.OK)
assert.Nil(t, r.Value)
}
func TestQueryCurrent_Ugly(t *testing.T) {
// current is explicitly set to the external screen
mock := &mockPlatform{
screens: []Screen{
{ID: "1", Name: "Built-in", IsPrimary: true,
Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}},
{ID: "2", Name: "External",
Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}},
},
}
mock.current = &mock.screens[1]
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
r := c.QUERY(QueryCurrent{})
scr := r.Value.(*Screen)
require.NotNil(t, scr)
assert.Equal(t, "External", scr.Name)
}
// --- Rect geometry helpers ---
func TestRect_Origin_Good(t *testing.T) {
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
pt := r.Origin()
assert.Equal(t, Point{X: 10, Y: 20}, pt)
}
func TestRect_Corner_Good(t *testing.T) {
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
pt := r.Corner()
assert.Equal(t, Point{X: 110, Y: 70}, pt)
}
func TestRect_InsideCorner_Good(t *testing.T) {
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
pt := r.InsideCorner()
assert.Equal(t, Point{X: 109, Y: 69}, pt)
}
func TestRect_IsEmpty_Good(t *testing.T) {
assert.False(t, Rect{X: 0, Y: 0, Width: 1, Height: 1}.IsEmpty())
}
func TestRect_IsEmpty_Bad(t *testing.T) {
assert.True(t, Rect{}.IsEmpty())
assert.True(t, Rect{Width: 0, Height: 10}.IsEmpty())
assert.True(t, Rect{Width: 10, Height: -1}.IsEmpty())
}
func TestRect_Contains_Good(t *testing.T) {
r := Rect{X: 0, Y: 0, Width: 100, Height: 100}
assert.True(t, r.Contains(Point{X: 0, Y: 0}))
assert.True(t, r.Contains(Point{X: 50, Y: 50}))
assert.True(t, r.Contains(Point{X: 99, Y: 99}))
}
func TestRect_Contains_Bad(t *testing.T) {
r := Rect{X: 0, Y: 0, Width: 100, Height: 100}
// exclusive right/bottom edge
assert.False(t, r.Contains(Point{X: 100, Y: 50}))
assert.False(t, r.Contains(Point{X: 50, Y: 100}))
assert.False(t, r.Contains(Point{X: -1, Y: 50}))
}
func TestRect_Contains_Ugly(t *testing.T) {
// zero-size rect never contains anything
r := Rect{X: 5, Y: 5, Width: 0, Height: 0}
assert.False(t, r.Contains(Point{X: 5, Y: 5}))
}
func TestRect_RectSize_Good(t *testing.T) {
r := Rect{X: 100, Y: 200, Width: 1920, Height: 1080}
sz := r.RectSize()
assert.Equal(t, Size{Width: 1920, Height: 1080}, sz)
}
func TestRect_Intersect_Good(t *testing.T) {
a := Rect{X: 0, Y: 0, Width: 100, Height: 100}
b := Rect{X: 50, Y: 50, Width: 100, Height: 100}
overlap := a.Intersect(b)
assert.Equal(t, Rect{X: 50, Y: 50, Width: 50, Height: 50}, overlap)
}
func TestRect_Intersect_Bad(t *testing.T) {
// no overlap
a := Rect{X: 0, Y: 0, Width: 50, Height: 50}
b := Rect{X: 100, Y: 100, Width: 50, Height: 50}
overlap := a.Intersect(b)
assert.True(t, overlap.IsEmpty())
}
func TestRect_Intersect_Ugly(t *testing.T) {
// empty rect intersects nothing
a := Rect{X: 0, Y: 0, Width: 0, Height: 0}
b := Rect{X: 0, Y: 0, Width: 100, Height: 100}
overlap := a.Intersect(b)
assert.True(t, overlap.IsEmpty())
}
// --- ScreenPlacement ---
func TestScreenPlacement_Apply_Good(t *testing.T) {
// secondary placed to the RIGHT of primary, no offset
primary := &Screen{
Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600},
WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562},
}
secondary := &Screen{
Bounds: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080},
WorkArea: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080},
}
NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin).Apply()
assert.Equal(t, 2560, secondary.Bounds.X)
assert.Equal(t, 0, secondary.Bounds.Y)
assert.Equal(t, 2560, secondary.WorkArea.X)
}
func TestScreenPlacement_Apply_Bad(t *testing.T) {
// screen placed ABOVE primary: newY = primary.Y - secondary.Height
primary := &Screen{
Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
}
secondary := &Screen{
Bounds: Rect{X: 0, Y: -600, Width: 1920, Height: 600},
WorkArea: Rect{X: 0, Y: -600, Width: 1920, Height: 600},
}
NewPlacement(secondary, primary, AlignTop, 0, OffsetBegin).Apply()
assert.Equal(t, 0, secondary.Bounds.X)
assert.Equal(t, -600, secondary.Bounds.Y)
}
func TestScreenPlacement_Apply_Ugly(t *testing.T) {
// END offset reference — places secondary flush to the bottom-right of parent
primary := &Screen{
Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
}
secondary := &Screen{
Bounds: Rect{X: 0, Y: 0, Width: 800, Height: 600},
WorkArea: Rect{X: 0, Y: 0, Width: 800, Height: 600},
}
// AlignBottom + OffsetEnd + offset=0 → secondary starts at right edge of parent
NewPlacement(secondary, primary, AlignBottom, 0, OffsetEnd).Apply()
assert.Equal(t, 1920-800, secondary.Bounds.X) // flush right
assert.Equal(t, 1080, secondary.Bounds.Y) // just below parent
}

View file

@ -1,21 +1,28 @@
// pkg/systray/menu.go // pkg/systray/menu.go
package systray package systray
import coreerr "dappco.re/go/core/log" import "forge.lthn.ai/core/go/pkg/core"
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors. // SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
// Use: _ = m.SetMenu([]TrayMenuItem{{Label: "Quit", ActionID: "quit"}})
func (m *Manager) SetMenu(items []TrayMenuItem) error { func (m *Manager) SetMenu(items []TrayMenuItem) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.SetMenu", "tray not initialised", nil) return core.E("systray.SetMenu", "tray not initialised", nil)
} }
menu := m.platform.NewMenu() m.menuItems = append([]TrayMenuItem(nil), items...)
m.buildMenu(menu, items) menu := m.buildMenu(items)
m.tray.SetMenu(menu) m.tray.SetMenu(menu)
return nil return nil
} }
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors. // buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) { func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
menu := m.platform.NewMenu()
m.buildMenuInto(menu, items)
return menu
}
func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) {
for _, item := range items { for _, item := range items {
if item.Type == "separator" { if item.Type == "separator" {
menu.AddSeparator() menu.AddSeparator()
@ -23,7 +30,7 @@ func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
} }
if len(item.Submenu) > 0 { if len(item.Submenu) > 0 {
sub := menu.AddSubmenu(item.Label) sub := menu.AddSubmenu(item.Label)
m.buildMenu(sub, item.Submenu) m.buildMenuInto(sub, item.Submenu)
continue continue
} }
mi := menu.Add(item.Label) mi := menu.Add(item.Label)
@ -48,6 +55,7 @@ func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
} }
// RegisterCallback registers a callback for a menu action ID. // RegisterCallback registers a callback for a menu action ID.
// Use: m.RegisterCallback("quit", func() { _ = app.Quit() })
func (m *Manager) RegisterCallback(actionID string, callback func()) { func (m *Manager) RegisterCallback(actionID string, callback func()) {
m.mu.Lock() m.mu.Lock()
m.callbacks[actionID] = callback m.callbacks[actionID] = callback
@ -55,6 +63,7 @@ func (m *Manager) RegisterCallback(actionID string, callback func()) {
} }
// UnregisterCallback removes a callback. // UnregisterCallback removes a callback.
// Use: m.UnregisterCallback("quit")
func (m *Manager) UnregisterCallback(actionID string) { func (m *Manager) UnregisterCallback(actionID string) {
m.mu.Lock() m.mu.Lock()
delete(m.callbacks, actionID) delete(m.callbacks, actionID)
@ -62,6 +71,7 @@ func (m *Manager) UnregisterCallback(actionID string) {
} }
// GetCallback returns the callback for an action ID. // GetCallback returns the callback for an action ID.
// Use: callback, ok := m.GetCallback("quit")
func (m *Manager) GetCallback(actionID string) (func(), bool) { func (m *Manager) GetCallback(actionID string) (func(), bool) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@ -70,8 +80,14 @@ func (m *Manager) GetCallback(actionID string) (func(), bool) {
} }
// GetInfo returns tray status information. // GetInfo returns tray status information.
// Use: info := m.GetInfo()
func (m *Manager) GetInfo() map[string]any { func (m *Manager) GetInfo() map[string]any {
return map[string]any{ return map[string]any{
"active": m.IsActive(), "active": m.IsActive(),
"tooltip": m.tooltip,
"label": m.label,
"hasIcon": m.hasIcon,
"hasTemplateIcon": m.hasTemplateIcon,
"menuItems": append([]TrayMenuItem(nil), m.menuItems...),
} }
} }

View file

@ -1,17 +1,54 @@
// pkg/systray/messages.go
package systray package systray
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
// Use: result, _, err := c.QUERY(systray.QueryConfig{})
type QueryConfig struct{} type QueryConfig struct{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayIcon{Data: iconBytes})
type TaskSetTrayIcon struct{ Data []byte } type TaskSetTrayIcon struct{ Data []byte }
// TaskSetTooltip updates the tray tooltip text.
// Use: _, _, err := c.PERFORM(systray.TaskSetTooltip{Tooltip: "Core is ready"})
type TaskSetTooltip struct{ Tooltip string }
// TaskSetLabel updates the tray label text.
// Use: _, _, err := c.PERFORM(systray.TaskSetLabel{Label: "Core"})
type TaskSetLabel struct{ Label string }
// TaskSetTrayMenu sets the tray menu items.
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayMenu{Items: items})
type TaskSetTrayMenu struct{ Items []TrayMenuItem } type TaskSetTrayMenu struct{ Items []TrayMenuItem }
// TaskShowPanel shows the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskShowPanel{})
type TaskShowPanel struct{} type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskHidePanel{})
type TaskHidePanel struct{} type TaskHidePanel struct{}
type TaskSaveConfig struct{ Config map[string]any } // TaskShowMessage shows a tray message or notification.
// Use: _, _, err := c.PERFORM(systray.TaskShowMessage{Title: "Core", Message: "Sync complete"})
type TaskShowMessage struct {
Title string `json:"title"`
Message string `json:"message"`
}
// TaskSaveConfig persists this service's config section via the display orchestrator.
// Use: _, _, err := c.PERFORM(systray.TaskSaveConfig{Value: map[string]any{"tooltip": "Core"}})
type TaskSaveConfig struct{ Value map[string]any }
// --- Actions ---
// ActionTrayClicked is broadcast when the tray icon is clicked.
// Use: _ = c.ACTION(systray.ActionTrayClicked{})
type ActionTrayClicked struct{} type ActionTrayClicked struct{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
// Use: _ = c.ACTION(systray.ActionTrayMenuItemClicked{ActionID: "quit"})
type ActionTrayMenuItemClicked struct{ ActionID string } type ActionTrayMenuItemClicked struct{ ActionID string }

View file

@ -1,11 +1,20 @@
// pkg/systray/mock_platform.go
package systray package systray
// MockPlatform is an exported mock for cross-package integration tests. // MockPlatform is an exported mock for cross-package integration tests.
// Use: platform := systray.NewMockPlatform()
type MockPlatform struct{} type MockPlatform struct{}
// NewMockPlatform creates a tray platform mock.
// Use: platform := systray.NewMockPlatform()
func NewMockPlatform() *MockPlatform { return &MockPlatform{} } func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
// NewTray creates a mock tray handle for tests.
// Use: tray := platform.NewTray()
func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} } func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} }
// NewMenu creates a mock tray menu for tests.
// Use: menu := platform.NewMenu()
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} } func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} }
type exportedMockTray struct { type exportedMockTray struct {
@ -22,7 +31,7 @@ func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
type exportedMockMenu struct { type exportedMockMenu struct {
items []exportedMockMenuItem items []exportedMockMenuItem
subs []*exportedMockMenu submenus []*exportedMockMenu
} }
func (m *exportedMockMenu) Add(label string) PlatformMenuItem { func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
@ -32,9 +41,9 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
} }
func (m *exportedMockMenu) AddSeparator() {} func (m *exportedMockMenu) AddSeparator() {}
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu { func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
m.items = append(m.items, exportedMockMenuItem{label: label})
sub := &exportedMockMenu{} sub := &exportedMockMenu{}
m.subs = append(m.subs, sub) m.items = append(m.items, exportedMockMenuItem{label: label})
m.submenus = append(m.submenus, sub)
return sub return sub
} }
@ -48,3 +57,4 @@ func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked } func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled } func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn } func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }

View file

@ -22,7 +22,7 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
type mockTrayMenu struct { type mockTrayMenu struct {
items []string items []string
subs []*mockTrayMenu submenus []*mockTrayMenu
} }
func (m *mockTrayMenu) Add(label string) PlatformMenuItem { func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
@ -33,7 +33,7 @@ func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu { func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
m.items = append(m.items, label) m.items = append(m.items, label)
sub := &mockTrayMenu{} sub := &mockTrayMenu{}
m.subs = append(m.subs, sub) m.submenus = append(m.submenus, sub)
return sub return sub
} }
@ -43,6 +43,7 @@ func (mi *mockTrayMenuItem) SetTooltip(text string) {}
func (mi *mockTrayMenuItem) SetChecked(checked bool) {} func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {} func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
func (mi *mockTrayMenuItem) OnClick(fn func()) {} func (mi *mockTrayMenuItem) OnClick(fn func()) {}
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
type mockTray struct { type mockTray struct {
icon, templateIcon []byte icon, templateIcon []byte

View file

@ -2,12 +2,14 @@
package systray package systray
// Platform abstracts the system tray backend. // Platform abstracts the system tray backend.
// Use: var p systray.Platform
type Platform interface { type Platform interface {
NewTray() PlatformTray NewTray() PlatformTray
NewMenu() PlatformMenu // Menu factory for building tray menus NewMenu() PlatformMenu // Menu factory for building tray menus
} }
// PlatformTray is a live tray handle from the backend. // PlatformTray is a live tray handle from the backend.
// Use: var tray systray.PlatformTray
type PlatformTray interface { type PlatformTray interface {
SetIcon(data []byte) SetIcon(data []byte)
SetTemplateIcon(data []byte) SetTemplateIcon(data []byte)
@ -18,6 +20,7 @@ type PlatformTray interface {
} }
// PlatformMenu is a tray menu built by the backend. // PlatformMenu is a tray menu built by the backend.
// Use: var menu systray.PlatformMenu
type PlatformMenu interface { type PlatformMenu interface {
Add(label string) PlatformMenuItem Add(label string) PlatformMenuItem
AddSeparator() AddSeparator()
@ -25,16 +28,19 @@ type PlatformMenu interface {
} }
// PlatformMenuItem is a single item in a tray menu. // PlatformMenuItem is a single item in a tray menu.
// Use: var item systray.PlatformMenuItem
type PlatformMenuItem interface { type PlatformMenuItem interface {
SetTooltip(text string) SetTooltip(text string)
SetChecked(checked bool) SetChecked(checked bool)
SetEnabled(enabled bool) SetEnabled(enabled bool)
OnClick(fn func()) OnClick(fn func())
AddSubmenu() PlatformMenu
} }
// WindowHandle is a cross-package interface for window operations. // WindowHandle is a cross-package interface for window operations.
// Defined locally to avoid circular imports (display imports systray). // Defined locally to avoid circular imports (display imports systray).
// pkg/window.PlatformWindow satisfies this implicitly. // pkg/window.PlatformWindow satisfies this implicitly.
// Use: var w systray.WindowHandle
type WindowHandle interface { type WindowHandle interface {
Name() string Name() string
Show() Show()

View file

@ -1,15 +1,15 @@
// pkg/systray/register.go
package systray package systray
import core "dappco.re/go/core" import "forge.lthn.ai/core/go/pkg/core"
// Register(p) binds the systray service to a Core instance. // Register creates a factory closure that captures the Platform adapter.
// core.WithService(systray.Register(wailsSystray)) func Register(p Platform) func(*core.Core) (any, error) {
func Register(p Platform) func(*core.Core) core.Result { return func(c *core.Core) (any, error) {
return func(c *core.Core) core.Result { return &Service{
return core.Result{Value: &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p, platform: p,
manager: NewManager(p), manager: NewManager(p),
}, OK: true} }, nil
} }
} }

View file

@ -1,13 +1,19 @@
// pkg/systray/service.go
package systray package systray
import ( import (
"context" "context"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/notification"
) )
// Options configures the systray service.
// Use: core.WithService(systray.Register(platform))
type Options struct{} type Options struct{}
// Service manages system tray operations via Core tasks.
// Use: svc := &systray.Service{}
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
manager *Manager manager *Manager
@ -15,48 +21,57 @@ type Service struct {
iconPath string iconPath string
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup loads tray config and registers task handlers.
r := s.Core().QUERY(QueryConfig{}) // Use: _ = svc.OnStartup(context.Background())
if r.OK { func (s *Service) OnStartup(ctx context.Context) error {
if trayConfig, ok := r.Value.(map[string]any); ok { cfg, handled, _ := s.Core().QUERY(QueryConfig{})
s.applyConfig(trayConfig) if handled {
if tCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(tCfg)
} }
} }
s.Core().Action("systray.setIcon", func(_ context.Context, opts core.Options) core.Result { s.Core().RegisterTask(s.handleTask)
t, _ := opts.Get("task").Value.(TaskSetTrayIcon) return nil
return core.Result{Value: nil, OK: true}.New(s.manager.SetIcon(t.Data))
})
s.Core().Action("systray.setMenu", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskSetTrayMenu)
return core.Result{Value: nil, OK: true}.New(s.taskSetTrayMenu(t))
})
s.Core().Action("systray.showPanel", func(_ context.Context, _ core.Options) core.Result {
// Panel show — deferred (requires WindowHandle integration)
return core.Result{OK: true}
})
s.Core().Action("systray.hidePanel", func(_ context.Context, _ core.Options) core.Result {
// Panel hide — deferred (requires WindowHandle integration)
return core.Result{OK: true}
})
return core.Result{OK: true}
} }
func (s *Service) applyConfig(configData map[string]any) { func (s *Service) applyConfig(cfg map[string]any) {
tooltip, _ := configData["tooltip"].(string) tooltip, _ := cfg["tooltip"].(string)
if tooltip == "" { if tooltip == "" {
tooltip = "Core" tooltip = "Core"
} }
_ = s.manager.Setup(tooltip, tooltip) _ = s.manager.Setup(tooltip, tooltip)
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" { if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
// Icon loading is deferred to when assets are available. // Icon loading is deferred to when assets are available.
// Store the path for later use. // Store the path for later use.
s.iconPath = iconPath s.iconPath = iconPath
} }
} }
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { // HandleIPCEvents satisfies Core's IPC hook.
return core.Result{OK: true} func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetTrayIcon:
return nil, true, s.manager.SetIcon(t.Data)
case TaskSetTooltip:
return nil, true, s.manager.SetTooltip(t.Tooltip)
case TaskSetLabel:
return nil, true, s.manager.SetLabel(t.Label)
case TaskSetTrayMenu:
return nil, true, s.taskSetTrayMenu(t)
case TaskShowPanel:
return nil, true, s.manager.ShowPanel()
case TaskHidePanel:
return nil, true, s.manager.HidePanel()
case TaskShowMessage:
return nil, true, s.showTrayMessage(t.Title, t.Message)
default:
return nil, false, nil
}
} }
func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error { func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
@ -72,6 +87,29 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
return s.manager.SetMenu(t.Items) return s.manager.SetMenu(t.Items)
} }
func (s *Service) showTrayMessage(title, message string) error {
if s.manager == nil || !s.manager.IsActive() {
_, _, err := s.Core().PERFORM(notification.TaskSend{
Opts: notification.NotificationOptions{Title: title, Message: message},
})
return err
}
tray := s.manager.Tray()
if tray == nil {
return core.E("systray.showTrayMessage", "tray not initialised", nil)
}
if messenger, ok := tray.(interface{ ShowMessage(title, message string) }); ok {
messenger.ShowMessage(title, message)
return nil
}
_, _, err := s.Core().PERFORM(notification.TaskSend{
Opts: notification.NotificationOptions{Title: title, Message: message},
})
return err
}
// Manager returns the underlying systray Manager.
// Use: manager := svc.Manager()
func (s *Service) Manager() *Manager { func (s *Service) Manager() *Manager {
return s.manager return s.manager
} }

View file

@ -4,28 +4,35 @@ import (
"context" "context"
"testing" "testing"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type mockWindowHandle struct {
name string
showCalled bool
hideCalled bool
}
func (w *mockWindowHandle) Name() string { return w.name }
func (w *mockWindowHandle) Show() { w.showCalled = true }
func (w *mockWindowHandle) Hide() { w.hideCalled = true }
func (w *mockWindowHandle) SetPosition(x, y int) {}
func (w *mockWindowHandle) SetSize(width, height int) {}
func newTestSystrayService(t *testing.T) (*Service, *core.Core) { func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
t.Helper() t.Helper()
c := core.New( c, err := core.New(
core.WithService(Register(newMockPlatform())), core.WithService(Register(newMockPlatform())),
core.WithServiceLock(), core.WithServiceLock(),
) )
require.True(t, c.ServiceStartup(context.Background(), nil).OK) require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "systray") svc := core.MustServiceFor[*Service](c, "systray")
return svc, c return svc, c
} }
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
func TestRegister_Good(t *testing.T) { func TestRegister_Good(t *testing.T) {
svc, _ := newTestSystrayService(t) svc, _ := newTestSystrayService(t)
assert.NotNil(t, svc) assert.NotNil(t, svc)
@ -39,8 +46,27 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
require.NoError(t, svc.manager.Setup("Test", "Test")) require.NoError(t, svc.manager.Setup("Test", "Test"))
icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header
r := taskRun(c, "systray.setIcon", TaskSetTrayIcon{Data: icon}) _, handled, err := c.PERFORM(TaskSetTrayIcon{Data: icon})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetTooltip_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetTooltip{Tooltip: "Updated"})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetLabel_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetLabel{Label: "Updated"})
require.NoError(t, err)
assert.True(t, handled)
} }
func TestTaskSetTrayMenu_Good(t *testing.T) { func TestTaskSetTrayMenu_Good(t *testing.T) {
@ -53,13 +79,68 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
{Type: "separator"}, {Type: "separator"},
{Label: "Quit", ActionID: "quit"}, {Label: "Quit", ActionID: "quit"},
} }
r := taskRun(c, "systray.setMenu", TaskSetTrayMenu{Items: items}) _, handled, err := c.PERFORM(TaskSetTrayMenu{Items: items})
require.True(t, r.OK) require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetTrayMenu_Submenu_Good(t *testing.T) {
p := newMockPlatform()
c, err := core.New(
core.WithService(Register(p)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "systray")
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: []TrayMenuItem{
{
Label: "File",
Submenu: []TrayMenuItem{
{Label: "Open", ActionID: "open"},
},
},
}})
require.NoError(t, err)
assert.True(t, handled)
require.Len(t, p.trays, 1)
require.NotEmpty(t, p.menus)
require.Len(t, p.menus[0].submenus, 1)
} }
func TestTaskSetTrayIcon_Bad(t *testing.T) { func TestTaskSetTrayIcon_Bad(t *testing.T) {
// No systray service — action is not registered // No systray service — PERFORM returns handled=false
c := core.New(core.WithServiceLock()) c, err := core.New(core.WithServiceLock())
r := c.Action("systray.setIcon").Run(context.Background(), core.NewOptions()) require.NoError(t, err)
assert.False(t, r.OK) _, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
assert.False(t, handled)
}
func TestTaskShowMessage_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskShowMessage{Title: "Hello", Message: "World"})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskShowHidePanel_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
panel := &mockWindowHandle{name: "panel"}
require.NoError(t, svc.manager.AttachWindow(panel))
_, handled, err := c.PERFORM(TaskShowPanel{})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, panel.showCalled)
_, handled, err = c.PERFORM(TaskHidePanel{})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, panel.hideCalled)
} }

View file

@ -5,22 +5,29 @@ import (
_ "embed" _ "embed"
"sync" "sync"
coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/go/pkg/core"
) )
//go:embed assets/apptray.png //go:embed assets/apptray.png
var defaultIcon []byte var defaultIcon []byte
// Manager manages the system tray lifecycle. // Manager manages the system tray lifecycle.
// State that was previously in package-level vars is now on the Manager. // Use: manager := systray.NewManager(platform)
type Manager struct { type Manager struct {
platform Platform platform Platform
tray PlatformTray tray PlatformTray
panelWindow WindowHandle
callbacks map[string]func() callbacks map[string]func()
tooltip string
label string
hasIcon bool
hasTemplateIcon bool
menuItems []TrayMenuItem
mu sync.RWMutex mu sync.RWMutex
} }
// NewManager creates a systray Manager. // NewManager creates a systray Manager.
// Use: manager := systray.NewManager(platform)
func NewManager(platform Platform) *Manager { func NewManager(platform Platform) *Manager {
return &Manager{ return &Manager{
platform: platform, platform: platform,
@ -29,68 +36,112 @@ func NewManager(platform Platform) *Manager {
} }
// Setup creates the system tray with default icon and tooltip. // Setup creates the system tray with default icon and tooltip.
// Use: _ = manager.Setup("Core", "Core")
func (m *Manager) Setup(tooltip, label string) error { func (m *Manager) Setup(tooltip, label string) error {
m.tray = m.platform.NewTray() m.tray = m.platform.NewTray()
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.Setup", "platform returned nil tray", nil) return core.E("systray.Setup", "platform returned nil tray", nil)
} }
m.tray.SetTemplateIcon(defaultIcon) m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip) m.tray.SetTooltip(tooltip)
m.tray.SetLabel(label) m.tray.SetLabel(label)
m.tooltip = tooltip
m.label = label
m.hasTemplateIcon = true
return nil return nil
} }
// SetIcon sets the tray icon. // SetIcon sets the tray icon.
// Use: _ = manager.SetIcon(iconBytes)
func (m *Manager) SetIcon(data []byte) error { func (m *Manager) SetIcon(data []byte) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.SetIcon", "tray not initialised", nil) return core.E("systray.SetIcon", "tray not initialised", nil)
} }
m.tray.SetIcon(data) m.tray.SetIcon(data)
m.hasIcon = len(data) > 0
return nil return nil
} }
// SetTemplateIcon sets the template icon (macOS). // SetTemplateIcon sets the template icon (macOS).
// Use: _ = manager.SetTemplateIcon(iconBytes)
func (m *Manager) SetTemplateIcon(data []byte) error { func (m *Manager) SetTemplateIcon(data []byte) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil) return core.E("systray.SetTemplateIcon", "tray not initialised", nil)
} }
m.tray.SetTemplateIcon(data) m.tray.SetTemplateIcon(data)
m.hasTemplateIcon = len(data) > 0
return nil return nil
} }
// SetTooltip sets the tray tooltip. // SetTooltip sets the tray tooltip.
// Use: _ = manager.SetTooltip("Core is ready")
func (m *Manager) SetTooltip(text string) error { func (m *Manager) SetTooltip(text string) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.SetTooltip", "tray not initialised", nil) return core.E("systray.SetTooltip", "tray not initialised", nil)
} }
m.tray.SetTooltip(text) m.tray.SetTooltip(text)
m.tooltip = text
return nil return nil
} }
// SetLabel sets the tray label. // SetLabel sets the tray label.
// Use: _ = manager.SetLabel("Core")
func (m *Manager) SetLabel(text string) error { func (m *Manager) SetLabel(text string) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.SetLabel", "tray not initialised", nil) return core.E("systray.SetLabel", "tray not initialised", nil)
} }
m.tray.SetLabel(text) m.tray.SetLabel(text)
m.label = text
return nil return nil
} }
// AttachWindow attaches a panel window to the tray. // AttachWindow attaches a panel window to the tray.
// Use: _ = manager.AttachWindow(windowHandle)
func (m *Manager) AttachWindow(w WindowHandle) error { func (m *Manager) AttachWindow(w WindowHandle) error {
if m.tray == nil { if m.tray == nil {
return coreerr.E("systray.AttachWindow", "tray not initialised", nil) return core.E("systray.AttachWindow", "tray not initialised", nil)
} }
m.mu.Lock()
m.panelWindow = w
m.mu.Unlock()
m.tray.AttachWindow(w) m.tray.AttachWindow(w)
return nil return nil
} }
// ShowPanel shows the attached tray panel window if one is configured.
// Use: _ = manager.ShowPanel()
func (m *Manager) ShowPanel() error {
m.mu.RLock()
w := m.panelWindow
m.mu.RUnlock()
if w == nil {
return nil
}
w.Show()
return nil
}
// HidePanel hides the attached tray panel window if one is configured.
// Use: _ = manager.HidePanel()
func (m *Manager) HidePanel() error {
m.mu.RLock()
w := m.panelWindow
m.mu.RUnlock()
if w == nil {
return nil
}
w.Hide()
return nil
}
// Tray returns the underlying platform tray for direct access. // Tray returns the underlying platform tray for direct access.
// Use: tray := manager.Tray()
func (m *Manager) Tray() PlatformTray { func (m *Manager) Tray() PlatformTray {
return m.tray return m.tray
} }
// IsActive returns whether a tray has been created. // IsActive returns whether a tray has been created.
// Use: active := manager.IsActive()
func (m *Manager) IsActive() bool { func (m *Manager) IsActive() bool {
return m.tray != nil return m.tray != nil
} }

View file

@ -84,29 +84,3 @@ func TestManager_GetInfo_Good(t *testing.T) {
info = m.GetInfo() info = m.GetInfo()
assert.True(t, info["active"].(bool)) assert.True(t, info["active"].(bool))
} }
func TestManager_Build_Submenu_Recursive_Good(t *testing.T) {
m, p := newTestManager()
require.NoError(t, m.Setup("Core", "Core"))
items := []TrayMenuItem{
{
Label: "Parent",
Submenu: []TrayMenuItem{
{Label: "Child 1"},
{Label: "Child 2"},
},
},
}
require.NoError(t, m.SetMenu(items))
require.Len(t, p.menus, 1)
menu := p.menus[0]
require.Len(t, menu.items, 1)
assert.Equal(t, "Parent", menu.items[0])
require.Len(t, menu.subs, 1)
require.Len(t, menu.subs[0].items, 2)
assert.Equal(t, "Child 1", menu.subs[0].items[0])
assert.Equal(t, "Child 2", menu.subs[0].items[1])
}

View file

@ -6,25 +6,31 @@ import (
) )
// WailsPlatform implements Platform using Wails v3. // WailsPlatform implements Platform using Wails v3.
// Use: platform := systray.NewWailsPlatform(app)
type WailsPlatform struct { type WailsPlatform struct {
app *application.App app *application.App
} }
// NewWailsPlatform creates a Wails-backed tray platform.
// Use: platform := systray.NewWailsPlatform(app)
func NewWailsPlatform(app *application.App) *WailsPlatform { func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app} return &WailsPlatform{app: app}
} }
// NewTray creates a Wails system tray handle.
// Use: tray := platform.NewTray()
func (wp *WailsPlatform) NewTray() PlatformTray { func (wp *WailsPlatform) NewTray() PlatformTray {
return &wailsTray{tray: wp.app.SystemTray.New(), app: wp.app} return &wailsTray{tray: wp.app.SystemTray.New()}
} }
// NewMenu creates a Wails tray menu handle.
// Use: menu := platform.NewMenu()
func (wp *WailsPlatform) NewMenu() PlatformMenu { func (wp *WailsPlatform) NewMenu() PlatformMenu {
return &wailsTrayMenu{menu: wp.app.NewMenu()} return &wailsTrayMenu{menu: wp.app.NewMenu()}
} }
type wailsTray struct { type wailsTray struct {
tray *application.SystemTray tray *application.SystemTray
app *application.App
} }
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) } func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
@ -39,8 +45,19 @@ func (wt *wailsTray) SetMenu(menu PlatformMenu) {
} }
func (wt *wailsTray) AttachWindow(w WindowHandle) { func (wt *wailsTray) AttachWindow(w WindowHandle) {
// Wails systray AttachWindow expects an application.Window interface. if wt.tray == nil {
// The caller must pass an appropriate wrapper. return
}
window, ok := w.(interface {
Show()
Hide()
Focus()
IsVisible() bool
})
if !ok {
return
}
wt.tray.AttachWindow(window)
} }
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. // wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
@ -71,3 +88,7 @@ func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabl
func (mi *wailsTrayMenuItem) OnClick(fn func()) { func (mi *wailsTrayMenuItem) OnClick(fn func()) {
mi.item.OnClick(func(ctx *application.Context) { fn() }) mi.item.OnClick(func(ctx *application.Context) { fn() })
} }
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
return &wailsTrayMenu{menu: application.NewMenu()}
}

34
pkg/systray/wails_test.go Normal file
View file

@ -0,0 +1,34 @@
package systray
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/application"
)
func TestWailsTray_AttachWindow_Good(t *testing.T) {
app := application.NewApp()
platform := NewWailsPlatform(app)
tray, ok := platform.NewTray().(*wailsTray)
require.True(t, ok)
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "panel",
Title: "Panel",
Hidden: true,
})
tray.AttachWindow(window)
assert.False(t, window.IsVisible())
tray.tray.Click()
assert.True(t, window.IsVisible())
assert.True(t, window.IsFocused())
tray.tray.Click()
assert.False(t, window.IsVisible())
}

158
pkg/webview/diagnostics.go Normal file
View file

@ -0,0 +1,158 @@
// pkg/webview/diagnostics.go
package webview
import (
"encoding/json"
"fmt"
"strings"
)
func jsQuote(v string) string {
b, _ := json.Marshal(v)
return string(b)
}
func computedStyleScript(selector string) string {
sel := jsQuote(selector)
return fmt.Sprintf(`(function(){
const el = document.querySelector(%s);
if (!el) return null;
const style = window.getComputedStyle(el);
const out = {};
for (let i = 0; i < style.length; i++) {
const key = style[i];
out[key] = style.getPropertyValue(key);
}
return out;
})()`, sel)
}
func highlightScript(selector, colour string) string {
sel := jsQuote(selector)
if colour == "" {
colour = "#ff9800"
}
col := jsQuote(colour)
return fmt.Sprintf(`(function(){
const el = document.querySelector(%s);
if (!el) return false;
if (el.__coreHighlightOrigOutline === undefined) {
el.__coreHighlightOrigOutline = el.style.outline || "";
}
el.style.outline = "3px solid " + %s;
el.style.outlineOffset = "2px";
try { el.scrollIntoView({block: "center", inline: "center", behavior: "smooth"}); } catch (e) {}
return true;
})()`, sel, col)
}
func performanceScript() string {
return `(function(){
const nav = performance.getEntriesByType("navigation")[0] || {};
const paints = performance.getEntriesByType("paint");
const firstPaint = paints.find((entry) => entry.name === "first-paint");
const firstContentfulPaint = paints.find((entry) => entry.name === "first-contentful-paint");
const memory = performance.memory || {};
return {
navigationStart: nav.startTime || 0,
domContentLoaded: nav.domContentLoadedEventEnd || 0,
loadEventEnd: nav.loadEventEnd || 0,
firstPaint: firstPaint ? firstPaint.startTime : 0,
firstContentfulPaint: firstContentfulPaint ? firstContentfulPaint.startTime : 0,
usedJSHeapSize: memory.usedJSHeapSize || 0,
totalJSHeapSize: memory.totalJSHeapSize || 0
};
})()`
}
func resourcesScript() string {
return `(function(){
return performance.getEntriesByType("resource").map((entry) => ({
name: entry.name,
entryType: entry.entryType,
initiatorType: entry.initiatorType,
startTime: entry.startTime,
duration: entry.duration,
transferSize: entry.transferSize || 0,
encodedBodySize: entry.encodedBodySize || 0,
decodedBodySize: entry.decodedBodySize || 0
}));
})()`
}
func networkInitScript() string {
return `(function(){
if (window.__coreNetworkLog) return true;
window.__coreNetworkLog = [];
const log = (entry) => { window.__coreNetworkLog.push(entry); };
const originalFetch = window.fetch;
if (originalFetch) {
window.fetch = async function(input, init) {
const request = typeof input === "string" ? input : (input && input.url) ? input.url : "";
const method = (init && init.method) || (input && input.method) || "GET";
const started = Date.now();
try {
const response = await originalFetch.call(this, input, init);
log({
url: response.url || request,
method: method,
status: response.status,
ok: response.ok,
resource: "fetch",
timestamp: started
});
return response;
} catch (error) {
log({
url: request,
method: method,
error: String(error),
resource: "fetch",
timestamp: started
});
throw error;
}
};
}
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this.__coreMethod = method;
this.__coreUrl = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
const started = Date.now();
this.addEventListener("loadend", () => {
log({
url: this.__coreUrl || "",
method: this.__coreMethod || "GET",
status: this.status || 0,
ok: this.status >= 200 && this.status < 400,
resource: "xhr",
timestamp: started
});
});
return originalSend.apply(this, arguments);
};
return true;
})()`
}
func networkClearScript() string {
return `(function(){
window.__coreNetworkLog = [];
return true;
})()`
}
func networkLogScript(limit int) string {
if limit <= 0 {
return `(window.__coreNetworkLog || [])`
}
return fmt.Sprintf(`(window.__coreNetworkLog || []).slice(-%d)`, limit)
}
func normalizeWhitespace(s string) string {
return strings.TrimSpace(s)
}

View file

@ -6,12 +6,19 @@ import "time"
// --- Queries (read-only) --- // --- Queries (read-only) ---
// QueryURL gets the current page URL. Result: string // QueryURL gets the current page URL. Result: string
type QueryURL struct{ Window string `json:"window"` } // Use: result, _, err := c.QUERY(webview.QueryURL{Window: "editor"})
type QueryURL struct {
Window string `json:"window"`
}
// QueryTitle gets the current page title. Result: string // QueryTitle gets the current page title. Result: string
type QueryTitle struct{ Window string `json:"window"` } // Use: result, _, err := c.QUERY(webview.QueryTitle{Window: "editor"})
type QueryTitle struct {
Window string `json:"window"`
}
// QueryConsole gets captured console messages. Result: []ConsoleMessage // QueryConsole gets captured console messages. Result: []ConsoleMessage
// Use: result, _, err := c.QUERY(webview.QueryConsole{Window: "editor", Level: "error", Limit: 20})
type QueryConsole struct { type QueryConsole struct {
Window string `json:"window"` Window string `json:"window"`
Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug" Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug"
@ -19,38 +26,77 @@ type QueryConsole struct {
} }
// QuerySelector finds a single element. Result: *ElementInfo (nil if not found) // QuerySelector finds a single element. Result: *ElementInfo (nil if not found)
// Use: result, _, err := c.QUERY(webview.QuerySelector{Window: "editor", Selector: "#submit"})
type QuerySelector struct { type QuerySelector struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
} }
// QuerySelectorAll finds all matching elements. Result: []*ElementInfo // QuerySelectorAll finds all matching elements. Result: []*ElementInfo
// Use: result, _, err := c.QUERY(webview.QuerySelectorAll{Window: "editor", Selector: "button"})
type QuerySelectorAll struct { type QuerySelectorAll struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
} }
// QueryDOMTree gets HTML content. Result: string (outerHTML) // QueryDOMTree gets HTML content. Result: string (outerHTML)
// Use: result, _, err := c.QUERY(webview.QueryDOMTree{Window: "editor", Selector: "main"})
type QueryDOMTree struct { type QueryDOMTree struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector,omitempty"` // empty = full document Selector string `json:"selector,omitempty"` // empty = full document
} }
// QueryComputedStyle returns the computed CSS properties for an element.
// Use: result, _, err := c.QUERY(webview.QueryComputedStyle{Window: "editor", Selector: "#panel"})
type QueryComputedStyle struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// QueryPerformance returns page performance metrics.
// Use: result, _, err := c.QUERY(webview.QueryPerformance{Window: "editor"})
type QueryPerformance struct {
Window string `json:"window"`
}
// QueryResources returns the page's loaded resource entries.
// Use: result, _, err := c.QUERY(webview.QueryResources{Window: "editor"})
type QueryResources struct {
Window string `json:"window"`
}
// QueryNetwork returns the captured network log.
// Use: result, _, err := c.QUERY(webview.QueryNetwork{Window: "editor", Limit: 50})
type QueryNetwork struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
// QueryExceptions returns captured JavaScript exceptions.
// Use: result, _, err := c.QUERY(webview.QueryExceptions{Window: "editor", Limit: 10})
type QueryExceptions struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
// --- Tasks (side-effects) --- // --- Tasks (side-effects) ---
// TaskEvaluate executes JavaScript. Result: any (JS return value) // TaskEvaluate executes JavaScript. Result: any (JS return value)
// Use: _, _, err := c.PERFORM(webview.TaskEvaluate{Window: "editor", Script: "document.title"})
type TaskEvaluate struct { type TaskEvaluate struct {
Window string `json:"window"` Window string `json:"window"`
Script string `json:"script"` Script string `json:"script"`
} }
// TaskClick clicks an element. Result: nil // TaskClick clicks an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskClick{Window: "editor", Selector: "#submit"})
type TaskClick struct { type TaskClick struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
} }
// TaskType types text into an element. Result: nil // TaskType types text into an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskType{Window: "editor", Selector: "#search", Text: "core"})
type TaskType struct { type TaskType struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
@ -58,15 +104,27 @@ type TaskType struct {
} }
// TaskNavigate navigates to a URL. Result: nil // TaskNavigate navigates to a URL. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskNavigate{Window: "editor", URL: "https://example.com"})
type TaskNavigate struct { type TaskNavigate struct {
Window string `json:"window"` Window string `json:"window"`
URL string `json:"url"` URL string `json:"url"`
} }
// TaskScreenshot captures the page as PNG. Result: ScreenshotResult // TaskScreenshot captures the page as PNG. Result: ScreenshotResult
type TaskScreenshot struct{ Window string `json:"window"` } // Use: result, _, err := c.PERFORM(webview.TaskScreenshot{Window: "editor"})
type TaskScreenshot struct {
Window string `json:"window"`
}
// TaskScreenshotElement captures a specific element as PNG. Result: ScreenshotResult
// Use: result, _, err := c.PERFORM(webview.TaskScreenshotElement{Window: "editor", Selector: "#panel"})
type TaskScreenshotElement struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil // TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskScroll{Window: "editor", X: 0, Y: 600})
type TaskScroll struct { type TaskScroll struct {
Window string `json:"window"` Window string `json:"window"`
X int `json:"x"` X int `json:"x"`
@ -74,12 +132,14 @@ type TaskScroll struct {
} }
// TaskHover hovers over an element. Result: nil // TaskHover hovers over an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskHover{Window: "editor", Selector: "#help"})
type TaskHover struct { type TaskHover struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
} }
// TaskSelect selects an option in a <select> element. Result: nil // TaskSelect selects an option in a <select> element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskSelect{Window: "editor", Selector: "#theme", Value: "dark"})
type TaskSelect struct { type TaskSelect struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
@ -87,6 +147,7 @@ type TaskSelect struct {
} }
// TaskCheck checks/unchecks a checkbox. Result: nil // TaskCheck checks/unchecks a checkbox. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskCheck{Window: "editor", Selector: "#accept", Checked: true})
type TaskCheck struct { type TaskCheck struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
@ -94,6 +155,7 @@ type TaskCheck struct {
} }
// TaskUploadFile uploads files to an input element. Result: nil // TaskUploadFile uploads files to an input element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskUploadFile{Window: "editor", Selector: "input[type=file]", Paths: []string{"/tmp/report.pdf"}})
type TaskUploadFile struct { type TaskUploadFile struct {
Window string `json:"window"` Window string `json:"window"`
Selector string `json:"selector"` Selector string `json:"selector"`
@ -101,6 +163,7 @@ type TaskUploadFile struct {
} }
// TaskSetViewport sets the viewport dimensions. Result: nil // TaskSetViewport sets the viewport dimensions. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskSetViewport{Window: "editor", Width: 1280, Height: 800})
type TaskSetViewport struct { type TaskSetViewport struct {
Window string `json:"window"` Window string `json:"window"`
Width int `json:"width"` Width int `json:"width"`
@ -108,43 +171,66 @@ type TaskSetViewport struct {
} }
// TaskClearConsole clears captured console messages. Result: nil // TaskClearConsole clears captured console messages. Result: nil
type TaskClearConsole struct{ Window string `json:"window"` } // Use: _, _, err := c.PERFORM(webview.TaskClearConsole{Window: "editor"})
type TaskClearConsole struct {
// TaskSetURL navigates to a URL (alias for TaskNavigate, preferred for direct URL setting). Result: nil
type TaskSetURL struct {
Window string `json:"window"` Window string `json:"window"`
URL string `json:"url"`
} }
// TaskSetZoom sets the page zoom level. Result: nil // TaskHighlight visually highlights an element.
// zoom := 1.0 is normal; 1.5 is 150%; 0.5 is 50%. // Use: _, _, err := c.PERFORM(webview.TaskHighlight{Window: "editor", Selector: "#submit", Colour: "#ffcc00"})
type TaskSetZoom struct { type TaskHighlight struct {
Window string `json:"window"` Window string `json:"window"`
Zoom float64 `json:"zoom"` Selector string `json:"selector"`
Colour string `json:"colour,omitempty"`
} }
// TaskPrint triggers the browser print dialog or prints to PDF. Result: *PrintResult // TaskOpenDevTools opens the browser devtools for the target window. Result: nil
// c.PERFORM(TaskPrint{Window: "main"}) // opens print dialog via window.print() // Use: _, _, err := c.PERFORM(webview.TaskOpenDevTools{Window: "editor"})
// c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) // returns base64 PDF bytes type TaskOpenDevTools struct {
Window string `json:"window"`
}
// TaskCloseDevTools closes the browser devtools for the target window. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskCloseDevTools{Window: "editor"})
type TaskCloseDevTools struct {
Window string `json:"window"`
}
// TaskInjectNetworkLogging injects fetch/XHR interception into the page.
// Use: _, _, err := c.PERFORM(webview.TaskInjectNetworkLogging{Window: "editor"})
type TaskInjectNetworkLogging struct {
Window string `json:"window"`
}
// TaskClearNetworkLog clears the captured network log.
// Use: _, _, err := c.PERFORM(webview.TaskClearNetworkLog{Window: "editor"})
type TaskClearNetworkLog struct {
Window string `json:"window"`
}
// TaskPrint prints the current page using the browser's native print flow.
// Use: _, _, err := c.PERFORM(webview.TaskPrint{Window: "editor"})
type TaskPrint struct { type TaskPrint struct {
Window string `json:"window"` Window string `json:"window"`
ToPDF bool `json:"toPDF,omitempty"` // true = return PDF bytes; false = open print dialog
} }
// QueryZoom gets the current page zoom level. Result: float64 // TaskExportPDF exports the page to a PDF document.
// result, _, _ := c.QUERY(QueryZoom{Window: "main"}) // Use: result, _, err := c.PERFORM(webview.TaskExportPDF{Window: "editor"})
// zoom := result.(float64) type TaskExportPDF struct {
type QueryZoom struct{ Window string `json:"window"` } Window string `json:"window"`
}
// --- Actions (broadcast) --- // --- Actions (broadcast) ---
// ActionConsoleMessage is broadcast when a console message is captured. // ActionConsoleMessage is broadcast when a console message is captured.
// Use: _ = c.ACTION(webview.ActionConsoleMessage{Window: "editor", Message: webview.ConsoleMessage{Type: "error", Text: "boom"}})
type ActionConsoleMessage struct { type ActionConsoleMessage struct {
Window string `json:"window"` Window string `json:"window"`
Message ConsoleMessage `json:"message"` Message ConsoleMessage `json:"message"`
} }
// ActionException is broadcast when a JavaScript exception occurs. // ActionException is broadcast when a JavaScript exception occurs.
// Use: _ = c.ACTION(webview.ActionException{Window: "editor", Exception: webview.ExceptionInfo{Text: "ReferenceError"}})
type ActionException struct { type ActionException struct {
Window string `json:"window"` Window string `json:"window"`
Exception ExceptionInfo `json:"exception"` Exception ExceptionInfo `json:"exception"`
@ -153,6 +239,7 @@ type ActionException struct {
// --- Types --- // --- Types ---
// ConsoleMessage represents a browser console message. // ConsoleMessage represents a browser console message.
// Use: msg := webview.ConsoleMessage{Type: "warn", Text: "slow network"}
type ConsoleMessage struct { type ConsoleMessage struct {
Type string `json:"type"` // "log", "warn", "error", "info", "debug" Type string `json:"type"` // "log", "warn", "error", "info", "debug"
Text string `json:"text"` Text string `json:"text"`
@ -163,6 +250,7 @@ type ConsoleMessage struct {
} }
// ElementInfo represents a DOM element. // ElementInfo represents a DOM element.
// Use: el := webview.ElementInfo{TagName: "button", InnerText: "Save"}
type ElementInfo struct { type ElementInfo struct {
TagName string `json:"tagName"` TagName string `json:"tagName"`
Attributes map[string]string `json:"attributes,omitempty"` Attributes map[string]string `json:"attributes,omitempty"`
@ -172,6 +260,7 @@ type ElementInfo struct {
} }
// BoundingBox represents element position and size. // BoundingBox represents element position and size.
// Use: box := webview.BoundingBox{X: 10, Y: 20, Width: 120, Height: 40}
type BoundingBox struct { type BoundingBox struct {
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` Y float64 `json:"y"`
@ -181,6 +270,7 @@ type BoundingBox struct {
// ExceptionInfo represents a JavaScript exception. // ExceptionInfo represents a JavaScript exception.
// Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column. // Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column.
// Use: err := webview.ExceptionInfo{Text: "ReferenceError", URL: "app://editor"}
type ExceptionInfo struct { type ExceptionInfo struct {
Text string `json:"text"` Text string `json:"text"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
@ -191,13 +281,52 @@ type ExceptionInfo struct {
} }
// ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport. // ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport.
// Use: shot := webview.ScreenshotResult{Base64: "iVBORw0KGgo=", MimeType: "image/png"}
type ScreenshotResult struct { type ScreenshotResult struct {
Base64 string `json:"base64"` Base64 string `json:"base64"`
MimeType string `json:"mimeType"` // always "image/png" MimeType string `json:"mimeType"` // always "image/png"
} }
// PrintResult wraps PDF bytes as base64 when TaskPrint.ToPDF is true. // PerformanceMetrics summarises browser performance timings.
type PrintResult struct { // Use: metrics := webview.PerformanceMetrics{NavigationStart: 1.2, LoadEventEnd: 42.5}
type PerformanceMetrics struct {
NavigationStart float64 `json:"navigationStart"`
DOMContentLoaded float64 `json:"domContentLoaded"`
LoadEventEnd float64 `json:"loadEventEnd"`
FirstPaint float64 `json:"firstPaint,omitempty"`
FirstContentfulPaint float64 `json:"firstContentfulPaint,omitempty"`
UsedJSHeapSize float64 `json:"usedJSHeapSize,omitempty"`
TotalJSHeapSize float64 `json:"totalJSHeapSize,omitempty"`
}
// ResourceEntry summarises a loaded resource.
// Use: entry := webview.ResourceEntry{Name: "app.js", EntryType: "resource"}
type ResourceEntry struct {
Name string `json:"name"`
EntryType string `json:"entryType"`
InitiatorType string `json:"initiatorType,omitempty"`
StartTime float64 `json:"startTime"`
Duration float64 `json:"duration"`
TransferSize float64 `json:"transferSize,omitempty"`
EncodedBodySize float64 `json:"encodedBodySize,omitempty"`
DecodedBodySize float64 `json:"decodedBodySize,omitempty"`
}
// NetworkEntry summarises a captured fetch/XHR request.
// Use: entry := webview.NetworkEntry{URL: "/api/status", Method: "GET", Status: 200}
type NetworkEntry struct {
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status,omitempty"`
Resource string `json:"resource,omitempty"`
OK bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
// PDFResult contains exported PDF bytes encoded for transport.
// Use: pdf := webview.PDFResult{Base64: "JVBERi0xLjQ=", MimeType: "application/pdf"}
type PDFResult struct {
Base64 string `json:"base64"` Base64 string `json:"base64"`
MimeType string `json:"mimeType"` // always "application/pdf" MimeType string `json:"mimeType"` // always "application/pdf"
} }

View file

@ -5,12 +5,21 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"math"
"reflect"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
"unsafe"
gowebview "forge.lthn.ai/core/go-webview" gowebview "forge.lthn.ai/core/go-webview"
core "dappco.re/go/core" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/window" "forge.lthn.ai/core/gui/pkg/window"
) )
@ -34,53 +43,63 @@ type connector interface {
ClearConsole() ClearConsole()
SetViewport(width, height int) error SetViewport(width, height int) error
UploadFile(selector string, paths []string) error UploadFile(selector string, paths []string) error
GetZoom() (float64, error) Print() error
SetZoom(zoom float64) error PrintToPDF() ([]byte, error)
Print(toPDF bool) ([]byte, error)
Close() error Close() error
} }
// Options holds configuration for the webview service.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Options struct { type Options struct {
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222") DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
Timeout time.Duration // Operation timeout (default: 30s) Timeout time.Duration // Operation timeout (default: 30s)
ConsoleLimit int // Max console messages per window (default: 1000) ConsoleLimit int // Max console messages per window (default: 1000)
} }
// Service is a core.Service managing webview interactions via IPC.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Service struct { type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
options Options opts Options
connections map[string]connector connections map[string]connector
exceptions map[string][]ExceptionInfo
mu sync.RWMutex mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation watcherSetup func(conn connector, windowName string) // called after connection creation
} }
// Register binds the webview service to a Core instance. // Register creates a factory closure with declarative options.
// core.WithService(webview.Register()) // Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
// core.WithService(webview.Register(func(o *Options) { o.DebugURL = "http://localhost:9223" })) func Register(options Options) func(*core.Core) (any, error) {
func Register(optionFns ...func(*Options)) func(*core.Core) core.Result {
o := Options{ o := Options{
DebugURL: "http://localhost:9222", DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
ConsoleLimit: 1000, ConsoleLimit: 1000,
} }
for _, fn := range optionFns { if options.DebugURL != "" {
fn(&o) o.DebugURL = options.DebugURL
} }
return func(c *core.Core) core.Result { if options.Timeout != 0 {
o.Timeout = options.Timeout
}
if options.ConsoleLimit != 0 {
o.ConsoleLimit = options.ConsoleLimit
}
return func(c *core.Core) (any, error) {
svc := &Service{ svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o), ServiceRuntime: core.NewServiceRuntime[Options](c, o),
options: o, opts: o,
connections: make(map[string]connector), connections: make(map[string]connector),
exceptions: make(map[string][]ExceptionInfo),
newConn: defaultNewConn(o), newConn: defaultNewConn(o),
} }
svc.watcherSetup = svc.defaultWatcherSetup svc.watcherSetup = svc.defaultWatcherSetup
return core.Result{Value: svc, OK: true} return svc, nil
} }
} }
// defaultNewConn creates real go-webview connections. // defaultNewConn creates real go-webview connections.
func defaultNewConn(options Options) func(string, string) (connector, error) { func defaultNewConn(opts Options) func(string, string) (connector, error) {
return func(debugURL, windowName string) (connector, error) { return func(debugURL, windowName string) (connector, error) {
// Enumerate targets, match by title/URL containing window name // Enumerate targets, match by title/URL containing window name
targets, err := gowebview.ListTargets(debugURL) targets, err := gowebview.ListTargets(debugURL)
@ -89,7 +108,7 @@ func defaultNewConn(options Options) func(string, string) (connector, error) {
} }
var wsURL string var wsURL string
for _, t := range targets { for _, t := range targets {
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) { if t.Type == "page" && (strings.Contains(t.Title, windowName) || strings.Contains(t.URL, windowName)) {
wsURL = t.WebSocketDebuggerURL wsURL = t.WebSocketDebuggerURL
break break
} }
@ -108,13 +127,13 @@ func defaultNewConn(options Options) func(string, string) (connector, error) {
} }
wv, err := gowebview.New( wv, err := gowebview.New(
gowebview.WithDebugURL(debugURL), gowebview.WithDebugURL(debugURL),
gowebview.WithTimeout(options.Timeout), gowebview.WithTimeout(opts.Timeout),
gowebview.WithConsoleLimit(options.ConsoleLimit), gowebview.WithConsoleLimit(opts.ConsoleLimit),
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &realConnector{wv: wv, debugURL: debugURL}, nil return &realConnector{wv: wv}, nil
} }
} }
@ -157,25 +176,26 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
}) })
} }
func (s *Service) OnStartup(_ context.Context) core.Result { // OnStartup registers IPC handlers.
func (s *Service) OnStartup(_ context.Context) error {
s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterQuery(s.handleQuery)
s.registerTaskActions() s.Core().RegisterTask(s.handleTask)
return core.Result{OK: true} return nil
} }
// OnShutdown closes all CDP connections. // OnShutdown closes all CDP connections.
func (s *Service) OnShutdown(_ context.Context) core.Result { func (s *Service) OnShutdown(_ context.Context) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for name, conn := range s.connections { for name, conn := range s.connections {
conn.Close() conn.Close()
delete(s.connections, name) delete(s.connections, name)
} }
return core.Result{OK: true} return nil
} }
// HandleIPCEvents listens for window close events to clean up connections. // HandleIPCEvents listens for window close events to clean up connections.
func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result { func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
switch m := msg.(type) { switch m := msg.(type) {
case window.ActionWindowClosed: case window.ActionWindowClosed:
s.mu.Lock() s.mu.Lock()
@ -183,9 +203,12 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result {
conn.Close() conn.Close()
delete(s.connections, m.Name) delete(s.connections, m.Name)
} }
delete(s.exceptions, m.Name)
s.mu.Unlock() s.mu.Unlock()
case ActionException:
s.recordException(m.Window, m.Exception)
} }
return core.Result{OK: true} return nil
} }
// getConn returns the connector for a window, creating it if needed. // getConn returns the connector for a window, creating it if needed.
@ -203,7 +226,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
if conn, ok := s.connections[windowName]; ok { if conn, ok := s.connections[windowName]; ok {
return conn, nil return conn, nil
} }
conn, err := s.newConn(s.options.DebugURL, windowName) conn, err := s.newConn(s.opts.DebugURL, windowName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -214,26 +237,26 @@ func (s *Service) getConn(windowName string) (connector, error) {
return conn, nil return conn, nil
} }
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) { switch q := q.(type) {
case QueryURL: case QueryURL:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
url, err := conn.GetURL() url, err := conn.GetURL()
return core.Result{}.New(url, err) return url, true, err
case QueryTitle: case QueryTitle:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
title, err := conn.GetTitle() title, err := conn.GetTitle()
return core.Result{}.New(title, err) return title, true, err
case QueryConsole: case QueryConsole:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
msgs := conn.GetConsole() msgs := conn.GetConsole()
// Filter by level if specified // Filter by level if specified
@ -250,195 +273,397 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
if q.Limit > 0 && len(msgs) > q.Limit { if q.Limit > 0 && len(msgs) > q.Limit {
msgs = msgs[len(msgs)-q.Limit:] msgs = msgs[len(msgs)-q.Limit:]
} }
return core.Result{Value: msgs, OK: true} return msgs, true, nil
case QuerySelector: case QuerySelector:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
el, err := conn.QuerySelector(q.Selector) el, err := conn.QuerySelector(q.Selector)
return core.Result{}.New(el, err) return el, true, err
case QuerySelectorAll: case QuerySelectorAll:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
els, err := conn.QuerySelectorAll(q.Selector) els, err := conn.QuerySelectorAll(q.Selector)
return core.Result{}.New(els, err) return els, true, err
case QueryDOMTree: case QueryDOMTree:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
selector := q.Selector selector := q.Selector
if selector == "" { if selector == "" {
selector = "html" selector = "html"
} }
html, err := conn.GetHTML(selector) html, err := conn.GetHTML(selector)
return core.Result{}.New(html, err) return html, true, err
case QueryZoom: case QueryComputedStyle:
conn, err := s.getConn(q.Window) conn, err := s.getConn(q.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
zoom, err := conn.GetZoom() result, err := conn.Evaluate(computedStyleScript(q.Selector))
return core.Result{}.New(zoom, err) if err != nil {
return nil, true, err
}
style, err := coerceToMapStringString(result)
if err != nil {
return nil, true, err
}
return style, true, nil
case QueryPerformance:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(performanceScript())
if err != nil {
return nil, true, err
}
metrics, err := coerceToPerformanceMetrics(result)
if err != nil {
return nil, true, err
}
return metrics, true, nil
case QueryResources:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(resourcesScript())
if err != nil {
return nil, true, err
}
resources, err := coerceToResourceEntries(result)
if err != nil {
return nil, true, err
}
return resources, true, nil
case QueryNetwork:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(networkLogScript(q.Limit))
if err != nil {
return nil, true, err
}
entries, err := coerceToNetworkEntries(result)
if err != nil {
return nil, true, err
}
return entries, true, nil
case QueryExceptions:
return s.queryExceptions(q.Window, q.Limit), true, nil
default: default:
return core.Result{} return nil, false, nil
} }
} }
// registerTaskActions registers all webview task handlers as named Core actions. func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
func (s *Service) registerTaskActions() { switch t := t.(type) {
c := s.Core() case TaskEvaluate:
c.Action("webview.evaluate", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskEvaluate)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
result, err := conn.Evaluate(t.Script) result, err := conn.Evaluate(t.Script)
return core.Result{}.New(result, err) return result, true, err
}) case TaskClick:
c.Action("webview.click", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskClick)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Click(t.Selector)) return nil, true, conn.Click(t.Selector)
}) case TaskType:
c.Action("webview.type", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskType)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Type(t.Selector, t.Text)) return nil, true, conn.Type(t.Selector, t.Text)
}) case TaskNavigate:
c.Action("webview.navigate", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskNavigate)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Navigate(t.URL)) return nil, true, conn.Navigate(t.URL)
}) case TaskScreenshot:
c.Action("webview.screenshot", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskScreenshot)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
png, err := conn.Screenshot() png, err := conn.Screenshot()
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: ScreenshotResult{ return ScreenshotResult{
Base64: base64.StdEncoding.EncodeToString(png), Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png", MimeType: "image/png",
}, OK: true} }, true, nil
}) case TaskScreenshotElement:
c.Action("webview.scroll", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskScroll)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
}
png, err := captureElementScreenshot(conn, t.Selector)
if err != nil {
return nil, true, err
}
return ScreenshotResult{
Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png",
}, true, nil
case TaskScroll:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
} }
_, err = conn.Evaluate("window.scrollTo(" + strconv.Itoa(t.X) + "," + strconv.Itoa(t.Y) + ")") _, err = conn.Evaluate("window.scrollTo(" + strconv.Itoa(t.X) + "," + strconv.Itoa(t.Y) + ")")
return core.Result{Value: nil, OK: true}.New(err) return nil, true, err
}) case TaskHover:
c.Action("webview.hover", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskHover)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Hover(t.Selector)) return nil, true, conn.Hover(t.Selector)
}) case TaskSelect:
c.Action("webview.select", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskSelect)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Select(t.Selector, t.Value)) return nil, true, conn.Select(t.Selector, t.Value)
}) case TaskCheck:
c.Action("webview.check", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskCheck)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Check(t.Selector, t.Checked)) return nil, true, conn.Check(t.Selector, t.Checked)
}) case TaskUploadFile:
c.Action("webview.uploadFile", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskUploadFile)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.UploadFile(t.Selector, t.Paths)) return nil, true, conn.UploadFile(t.Selector, t.Paths)
}) case TaskSetViewport:
c.Action("webview.setViewport", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskSetViewport)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.SetViewport(t.Width, t.Height)) return nil, true, conn.SetViewport(t.Width, t.Height)
}) case TaskClearConsole:
c.Action("webview.clearConsole", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskClearConsole)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
conn.ClearConsole() conn.ClearConsole()
return core.Result{OK: true} return nil, true, nil
}) case TaskHighlight:
c.Action("webview.setURL", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskSetURL)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.Navigate(t.URL)) _, err = conn.Evaluate(highlightScript(t.Selector, t.Colour))
}) return nil, true, err
c.Action("webview.setZoom", func(_ context.Context, opts core.Options) core.Result { case TaskOpenDevTools:
t, _ := opts.Get("task").Value.(TaskSetZoom) ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
if err != nil {
return nil, true, err
}
pw, ok := ws.Manager().Get(t.Window)
if !ok {
return nil, true, fmt.Errorf("window not found: %s", t.Window)
}
pw.OpenDevTools()
return nil, true, nil
case TaskCloseDevTools:
ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
if err != nil {
return nil, true, err
}
pw, ok := ws.Manager().Get(t.Window)
if !ok {
return nil, true, fmt.Errorf("window not found: %s", t.Window)
}
pw.CloseDevTools()
return nil, true, nil
case TaskInjectNetworkLogging:
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
return core.Result{Value: nil, OK: true}.New(conn.SetZoom(t.Zoom)) _, err = conn.Evaluate(networkInitScript())
}) return nil, true, err
c.Action("webview.print", func(_ context.Context, opts core.Options) core.Result { case TaskClearNetworkLog:
t, _ := opts.Get("task").Value.(TaskPrint)
conn, err := s.getConn(t.Window) conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
pdfBytes, err := conn.Print(t.ToPDF) _, err = conn.Evaluate(networkClearScript())
return nil, true, err
case TaskPrint:
conn, err := s.getConn(t.Window)
if err != nil { if err != nil {
return core.Result{Value: err, OK: false} return nil, true, err
} }
if !t.ToPDF { return nil, true, conn.Print()
return core.Result{OK: true} case TaskExportPDF:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
} }
return core.Result{Value: PrintResult{ pdf, err := conn.PrintToPDF()
Base64: base64.StdEncoding.EncodeToString(pdfBytes), if err != nil {
return nil, true, err
}
return PDFResult{
Base64: base64.StdEncoding.EncodeToString(pdf),
MimeType: "application/pdf", MimeType: "application/pdf",
}, OK: true} }, true, nil
}) default:
return nil, false, nil
}
}
func (s *Service) recordException(windowName string, exc ExceptionInfo) {
s.mu.Lock()
defer s.mu.Unlock()
exceptions := append(s.exceptions[windowName], exc)
if limit := s.opts.ConsoleLimit; limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
s.exceptions[windowName] = exceptions
}
func (s *Service) queryExceptions(windowName string, limit int) []ExceptionInfo {
s.mu.RLock()
defer s.mu.RUnlock()
exceptions := append([]ExceptionInfo(nil), s.exceptions[windowName]...)
if limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
return exceptions
}
func coerceJSON[T any](v any) (T, error) {
var out T
raw, err := json.Marshal(v)
if err != nil {
return out, err
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, err
}
return out, nil
}
func coerceToMapStringString(v any) (map[string]string, error) {
return coerceJSON[map[string]string](v)
}
func coerceToPerformanceMetrics(v any) (PerformanceMetrics, error) {
return coerceJSON[PerformanceMetrics](v)
}
func coerceToResourceEntries(v any) ([]ResourceEntry, error) {
return coerceJSON[[]ResourceEntry](v)
}
func coerceToNetworkEntries(v any) ([]NetworkEntry, error) {
return coerceJSON[[]NetworkEntry](v)
}
type elementScreenshotBounds struct {
Left float64 `json:"left"`
Top float64 `json:"top"`
Width float64 `json:"width"`
Height float64 `json:"height"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
}
func elementScreenshotScript(selector string) string {
sel := jsQuote(selector)
return fmt.Sprintf(`(function(){
const el = document.querySelector(%s);
if (!el) return null;
try { el.scrollIntoView({block: "center", inline: "center"}); } catch (e) {}
const rect = el.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
devicePixelRatio: window.devicePixelRatio || 1
};
})()`, sel)
}
func captureElementScreenshot(conn connector, selector string) ([]byte, error) {
result, err := conn.Evaluate(elementScreenshotScript(selector))
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("webview: element not found: %s", selector)
}
bounds, err := coerceJSON[elementScreenshotBounds](result)
if err != nil {
return nil, err
}
if bounds.Width <= 0 || bounds.Height <= 0 {
return nil, fmt.Errorf("webview: element has no measurable bounds: %s", selector)
}
raw, err := conn.Screenshot()
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, err
}
scale := bounds.DevicePixelRatio
if scale <= 0 {
scale = 1
}
left := int(math.Floor(bounds.Left * scale))
top := int(math.Floor(bounds.Top * scale))
right := int(math.Ceil((bounds.Left + bounds.Width) * scale))
bottom := int(math.Ceil((bounds.Top + bounds.Height) * scale))
srcBounds := img.Bounds()
if left < srcBounds.Min.X {
left = srcBounds.Min.X
}
if top < srcBounds.Min.Y {
top = srcBounds.Min.Y
}
if right > srcBounds.Max.X {
right = srcBounds.Max.X
}
if bottom > srcBounds.Max.Y {
bottom = srcBounds.Max.Y
}
if right <= left || bottom <= top {
return nil, fmt.Errorf("webview: element is outside the captured screenshot: %s", selector)
}
crop := image.NewRGBA(image.Rect(0, 0, right-left, bottom-top))
draw.Draw(crop, crop.Bounds(), img, image.Point{X: left, Y: top}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, crop); err != nil {
return nil, err
}
return buf.Bytes(), nil
} }
// realConnector wraps *gowebview.Webview, converting types at the boundary. // realConnector wraps *gowebview.Webview, converting types at the boundary.
// debugURL is retained so that PDF printing can issue a Page.printToPDF CDP call
// via a fresh CDPClient, since go-webview v0.1.7 does not expose a PrintToPDF helper.
type realConnector struct { type realConnector struct {
wv *gowebview.Webview wv *gowebview.Webview
debugURL string // Chrome debug HTTP endpoint (e.g., http://localhost:9222) for direct CDP calls
} }
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) } func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
@ -450,85 +675,47 @@ func (r *realConnector) GetURL() (string, error) { return r.wv.G
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() } func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) } func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() } func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err }
func (r *realConnector) Close() error { return r.wv.Close() } func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) } func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) } func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
func (r *realConnector) PrintToPDF() ([]byte, error) {
// GetZoom returns the current CSS zoom level as a float64. client, err := r.cdpClient()
// zoom, _ := conn.GetZoom() // 1.0 = 100%, 1.5 = 150%
func (r *realConnector) GetZoom() (float64, error) {
raw, err := r.wv.Evaluate("parseFloat(document.documentElement.style.zoom) || 1.0")
if err != nil { if err != nil {
return 0, core.E("realConnector.GetZoom", "failed to get zoom", err) return nil, err
} }
switch v := raw.(type) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
case float64:
return v, nil
case int:
return float64(v), nil
default:
return 1.0, nil
}
}
// SetZoom sets the CSS zoom level on the document root element.
// conn.SetZoom(1.5) // 150%
// conn.SetZoom(1.0) // reset to normal
func (r *realConnector) SetZoom(zoom float64) error {
script := "document.documentElement.style.zoom = '" + strconv.FormatFloat(zoom, 'g', -1, 64) + "'; undefined"
_, err := r.wv.Evaluate(script)
if err != nil {
return core.E("realConnector.SetZoom", "failed to set zoom", err)
}
return nil
}
// Print triggers window.print() or exports to PDF via Page.printToPDF.
// When toPDF is false the browser print dialog is opened (via window.print()) and nil bytes are returned.
// When toPDF is true a fresh CDPClient is opened against the stored WebSocket URL to issue
// Page.printToPDF, which returns raw PDF bytes.
func (r *realConnector) Print(toPDF bool) ([]byte, error) {
if !toPDF {
_, err := r.wv.Evaluate("window.print(); undefined")
if err != nil {
return nil, core.E("realConnector.Print", "failed to open print dialog", err)
}
return nil, nil
}
if r.debugURL == "" {
return nil, core.E("realConnector.Print", "no debug URL stored; cannot issue Page.printToPDF", nil)
}
// Open a dedicated CDPClient for the single Page.printToPDF call.
// NewCDPClient connects to the first page target at the debug endpoint.
client, err := gowebview.NewCDPClient(r.debugURL)
if err != nil {
return nil, core.E("realConnector.Print", "failed to connect for PDF export", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
result, err := client.Call(ctx, "Page.printToPDF", map[string]any{ result, err := client.Call(ctx, "Page.printToPDF", map[string]any{
"printBackground": true, "printBackground": true,
"preferCSSPageSize": true,
}) })
if err != nil { if err != nil {
return nil, core.E("realConnector.Print", "Page.printToPDF failed", err) return nil, err
}
data, ok := result["data"].(string)
if !ok || data == "" {
return nil, fmt.Errorf("webview: missing PDF data")
}
return base64.StdEncoding.DecodeString(data)
} }
dataStr, ok := result["data"].(string) func (r *realConnector) cdpClient() (*gowebview.CDPClient, error) {
if !ok { rv := reflect.ValueOf(r.wv)
return nil, core.E("realConnector.Print", "Page.printToPDF returned no data", nil) if rv.Kind() != reflect.Ptr || rv.IsNil() {
return nil, fmt.Errorf("webview: invalid connector")
} }
elem := rv.Elem()
pdfBytes, err := base64.StdEncoding.DecodeString(dataStr) field := elem.FieldByName("client")
if err != nil { if !field.IsValid() || field.IsNil() {
return nil, core.E("realConnector.Print", "failed to decode PDF data", err) return nil, fmt.Errorf("webview: CDP client not available")
} }
ptr := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface()
return pdfBytes, nil client, ok := ptr.(*gowebview.CDPClient)
if !ok || client == nil {
return nil, fmt.Errorf("webview: unexpected CDP client type")
}
return client, nil
} }
func (r *realConnector) Hover(sel string) error { func (r *realConnector) Hover(sel string) error {

Some files were not shown because too many files have changed in this diff Show more