Compare commits

..

16 commits

Author SHA1 Message Date
Claude
d9fa59ab04
feat(stubs): rebuild Wails v3 stub bridge from clean dev — 15 files, 479 exports
Some checks failed
Security Scan / security (push) Failing after 24s
Rebuilt from scratch on current dev (post-fleet AX passes).

Stub files:
- application.go (expanded App with 12 managers, WebviewWindow satisfies Window)
- application_options.go (Options, Mac/Win/Linux/iOS/Android, Server, TLS, Assets)
- browser_manager.go, browser_window.go (~47 no-op methods)
- clipboard.go, context_menu.go, dialog.go (full dialog builder API)
- environment.go, events.go (EventManager with On/Off/Emit/Reset)
- keybinding.go, menuitem.go (42 Role constants)
- screen.go (Rect/Point/Size geometry), services.go (generic Service[T])
- webview_window_options.go (full platform types)
- window.go (Window interface ~50 methods)

Wails v3 submodule at internal/wails3/ pinned to alpha 74.
All 16 gui packages build and test clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:20:22 +01:00
4533018f80 Merge pull request '[agent/codex:gpt-5.4-mini] Update the code against the AX design principles in ~/spec/r...' (#6) from agent/update-the-code-against-the-ax-design-pr into dev
Some checks failed
Security Scan / security (push) Failing after 30s
Test / test (push) Successful in 1m32s
2026-03-31 13:20:18 +00:00
Virgil
3bcca95b5e refactor(ax): align GUI code with declarative AX principles
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 13:19:46 +00:00
Snider
13a493f57d refactor(ax): AX compliance sweep — comments, error handling, test names
Some checks failed
Security Scan / security (push) Failing after 26s
Test / test (push) Failing after 56s
- Fix window/service.go: replace 3 fmt.Errorf calls with coreerr.E() (removes implicit fmt dependency)
- Add usage-example comments to all bare Register() functions across 10 packages
- Remove redundant prose comments (Options/Service/Register/OnStartup/HandleIPCEvents boilerplate)
- Add Result-type comments to message types in contextmenu, keybinding, notification packages
- Fix test naming to TestFilename_Function_{Good,Bad,Ugly} pattern in display_test, window_test, persistence_test, service_screen_test
- Convert New() and CreateWindowOptions doc comments to usage-example style

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 12:18:41 +01:00
2001e7ed87 Merge pull request '[agent/codex:gpt-5.4-mini] Update the code against the AX design principles in ~/spec/r...' (#4) from main into dev
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Failing after 41s
2026-03-31 05:57:04 +00:00
Virgil
089bdacadb refactor(ax): align GUI surface with AX principles
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 2m21s
Apply declarative window specs across display, MCP, and window service paths; route layout/window controls through IPC tasks; and add a local Wails stub so the workspace builds cleanly here.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 05:56:36 +00:00
Snider
bff6c5b7d3 refactor(ax): add screen service tests and window improvements
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Failing after 2m2s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 06:38:55 +01:00
Virgil
02ea237a1d refactor(ax): use primary screen size for layouts 2026-03-31 05:38:00 +00:00
Snider
0e624bfb7b refactor(ax): merge mini AX pass
Some checks failed
Security Scan / security (push) Failing after 30s
Test / test (push) Failing after 2m10s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 06:32:06 +01:00
Virgil
35f8f5ec51 refactor(ax): align GUI APIs with AX principles 2026-03-31 05:31:00 +00:00
Virgil
2b6e6cee0b refactor(ax): finish AX cleanup in display stack
Some checks failed
Security Scan / security (push) Failing after 28s
Test / test (push) Failing after 2m9s
2026-03-31 05:24:09 +00:00
Virgil
da22bedbc6 refactor(ax): align public APIs with AX principles
Some checks failed
Security Scan / security (push) Failing after 42s
Test / test (push) Failing after 1m31s
2026-03-31 05:13:43 +00:00
Snider
bd58099c17 docs: add AX design principles RFC for agent dispatch
Some checks failed
Security Scan / security (push) Failing after 34s
Test / test (push) Failing after 2m9s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 23:56:08 +01:00
9177ea99c9 Merge pull request 'DX audit and fix' (#3) from agent/dx-audit-and-fix--1--review-claude-md into dev
Some checks failed
Security Scan / security (push) Failing after 28s
Test / test (push) Failing after 1m53s
Reviewed-on: #3
2026-03-24 11:43:13 +00:00
Snider
a36392ec08 Merge origin/dev into agent/dx-audit-and-fix--1--review-claude-md
Resolve go.mod conflict: keep go-io and go-log as direct dependencies
(used by PR code changes) with dev's newer dependency versions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-24 11:40:56 +00:00
Snider
b559562dd9 fix(dx): use coreerr.E() and go-io, update CLAUDE.md, add tests
Some checks failed
Security Scan / security (pull_request) Failing after 28s
Test / test (pull_request) Failing after 1m59s
- Replace 90+ fmt.Errorf calls with coreerr.E() from go-log across
  display, window, systray, keybinding, contextmenu, and mcp packages
- Replace os.ReadFile/WriteFile/MkdirAll with coreio.Local in
  window/layout.go and window/state.go
- Update CLAUDE.md: fix key files table for new package structure,
  document error handling and file I/O conventions, add missing deps
- Add 37 tests for window package (task handlers, persistence,
  tiling modes, snap positions, workflow layouts)
- Window coverage: 47.1% → 69.8%

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 09:05:35 +00:00
134 changed files with 6300 additions and 8261 deletions

View file

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

View file

@ -0,0 +1,440 @@
# 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,7 +73,6 @@ Response:
| `window_maximize` | Maximize window |
| `window_minimize` | Minimize window |
| `window_focus` | Bring window to front |
| `window_title_set` | Alias for `window_title` |
### WebView Interaction
@ -85,7 +84,6 @@ Response:
| `webview_screenshot` | Capture page |
| `webview_navigate` | Navigate to URL |
| `webview_console` | Get console messages |
| `webview_errors` | Get structured JavaScript errors |
### Screen Management
@ -95,8 +93,6 @@ Response:
| `screen_primary` | Get primary screen |
| `screen_at_point` | Get screen at coordinates |
| `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

View file

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

View file

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

View file

@ -1 +0,0 @@
placeholder

14
go.mod
View file

@ -1,22 +1,22 @@
module dappco.re/go/core/gui
module forge.lthn.ai/core/gui
go 1.26.0
require (
dappco.re/go/core/config v0.1.8
dappco.re/go/core v0.3.3
dappco.re/go/core/webview v0.1.7
forge.lthn.ai/core/config v0.1.8
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-io v0.1.7
forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/go-webview v0.1.7
github.com/gorilla/websocket v1.5.3
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
)
replace github.com/wailsapp/wails/v3 => ./stubs/wails/v3
replace github.com/wailsapp/wails/v3 => ./stubs/wails
require (
dappco.re/go/core/io v0.1.7 // indirect
dappco.re/go/core/log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect

30
go.sum
View file

@ -1,32 +1,15 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
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/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-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/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/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/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
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/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=
@ -39,22 +22,18 @@ 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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/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/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/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
@ -63,7 +42,6 @@ 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/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
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/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@ -72,7 +50,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@ -81,11 +58,8 @@ 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=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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/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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
@ -97,7 +71,3 @@ 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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
internal/wails3 Submodule

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

View file

@ -1,10 +1,9 @@
// pkg/browser/register.go
package browser
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
// Register(p) binds the browser service to a Core instance.
// core.WithService(browser.Register(wailsBrowser))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/browser/service.go
package browser
import (
@ -7,29 +6,22 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the browser service.
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 {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
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:

View file

@ -9,16 +9,3 @@ type TaskSetText struct{ Text string }
// TaskClear clears the clipboard. Result: bool (success)
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,8 +1,6 @@
// pkg/clipboard/platform.go
package clipboard
import "encoding/base64"
// Platform abstracts the system clipboard backend.
type Platform interface {
Text() (string, bool)
@ -14,22 +12,3 @@ type ClipboardContent struct {
Text string `json:"text"`
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

@ -7,19 +7,15 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options configures the clipboard service.
// Use: core.WithService(clipboard.Register(platform))
type Options struct{}
// Service manages clipboard operations via Core queries and tasks.
// Use: svc := &clipboard.Service{}
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a Core service factory for the clipboard backend.
// Use: core.New(core.WithService(clipboard.Register(platform)))
// Register(p) binds the clipboard service to a Core instance.
// c.WithService(clipboard.Register(wailsClipboard))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,15 +25,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// 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().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents satisfies Core's IPC hook.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -47,12 +40,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
case QueryText:
text, ok := s.platform.Text()
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:
return nil, false, nil
}
@ -63,17 +50,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
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)
return s.platform.SetText(""), true, nil
default:
return nil, false, nil
}

View file

@ -11,10 +11,8 @@ import (
)
type mockPlatform struct {
text string
ok bool
img []byte
imgOk bool
text string
ok bool
}
func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok }
@ -23,12 +21,6 @@ func (m *mockPlatform) SetText(text string) bool {
m.ok = text != ""
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) {
t.Helper()
@ -87,34 +79,3 @@ func TestTaskClear_Good(t *testing.T) {
assert.Equal(t, "", r.(ClipboardContent).Text)
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,15 +1,10 @@
// pkg/contextmenu/messages.go
package contextmenu
import "errors"
// 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")
var ErrorMenuNotFound = errors.New("contextmenu: menu not found")
// --- Queries ---
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
// QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found)
type QueryGet struct {
Name string `json:"name"`
}
@ -17,26 +12,18 @@ type QueryGet struct {
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
type QueryList struct{}
// --- Tasks ---
// TaskAdd registers a context menu. Result: nil
// If a menu with the same name already exists it is replaced (remove + re-add).
// TaskAdd registers a named context menu. Replaces if already exists.
type TaskAdd struct {
Name string `json:"name"`
Menu ContextMenuDef `json:"menu"`
}
// TaskRemove unregisters a context menu. Result: nil
// Returns ErrMenuNotFound if the menu does not exist.
// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing.
type TaskRemove struct {
Name string `json:"name"`
}
// --- Actions ---
// 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 {
MenuName string `json:"menuName"`
ActionID string `json:"actionId"`

View file

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

View file

@ -3,31 +3,25 @@ package contextmenu
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the context menu service.
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 {
*core.ServiceRuntime[Options]
platform Platform
menus map[string]ContextMenuDef
platform Platform
registeredMenus map[string]ContextMenuDef
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -45,19 +39,17 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryGet returns a single menu definition by name, or nil if not found.
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
menu, ok := s.menus[q.Name]
menu, ok := s.registeredMenus[q.Name]
if !ok {
return nil
}
return &menu
}
// queryList returns a copy of all registered menus.
func (s *Service) queryList() map[string]ContextMenuDef {
result := make(map[string]ContextMenuDef, len(s.menus))
for k, v := range s.menus {
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
for k, v := range s.registeredMenus {
result[k] = v
}
return result
@ -78,9 +70,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
func (s *Service) taskAdd(t TaskAdd) error {
// If menu already exists, remove it first (replace semantics)
if _, exists := s.menus[t.Name]; exists {
if _, exists := s.registeredMenus[t.Name]; exists {
_ = s.platform.Remove(t.Name)
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
}
// Register on platform with a callback that broadcasts ActionItemClicked
@ -92,23 +84,23 @@ func (s *Service) taskAdd(t TaskAdd) error {
})
})
if err != nil {
return fmt.Errorf("contextmenu: platform add failed: %w", err)
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
}
s.menus[t.Name] = t.Menu
s.registeredMenus[t.Name] = t.Menu
return nil
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.menus[t.Name]; !exists {
return ErrMenuNotFound
if _, exists := s.registeredMenus[t.Name]; !exists {
return ErrorMenuNotFound
}
err := s.platform.Remove(t.Name)
if err != nil {
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
}
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
return nil
}

View file

@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrMenuNotFound)
assert.ErrorIs(t, err, ErrorMenuNotFound)
}
func TestQueryGet_Good(t *testing.T) {

View file

@ -1,14 +1,9 @@
// pkg/dialog/messages.go
package dialog
// TaskOpenFile shows an open file dialog. Result: []string (paths)
type TaskOpenFile struct{ Opts OpenFileOptions }
type TaskOpenFile struct{ Options OpenFileOptions }
// TaskSaveFile shows a save file dialog. Result: string (path)
type TaskSaveFile struct{ Opts SaveFileOptions }
type TaskSaveFile struct{ Options SaveFileOptions }
// TaskOpenDirectory shows a directory picker. Result: string (path)
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
type TaskMessageDialog struct{ Opts MessageDialogOptions }
type TaskMessageDialog struct{ Options MessageDialogOptions }

View file

@ -3,10 +3,10 @@ package dialog
// Platform abstracts the native dialog backend.
type Platform interface {
OpenFile(opts OpenFileOptions) ([]string, error)
SaveFile(opts SaveFileOptions) (string, error)
OpenDirectory(opts OpenDirectoryOptions) (string, error)
MessageDialog(opts MessageDialogOptions) (string, error)
OpenFile(options OpenFileOptions) ([]string, error)
SaveFile(options SaveFileOptions) (string, error)
OpenDirectory(options OpenDirectoryOptions) (string, error)
MessageDialog(options MessageDialogOptions) (string, error)
}
// DialogType represents the type of message dialog.

View file

@ -7,16 +7,13 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dialog service.
type Options struct{}
// Service is a core.Service managing native dialogs via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -26,13 +23,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -40,16 +35,16 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFile:
paths, err := s.platform.OpenFile(t.Opts)
paths, err := s.platform.OpenFile(t.Options)
return paths, true, err
case TaskSaveFile:
path, err := s.platform.SaveFile(t.Opts)
path, err := s.platform.SaveFile(t.Options)
return path, true, err
case TaskOpenDirectory:
path, err := s.platform.OpenDirectory(t.Opts)
path, err := s.platform.OpenDirectory(t.Options)
return path, true, err
case TaskMessageDialog:
button, err := s.platform.MessageDialog(t.Opts)
button, err := s.platform.MessageDialog(t.Options)
return button, true, err
default:
return nil, false, nil

View file

@ -11,18 +11,18 @@ import (
)
type mockPlatform struct {
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
}
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
result, handled, err := c.PERFORM(TaskOpenFile{
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
})
require.NoError(t, err)
assert.True(t, handled)
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
func TestTaskSaveFile_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskSaveFile{
Opts: SaveFileOptions{Filename: "out.txt"},
Options: SaveFileOptions{Filename: "out.txt"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
func TestTaskOpenDirectory_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskOpenDirectory{
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
Options: OpenDirectoryOptions{Title: "Pick Dir"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
mock.messageButton = "Yes"
result, handled, err := c.PERFORM(TaskMessageDialog{
Opts: MessageDialogOptions{
Options: MessageDialogOptions{
Type: DialogQuestion, Title: "Confirm",
Message: "Sure?", Buttons: []string{"Yes", "No"},
},

View file

@ -31,7 +31,6 @@ 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_always_on_top` - Pin window above others
- [x] `window_background_colour` - Set window background color with alpha (transparency)
- [x] `window_opacity` - Set window opacity
- [x] `window_fullscreen` - Enter/exit fullscreen mode
---
@ -60,13 +59,13 @@ This document tracks the implementation of display server features that enable A
### Smart Layout
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
- [x] `layout_stack` - Stack windows in cascade pattern
- [x] `layout_beside_editor` - Position window beside detected IDE window
- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [ ] `layout_beside_editor` - Position window beside detected IDE window
- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_snap` - Snap window to screen edge/corner/center
### AI-Optimized Layout
- [x] `screen_find_space` - Find empty screen space for new window
- [x] `window_arrange_pair` - Put two windows side-by-side optimally
- [ ] `screen_find_space` - Find empty screen space for new window
- [ ] `window_arrange_pair` - Put two windows side-by-side optimally
- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side"
---
@ -115,8 +114,8 @@ This document tracks the implementation of display server features that enable A
- [x] `webview_resources` - List loaded resources (scripts, styles, images)
### DevTools
- [x] `webview_devtools_open` - Open DevTools for window
- [x] `webview_devtools_close` - Close DevTools
- [ ] `webview_devtools_open` - Open DevTools for window
- [ ] `webview_devtools_close` - Close DevTools
---
@ -125,8 +124,8 @@ This document tracks the implementation of display server features that enable A
### Clipboard
- [x] `clipboard_read` - Read clipboard text content
- [x] `clipboard_write` - Write text to clipboard
- [x] `clipboard_read_image` - Read image from clipboard
- [x] `clipboard_write_image` - Write image to clipboard
- [ ] `clipboard_read_image` - Read image from clipboard
- [ ] `clipboard_write_image` - Write image to clipboard
- [x] `clipboard_has` - Check clipboard content type
- [x] `clipboard_clear` - Clear clipboard contents
@ -134,8 +133,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_permission_request` - Request notification permission
- [x] `notification_permission_check` - Check notification authorization status
- [x] `notification_clear` - Clear notifications
- [x] `notification_with_actions` - Interactive notifications with buttons
- [ ] `notification_clear` - Clear notifications
- [ ] `notification_with_actions` - Interactive notifications with buttons
### Dialogs
- [x] `dialog_open_file` - Show file open dialog
@ -143,11 +142,11 @@ This document tracks the implementation of display server features that enable A
- [x] `dialog_open_directory` - Show directory picker
- [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show)
- [x] `dialog_confirm` - Show confirmation dialog
- [x] `dialog_prompt` - Show input prompt dialog with a webview fallback when native support is unavailable
- [~] `dialog_prompt` - Show input prompt dialog (not supported natively in Wails v3)
### Theme & Appearance
- [x] `theme_get` - Get current theme (dark/light)
- [x] `theme_set` - Set application theme
- [ ] `theme_set` - Set application theme
- [x] `theme_system` - Get system theme preference
- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events)
@ -174,7 +173,7 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_set_label` - Set tray label text
- [x] `tray_set_menu` - Set tray menu items (with nested submenus)
- [x] `tray_info` - Get tray status info
- [x] `tray_show_message` - Show tray balloon notification
- [ ] `tray_show_message` - Show tray balloon notification
---
@ -236,6 +235,7 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_info` - Get tray status
### Phase 8 - Remaining Features (Future)
- [ ] window_opacity (true opacity if Wails adds support)
- [ ] layout_beside_editor, layout_suggest
- [ ] webview_devtools_open, webview_devtools_close
- [ ] 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,10 +1,9 @@
// pkg/display/events.go
package display
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
@ -13,7 +12,6 @@ import (
)
// EventType represents the type of event.
// Use: eventType := display.EventWindowFocus
type EventType string
const (
@ -45,7 +43,6 @@ const (
)
// Event represents a display event sent to subscribers.
// Use: evt := display.Event{Type: display.EventWindowFocus, Window: "editor"}
type Event struct {
Type EventType `json:"type"`
Timestamp int64 `json:"timestamp"`
@ -54,22 +51,12 @@ type Event struct {
}
// Subscription represents a client subscription to events.
// Use: sub := display.Subscription{ID: "sub-1", EventTypes: []display.EventType{display.EventWindowFocus}}
type Subscription struct {
ID string `json:"id"`
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.
// Use: events := display.NewWSEventManager()
type WSEventManager struct {
upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState
@ -79,14 +66,12 @@ type WSEventManager struct {
}
// clientState tracks a client's subscriptions.
// Use: state := &clientState{subscriptions: map[string]*Subscription{}}
type clientState struct {
subscriptions map[string]*Subscription
mu sync.RWMutex
}
// NewWSEventManager creates a new event manager.
// Use: events := display.NewWSEventManager()
func NewWSEventManager() *WSEventManager {
em := &WSEventManager{
upgrader: websocket.Upgrader{
@ -217,7 +202,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
if id == "" {
em.mu.Lock()
em.nextSubID++
id = fmt.Sprintf("sub-%d", em.nextSubID)
id = "sub-" + strconv.Itoa(em.nextSubID)
em.mu.Unlock()
}
@ -320,23 +305,6 @@ func (em *WSEventManager) ConnectedClients() int {
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.
func (em *WSEventManager) Close() {
em.mu.Lock()

View file

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

View file

@ -4,17 +4,9 @@ package display
// ActionIDECommand is broadcast when a menu handler triggers an IDE command
// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls.
// Listeners (e.g. editor windows) handle this via HandleIPCEvents.
// Use: _ = c.ACTION(display.ActionIDECommand{Command: "save"})
type ActionIDECommand struct {
Command string `json:"command"` // "save", "run", "build"
}
// EventIDECommand is the WS event type for IDE commands.
// Use: eventType := display.EventIDECommand
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

@ -1,10 +1,9 @@
// pkg/dock/register.go
package dock
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
// Register(p) binds the dock service to a Core instance.
// core.WithService(dock.Register(wailsDock))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/dock/service.go
package dock
import (
@ -7,30 +6,23 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dock service.
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 {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryVisible:
@ -40,8 +32,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskShowIcon:

View file

@ -16,13 +16,6 @@ type TaskOpenFileManager struct {
Select bool `json:"select"`
}
// TaskSetTheme applies an application theme override when supported.
// 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.
type ActionThemeChanged struct {
IsDark bool `json:"isDark"`

View file

@ -3,23 +3,20 @@ package environment
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the environment service.
type Options struct{}
// Service is a core.Service providing environment queries and theme change events via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
cancelTheme func() // cancel function for theme change listener
overrideDark *bool
platform Platform
cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown
}
// Register creates a factory closure that captures the Platform adapter.
// Register(p) binds the environment service to a Core instance.
// core.WithService(environment.Register(wailsEnvironment))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,7 +26,6 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers and the theme change listener.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
@ -41,7 +37,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
// OnShutdown cancels the theme change listener.
func (s *Service) OnShutdown(ctx context.Context) error {
if s.cancelTheme != nil {
s.cancelTheme()
@ -49,7 +44,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -57,7 +51,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryTheme:
isDark := s.currentTheme()
isDark := s.platform.IsDarkMode()
theme := "light"
if isDark {
theme = "dark"
@ -76,52 +70,7 @@ 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

@ -17,14 +17,12 @@ type mockPlatform struct {
accentColour string
openFMErr error
themeHandler func(isDark bool)
setThemeSeen bool
setThemeDark bool
mu sync.Mutex
}
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
return m.openFMErr
}
@ -38,12 +36,6 @@ func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() {
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).
func (m *mockPlatform) simulateThemeChange(isDark bool) {
@ -139,33 +131,3 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
require.NotNil(t, r)
assert.False(t, r.IsDark)
}
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)
result, handled, err := c.QUERY(QueryTheme{})
require.NoError(t, err)
assert.True(t, handled)
theme := result.(ThemeInfo)
assert.False(t, theme.IsDark)
assert.Equal(t, "light", theme.Theme)
}
func TestTaskSetTheme_Compatibility_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSetTheme{IsDark: true})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.setThemeSeen)
result, handled, err := c.QUERY(QueryTheme{})
require.NoError(t, err)
assert.True(t, handled)
theme := result.(ThemeInfo)
assert.True(t, theme.IsDark)
assert.Equal(t, "dark", theme.Theme)
}

View file

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

View file

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

View file

@ -3,31 +3,25 @@ package keybinding
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the keybinding service.
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 {
*core.ServiceRuntime[Options]
platform Platform
bindings map[string]BindingInfo
platform Platform
registeredBindings map[string]BindingInfo
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -43,10 +37,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryList reads from the in-memory registry (not platform.GetAll()).
func (s *Service) queryList() []BindingInfo {
result := make([]BindingInfo, 0, len(s.bindings))
for _, info := range s.bindings {
result := make([]BindingInfo, 0, len(s.registeredBindings))
for _, info := range s.registeredBindings {
result = append(result, info)
}
return result
@ -66,8 +59,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
func (s *Service) taskAdd(t TaskAdd) error {
if _, exists := s.bindings[t.Accelerator]; exists {
return ErrAlreadyRegistered
if _, exists := s.registeredBindings[t.Accelerator]; exists {
return ErrorAlreadyRegistered
}
// Register on platform with a callback that broadcasts ActionTriggered
@ -75,10 +68,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
})
if err != nil {
return fmt.Errorf("keybinding: platform add failed: %w", err)
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
}
s.bindings[t.Accelerator] = BindingInfo{
s.registeredBindings[t.Accelerator] = BindingInfo{
Accelerator: t.Accelerator,
Description: t.Description,
}
@ -86,15 +79,15 @@ func (s *Service) taskAdd(t TaskAdd) error {
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.bindings[t.Accelerator]; !exists {
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
}
err := s.platform.Remove(t.Accelerator)
if err != nil {
return fmt.Errorf("keybinding: platform remove failed: %w", err)
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
}
delete(s.bindings, t.Accelerator)
delete(s.registeredBindings, t.Accelerator)
return nil
}

View file

@ -99,7 +99,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
// Second add with same accelerator should fail
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrAlreadyRegistered)
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
}
func TestTaskRemove_Good(t *testing.T) {

View file

@ -1,10 +1,9 @@
// pkg/lifecycle/register.go
package lifecycle
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
// Register(p) binds the lifecycle service to a Core instance.
// core.WithService(lifecycle.Register(wailsLifecycle))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/lifecycle/service.go
package lifecycle
import (
@ -7,22 +6,15 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the lifecycle service.
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 {
*core.ServiceRuntime[Options]
platform Platform
cancels []func()
}
// 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(){
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
@ -38,7 +30,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
s.cancels = append(s.cancels, cancel)
}
// Register file-open callback (carries data)
cancel := s.platform.OnOpenedWithFile(func(path string) {
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
})
@ -47,7 +38,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
// OnShutdown cancels all registered platform callbacks.
func (s *Service) OnShutdown(ctx context.Context) error {
for _, cancel := range s.cancels {
cancel()
@ -56,8 +46,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// 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

@ -7,12 +7,6 @@ import (
"forge.lthn.ai/core/go/pkg/core"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -20,13 +14,13 @@ import (
func TestSubsystem_Good_Name(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
sub := New(c)
sub := NewSubsystem(c)
assert.Equal(t, "display", sub.Name())
}
func TestSubsystem_Good_RegisterTools(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
sub := New(c)
sub := NewSubsystem(c)
// RegisterTools should not panic with a real mcp.Server
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
assert.NotPanics(t, func() { sub.RegisterTools(server) })
@ -42,56 +36,6 @@ type mockClipPlatform struct {
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 }
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) {
c, err := core.New(
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
@ -109,171 +53,6 @@ func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
assert.Equal(t, "hello", content.Text)
}
func TestMCP_Good_DialogMessage(t *testing.T) {
mock := &mockNotificationPlatform{}
c, err := core.New(
core.WithService(notification.Register(mock)),
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

View file

@ -6,21 +6,24 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Subsystem implements the MCP Subsystem interface via structural typing.
// It registers GUI tools that translate MCP tool calls to IPC messages.
// Subsystem translates MCP tool calls to Core IPC messages for GUI operations.
type Subsystem struct {
core *core.Core
}
// New creates a display MCP subsystem backed by the given Core instance.
func New(c *core.Core) *Subsystem {
// NewSubsystem creates the display MCP bridge for a Core instance.
// sub := mcp.NewSubsystem(c); sub.RegisterTools(server)
func NewSubsystem(c *core.Core) *Subsystem {
return &Subsystem{core: c}
}
// Name returns the subsystem identifier.
// Deprecated: use NewSubsystem(c).
func New(c *core.Core) *Subsystem {
return NewSubsystem(c)
}
func (s *Subsystem) Name() string { return "display" }
// RegisterTools registers all GUI tools with the MCP server.
func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerWebviewTools(server)
s.registerWindowTools(server)

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/clipboard"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C
}
content, ok := result.(clipboard.ClipboardContent)
if !ok {
return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query")
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil)
}
return nil, ClipboardReadOutput{Content: content.Text}, nil
}
@ -44,7 +44,7 @@ func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, in
}
success, ok := result.(bool)
if !ok {
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil)
}
return nil, ClipboardWriteOutput{Success: success}, nil
}
@ -63,7 +63,7 @@ func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ Cl
}
content, ok := result.(clipboard.ClipboardContent)
if !ok {
return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query")
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil)
}
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
}
@ -82,47 +82,11 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
}
success, ok := result.(bool)
if !ok {
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", 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 ---
func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
@ -130,6 +94,4 @@ 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_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_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

@ -4,8 +4,8 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/contextmenu"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -27,11 +27,11 @@ func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, in
// Convert map[string]any to ContextMenuDef via JSON round-trip
menuJSON, err := json.Marshal(input.Menu)
if err != nil {
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err)
}
var menuDef contextmenu.ContextMenuDef
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err)
}
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
if err != nil {
@ -73,7 +73,7 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
}
menu, ok := result.(*contextmenu.ContextMenuDef)
if !ok {
return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
}
if menu == nil {
return nil, ContextMenuGetOutput{}, nil
@ -81,11 +81,11 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
menuJSON, err := json.Marshal(menu)
if err != nil {
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err)
}
var menuMap map[string]any
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err)
}
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
}
@ -104,16 +104,16 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _
}
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
if !ok {
return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query")
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil)
}
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
menusJSON, err := json.Marshal(menus)
if err != nil {
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err)
}
var menusMap map[string]any
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err)
}
return nil, ContextMenuListOutput{Menus: menusMap}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/dialog"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -22,7 +22,7 @@ type DialogOpenFileOutput struct {
}
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
Title: input.Title,
Directory: input.Directory,
Filters: input.Filters,
@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
}
paths, ok := result.([]string)
if !ok {
return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog")
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil)
}
return nil, DialogOpenFileOutput{Paths: paths}, nil
}
@ -51,7 +51,7 @@ type DialogSaveFileOutput struct {
}
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
Title: input.Title,
Directory: input.Directory,
Filename: input.Filename,
@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
}
path, ok := result.(string)
if !ok {
return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog")
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil)
}
return nil, DialogSaveFileOutput{Path: path}, nil
}
@ -78,7 +78,7 @@ type DialogOpenDirectoryOutput struct {
}
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
Title: input.Title,
Directory: input.Directory,
}})
@ -87,7 +87,7 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques
}
path, ok := result.(string)
if !ok {
return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog")
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil)
}
return nil, DialogOpenDirectoryOutput{Path: path}, nil
}
@ -104,7 +104,7 @@ type DialogConfirmOutput struct {
}
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogQuestion,
Title: input.Title,
Message: input.Message,
@ -115,7 +115,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp
}
button, ok := result.(string)
if !ok {
return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog")
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil)
}
return nil, DialogConfirmOutput{Button: button}, nil
}
@ -131,7 +131,7 @@ type DialogPromptOutput struct {
}
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: input.Title,
Message: input.Message,
@ -142,7 +142,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu
}
button, ok := result.(string)
if !ok {
return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog")
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil)
}
return nil, DialogPromptOutput{Button: button}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/environment"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query")
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil)
}
return nil, ThemeGetOutput{Theme: theme}, nil
}
@ -42,40 +42,14 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
}
info, ok := result.(environment.EnvironmentInfo)
if !ok {
return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query")
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", 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 ---
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_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

@ -3,10 +3,8 @@ package mcp
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -59,7 +57,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo
}
layouts, ok := result.([]window.LayoutInfo)
if !ok {
return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query")
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil)
}
return nil, LayoutListOutput{Layouts: layouts}, nil
}
@ -97,7 +95,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L
}
layout, ok := result.(*window.Layout)
if !ok {
return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query")
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil)
}
return nil, LayoutGetOutput{Layout: layout}, nil
}
@ -138,145 +136,19 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input
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 ---
type LayoutStackInput struct {
Windows []string `json:"windows,omitempty"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
OffsetX int `json:"offsetX"`
OffsetY int `json:"offsetY"`
}
type LayoutStackOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
_, _, err := s.core.PERFORM(window.TaskStackWindows{
Windows: input.Windows,
OffsetX: input.OffsetX,
OffsetY: input.OffsetY,
})
_, _, err := s.core.PERFORM(window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY})
if err != nil {
return nil, LayoutStackOutput{}, err
}
@ -294,14 +166,7 @@ type LayoutWorkflowOutput struct {
}
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
workflow, ok := window.ParseWorkflowLayout(input.Workflow)
if !ok {
return nil, LayoutWorkflowOutput{}, fmt.Errorf("unknown workflow: %s", input.Workflow)
}
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{
Workflow: workflow,
Windows: input.Windows,
})
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows})
if err != nil {
return nil, LayoutWorkflowOutput{}, err
}
@ -318,28 +183,6 @@ 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_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_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor)
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
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/notification"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -21,7 +21,7 @@ type NotificationShowOutput struct {
}
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Subtitle: input.Subtitle,
@ -32,31 +32,6 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest,
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 ---
type NotificationPermissionRequestInput struct{}
@ -71,7 +46,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call
}
granted, ok := result.(bool)
if !ok {
return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request")
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type", nil)
}
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
}
@ -90,65 +65,15 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
}
status, ok := result.(notification.PermissionStatus)
if !ok {
return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check")
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type", 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 ---
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_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_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,11 +3,10 @@ package mcp
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/display"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -25,7 +24,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre
}
screens, ok := result.([]screen.Screen)
if !ok {
return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query")
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil)
}
return nil, ScreenListOutput{Screens: screens}, nil
}
@ -46,7 +45,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query")
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil)
}
return nil, ScreenGetOutput{Screen: scr}, nil
}
@ -65,7 +64,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query")
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil)
}
return nil, ScreenPrimaryOutput{Screen: scr}, nil
}
@ -87,7 +86,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query")
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil)
}
return nil, ScreenAtPointOutput{Screen: scr}, nil
}
@ -106,35 +105,36 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
}
areas, ok := result.([]screen.Rect)
if !ok {
return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query")
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", 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 ---
type ScreenForWindowInput struct {
Window string `json:"window"`
Name string `json:"name"`
}
type ScreenForWindowOutput struct {
Screen *screen.Screen `json:"screen"`
}
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
svc, err := core.ServiceFor[*display.Service](s.core, "display")
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
scr, err := svc.GetScreenForWindow(input.Window)
info, _ := result.(*window.WindowInfo)
if info == nil {
return nil, ScreenForWindowOutput{}, nil
}
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
screenResult, _, err := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY})
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
scr, _ := screenResult.(*screen.Screen)
return nil, ScreenForWindowOutput{Screen: scr}, nil
}
@ -146,6 +146,5 @@ 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_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_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)
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/systray"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -36,10 +36,8 @@ type TraySetTooltipOutput struct {
}
func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) {
_, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip})
if err != nil {
return nil, TraySetTooltipOutput{}, err
}
// Tooltip is set via the tray menu items; for now this is a no-op placeholder
_ = input.Tooltip
return nil, TraySetTooltipOutput{Success: true}, nil
}
@ -53,10 +51,8 @@ type TraySetLabelOutput struct {
}
func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) {
_, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label})
if err != nil {
return nil, TraySetLabelOutput{}, err
}
// Label is part of the tray configuration; placeholder for now
_ = input.Label
return nil, TraySetLabelOutput{Success: true}, nil
}
@ -74,29 +70,11 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
}
config, ok := result.(map[string]any)
if !ok {
return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query")
return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", 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 ---
func (s *Subsystem) registerTrayTools(server *mcp.Server) {
@ -104,5 +82,4 @@ 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_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_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage)
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/webview"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -105,35 +105,11 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
}
sr, ok := result.(webview.ScreenshotResult)
if !ok {
return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot")
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", 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 ---
type WebviewScrollInput struct {
@ -272,7 +248,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in
}
msgs, ok := result.([]webview.ConsoleMessage)
if !ok {
return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query")
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil)
}
return nil, WebviewConsoleOutput{Messages: msgs}, nil
}
@ -295,63 +271,6 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
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 ---
type WebviewQueryInput struct {
@ -370,17 +289,11 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
}
el, ok := result.(*webview.ElementInfo)
if !ok {
return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query")
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", 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 ---
type WebviewQueryAllInput struct {
@ -399,7 +312,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i
}
els, ok := result.([]*webview.ElementInfo)
if !ok {
return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all")
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil)
}
return nil, WebviewQueryAllOutput{Elements: els}, nil
}
@ -422,204 +335,11 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
}
html, ok := result.(string)
if !ok {
return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query")
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", 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 ---
type WebviewURLInput struct {
@ -637,7 +357,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input
}
url, ok := result.(string)
if !ok {
return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query")
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil)
}
return nil, WebviewURLOutput{URL: url}, nil
}
@ -659,7 +379,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
}
title, ok := result.(string)
if !ok {
return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query")
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil)
}
return nil, WebviewTitleOutput{Title: title}, nil
}
@ -672,7 +392,6 @@ 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_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_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_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)
@ -681,24 +400,9 @@ 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_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_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_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_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_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,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type", nil)
}
return nil, WindowListOutput{Windows: windows}, nil
}
@ -44,7 +44,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W
}
info, ok := result.(*window.WindowInfo)
if !ok {
return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query")
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type", nil)
}
return nil, WindowGetOutput{Window: info}, nil
}
@ -63,7 +63,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type", nil)
}
for _, w := range windows {
if w.Focused {
@ -105,7 +105,7 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu
}
info, ok := result.(window.WindowInfo)
if !ok {
return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task")
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type", nil)
}
return nil, WindowCreateOutput{Window: info}, nil
}
@ -276,12 +276,6 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
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 ---
type WindowTitleGetInput struct {
@ -362,24 +356,6 @@ func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolReq
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 ---
type WindowFullscreenInput struct {
@ -413,13 +389,10 @@ 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_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: "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_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_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_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)
}

View file

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

View file

@ -1,17 +1,9 @@
// pkg/menu/messages.go
package menu
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// QueryGetAppMenu returns the current app menu item descriptors.
// Result: []MenuItem
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 }
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }

View file

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

View file

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

View file

@ -1,9 +1,9 @@
// pkg/menu/register.go
package menu
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// Register(p) binds the menu service to a Core instance.
// core.WithService(menu.Register(wailsMenu))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/menu/service.go
package menu
import (
@ -7,24 +6,21 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the menu service.
type Options struct{}
// Service is a core.Service managing application menus via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
items []MenuItem // last-set menu items for QueryGetAppMenu
menuItems []MenuItem
showDevTools bool
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if mCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(mCfg)
if menuConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(menuConfig)
}
}
s.Core().RegisterQuery(s.handleQuery)
@ -32,20 +28,18 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if v, ok := cfg["show_dev_tools"]; ok {
func (s *Service) applyConfig(configData map[string]any) {
if v, ok := configData["show_dev_tools"]; ok {
if show, ok := v.(bool); ok {
s.showDevTools = show
}
}
}
// ShowDevTools returns whether developer tools menu items should be shown.
func (s *Service) ShowDevTools() bool {
return s.showDevTools
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -53,7 +47,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryGetAppMenu:
return s.items, true, nil
return s.menuItems, true, nil
default:
return nil, false, nil
}
@ -62,7 +56,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetAppMenu:
s.items = t.Items
s.menuItems = t.Items
s.manager.SetApplicationMenu(t.Items)
return nil, true, nil
default:
@ -70,7 +64,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
}
// Manager returns the underlying menu Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -1,17 +1,13 @@
// pkg/notification/messages.go
package notification
// QueryPermission checks notification authorisation. Result: PermissionStatus
// QueryPermission returns current notification permission status. Result: PermissionStatus
type QueryPermission struct{}
// TaskSend sends a notification. Falls back to dialog if platform fails.
type TaskSend struct{ Opts NotificationOptions }
// TaskSend sends a native notification, falling back to dialog on failure.
type TaskSend struct{ Options NotificationOptions }
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
// TaskRequestPermission requests notification permission from the OS. Result: bool (granted)
type TaskRequestPermission struct{}
// TaskClear clears pending notifications when the backend supports it.
type TaskClear struct{}
// ActionNotificationClicked is broadcast when a notification is clicked.
// ActionNotificationClicked is broadcast when the user clicks a notification.
type ActionNotificationClicked struct{ ID string }

View file

@ -3,17 +3,11 @@ package notification
// Platform abstracts the native notification backend.
type Platform interface {
Send(opts NotificationOptions) error
Send(options NotificationOptions) error
RequestPermission() (bool, error)
CheckPermission() (bool, 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.
type NotificationSeverity int
@ -30,18 +24,9 @@ type NotificationOptions struct {
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
Severity NotificationSeverity `json:"severity,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
}
// PermissionStatus indicates whether notifications are authorised.
type PermissionStatus struct {
Granted bool `json:"granted"`
}
type clearer interface {
Clear() error
}
type actionSender interface {
SendWithActions(opts NotificationOptions) error
}

View file

@ -3,26 +3,20 @@ package notification
import (
"context"
"fmt"
"strconv"
"time"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/dialog"
)
// Options configures the notification service.
// Use: core.WithService(notification.Register(platform))
type Options struct{}
// Service manages notifications via Core tasks and queries.
// Use: svc := &notification.Service{}
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a Core service factory for the notification backend.
// Use: core.New(core.WithService(notification.Register(platform)))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -32,15 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// 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().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents satisfies Core's IPC hook.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -58,47 +49,34 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSend:
return nil, true, s.sendNotification(t.Opts)
return nil, true, s.send(t.Options)
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
}
}
// sendNotification attempts a native notification and falls back to a dialog via IPC.
func (s *Service) sendNotification(opts NotificationOptions) error {
// Generate an ID when the caller does not provide one.
if opts.ID == "" {
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
// send attempts native notification, falls back to dialog via IPC.
func (s *Service) send(options NotificationOptions) error {
// Generate ID if not provided
if options.ID == "" {
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
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)
if err := s.platform.Send(options); err != nil {
// Fallback: show as dialog via IPC
return s.fallbackDialog(options)
}
return nil
}
// showFallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) showFallbackDialog(opts NotificationOptions) error {
// Map severity to dialog type.
// fallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) fallbackDialog(options NotificationOptions) error {
// Map severity to dialog type
var dt dialog.DialogType
switch opts.Severity {
switch options.Severity {
case SeverityWarning:
dt = dialog.DialogWarning
case SeverityError:
@ -107,15 +85,15 @@ func (s *Service) showFallbackDialog(opts NotificationOptions) error {
dt = dialog.DialogInfo
}
msg := opts.Message
if opts.Subtitle != "" {
msg = opts.Subtitle + "\n\n" + msg
msg := options.Message
if options.Subtitle != "" {
msg = options.Subtitle + "\n\n" + msg
}
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Options: dialog.MessageDialogOptions{
Type: dt,
Title: opts.Title,
Title: options.Title,
Message: msg,
Buttons: []string{"OK"},
},

View file

@ -18,7 +18,6 @@ type mockPlatform struct {
permErr error
lastOpts NotificationOptions
sendCalled bool
clearCalled bool
}
func (m *mockPlatform) Send(opts NotificationOptions) error {
@ -26,14 +25,8 @@ func (m *mockPlatform) Send(opts NotificationOptions) error {
m.lastOpts = opts
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) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
func (m *mockPlatform) Clear() error { m.clearCalled = true; return nil }
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
type mockDialogPlatform struct {
@ -73,7 +66,7 @@ func TestRegister_Good(t *testing.T) {
func TestTaskSend_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
Options: NotificationOptions{Title: "Test", Message: "Hello"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -94,7 +87,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
})
assert.True(t, handled)
assert.NoError(t, err) // fallback succeeds even though platform failed
@ -124,26 +117,3 @@ func TestTaskSend_Bad(t *testing.T) {
_, handled, _ := c.PERFORM(TaskSend{})
assert.False(t, handled)
}
func TestTaskSend_WithActions_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{
Title: "Test",
Message: "Hello",
Actions: []NotificationAction{{ID: "ok", Label: "OK"}},
},
})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.sendCalled)
assert.Len(t, mock.lastOpts.Actions, 1)
}
func TestTaskClear_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskClear{})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.clearCalled)
}

View file

@ -2,25 +2,19 @@
package screen
// QueryAll returns all screens. Result: []Screen
// Use: result, _, err := c.QUERY(screen.QueryAll{})
type QueryAll struct{}
// QueryPrimary returns the primary screen. Result: *Screen (nil if not found)
// Use: result, _, err := c.QUERY(screen.QueryPrimary{})
type QueryPrimary struct{}
// 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 }
// 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 }
// QueryWorkAreas returns work areas for all screens. Result: []Rect
// Use: result, _, err := c.QUERY(screen.QueryWorkAreas{})
type QueryWorkAreas struct{}
// ActionScreensChanged is broadcast when displays change.
// Use: _ = c.ACTION(screen.ActionScreensChanged{Screens: screens})
// ActionScreensChanged is broadcast when displays change (future).
type ActionScreensChanged struct{ Screens []Screen }

View file

@ -2,14 +2,12 @@
package screen
// Platform abstracts the screen/display backend.
// Use: var p screen.Platform
type Platform interface {
GetAll() []Screen
GetPrimary() *Screen
}
// Screen describes a display/monitor.
// Use: scr := screen.Screen{ID: "display-1"}
type Screen struct {
ID string `json:"id"`
Name string `json:"name"`
@ -22,7 +20,6 @@ type Screen struct {
}
// Rect represents a rectangle with position and dimensions.
// Use: rect := screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
type Rect struct {
X int `json:"x"`
Y int `json:"y"`
@ -31,7 +28,6 @@ type Rect struct {
}
// Size represents dimensions.
// Use: size := screen.Size{Width: 1920, Height: 1080}
type Size struct {
Width int `json:"width"`
Height int `json:"height"`

View file

@ -7,19 +7,15 @@ import (
"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{}
// Service is a core.Service providing screen/display queries via IPC.
// Use: svc, err := screen.Register(platform)(core.New())
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
// Use: core.WithService(screen.Register(platform))
// Register(p) binds the screen service to a Core instance.
// core.WithService(screen.Register(wailsScreen))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,15 +25,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
// Use: _ = svc.HandleIPCEvents(core, msg)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

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

View file

@ -1,54 +1,17 @@
// pkg/systray/messages.go
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{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayIcon{Data: iconBytes})
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 }
// TaskShowPanel shows the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskShowPanel{})
type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskHidePanel{})
type TaskHidePanel struct{}
// 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"`
}
type TaskSaveConfig struct{ Config map[string]any }
// 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{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
// Use: _ = c.ACTION(systray.ActionTrayMenuItemClicked{ActionID: "quit"})
type ActionTrayMenuItemClicked struct{ ActionID string }

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
// pkg/systray/register.go
package systray
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// Register(p) binds the systray service to a Core instance.
// core.WithService(systray.Register(wailsSystray))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,19 +1,13 @@
// pkg/systray/service.go
package systray
import (
"context"
"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{}
// Service manages system tray operations via Core tasks.
// Use: svc := &systray.Service{}
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
@ -21,34 +15,31 @@ type Service struct {
iconPath string
}
// OnStartup loads tray config and registers task handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if tCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(tCfg)
if trayConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(trayConfig)
}
}
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
tooltip, _ := cfg["tooltip"].(string)
func (s *Service) applyConfig(configData map[string]any) {
tooltip, _ := configData["tooltip"].(string)
if tooltip == "" {
tooltip = "Core"
}
_ = s.manager.Setup(tooltip, tooltip)
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
// Icon loading is deferred to when assets are available.
// Store the path for later use.
s.iconPath = iconPath
}
}
// HandleIPCEvents satisfies Core's IPC hook.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -57,18 +48,14 @@ 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()
// Panel show — deferred (requires WindowHandle integration)
return nil, true, nil
case TaskHidePanel:
return nil, true, s.manager.HidePanel()
case TaskShowMessage:
return nil, true, s.showTrayMessage(t.Title, t.Message)
// Panel hide — deferred (requires WindowHandle integration)
return nil, true, nil
default:
return nil, false, nil
}
@ -87,29 +74,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
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 {
return s.manager
}

View file

@ -9,18 +9,6 @@ import (
"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) {
t.Helper()
c, err := core.New(
@ -51,24 +39,6 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
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) {
svc, c := newTestSystrayService(t)
@ -84,33 +54,6 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
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) {
// No systray service — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())
@ -118,29 +61,3 @@ func TestTaskSetTrayIcon_Bad(t *testing.T) {
_, 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,29 +5,23 @@ import (
_ "embed"
"sync"
"forge.lthn.ai/core/go/pkg/core"
coreerr "forge.lthn.ai/core/go-log"
)
//go:embed assets/apptray.png
var defaultIcon []byte
// Manager manages the system tray lifecycle.
// Use: manager := systray.NewManager(platform)
// State that was previously in package-level vars is now on the Manager.
type Manager struct {
platform Platform
tray PlatformTray
panelWindow WindowHandle
callbacks map[string]func()
tooltip string
label string
hasIcon bool
hasTemplateIcon bool
menuItems []TrayMenuItem
mu sync.RWMutex
platform Platform
tray PlatformTray
callbacks map[string]func()
mu sync.RWMutex
}
// NewManager creates a systray Manager.
// Use: manager := systray.NewManager(platform)
// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core")
func NewManager(platform Platform) *Manager {
return &Manager{
platform: platform,
@ -36,112 +30,69 @@ func NewManager(platform Platform) *Manager {
}
// Setup creates the system tray with default icon and tooltip.
// Use: _ = manager.Setup("Core", "Core")
// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core")
func (m *Manager) Setup(tooltip, label string) error {
m.tray = m.platform.NewTray()
if m.tray == nil {
return core.E("systray.Setup", "platform returned nil tray", nil)
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
}
m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip)
m.tray.SetLabel(label)
m.tooltip = tooltip
m.label = label
m.hasTemplateIcon = true
return nil
}
// SetIcon sets the tray icon.
// Use: _ = manager.SetIcon(iconBytes)
func (m *Manager) SetIcon(data []byte) error {
if m.tray == nil {
return core.E("systray.SetIcon", "tray not initialised", nil)
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
}
m.tray.SetIcon(data)
m.hasIcon = len(data) > 0
return nil
}
// SetTemplateIcon sets the template icon (macOS).
// Use: _ = manager.SetTemplateIcon(iconBytes)
func (m *Manager) SetTemplateIcon(data []byte) error {
if m.tray == nil {
return core.E("systray.SetTemplateIcon", "tray not initialised", nil)
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
}
m.tray.SetTemplateIcon(data)
m.hasTemplateIcon = len(data) > 0
return nil
}
// SetTooltip sets the tray tooltip.
// Use: _ = manager.SetTooltip("Core is ready")
func (m *Manager) SetTooltip(text string) error {
if m.tray == nil {
return core.E("systray.SetTooltip", "tray not initialised", nil)
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
}
m.tray.SetTooltip(text)
m.tooltip = text
return nil
}
// SetLabel sets the tray label.
// Use: _ = manager.SetLabel("Core")
func (m *Manager) SetLabel(text string) error {
if m.tray == nil {
return core.E("systray.SetLabel", "tray not initialised", nil)
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
}
m.tray.SetLabel(text)
m.label = text
return nil
}
// AttachWindow attaches a panel window to the tray.
// Use: _ = manager.AttachWindow(windowHandle)
func (m *Manager) AttachWindow(w WindowHandle) error {
if m.tray == nil {
return core.E("systray.AttachWindow", "tray not initialised", nil)
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
}
m.mu.Lock()
m.panelWindow = w
m.mu.Unlock()
m.tray.AttachWindow(w)
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.
// Use: tray := manager.Tray()
func (m *Manager) Tray() PlatformTray {
return m.tray
}
// IsActive returns whether a tray has been created.
// Use: active := manager.IsActive()
func (m *Manager) IsActive() bool {
return m.tray != nil
}

View file

@ -84,3 +84,29 @@ func TestManager_GetInfo_Good(t *testing.T) {
info = m.GetInfo()
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,31 +6,25 @@ import (
)
// WailsPlatform implements Platform using Wails v3.
// Use: platform := systray.NewWailsPlatform(app)
type WailsPlatform struct {
app *application.App
}
// NewWailsPlatform creates a Wails-backed tray platform.
// Use: platform := systray.NewWailsPlatform(app)
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
// NewTray creates a Wails system tray handle.
// Use: tray := platform.NewTray()
func (wp *WailsPlatform) NewTray() PlatformTray {
return &wailsTray{tray: wp.app.SystemTray.New()}
return &wailsTray{tray: wp.app.SystemTray.New(), app: wp.app}
}
// NewMenu creates a Wails tray menu handle.
// Use: menu := platform.NewMenu()
func (wp *WailsPlatform) NewMenu() PlatformMenu {
return &wailsTrayMenu{menu: wp.app.NewMenu()}
}
type wailsTray struct {
tray *application.SystemTray
app *application.App
}
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
@ -45,19 +39,8 @@ func (wt *wailsTray) SetMenu(menu PlatformMenu) {
}
func (wt *wailsTray) AttachWindow(w WindowHandle) {
if wt.tray == nil {
return
}
window, ok := w.(interface {
Show()
Hide()
Focus()
IsVisible() bool
})
if !ok {
return
}
wt.tray.AttachWindow(window)
// Wails systray AttachWindow expects an application.Window interface.
// The caller must pass an appropriate wrapper.
}
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
@ -88,7 +71,3 @@ func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabl
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
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()}
}

View file

@ -1,34 +0,0 @@
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())
}

View file

@ -1,158 +0,0 @@
// 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,19 +6,12 @@ import "time"
// --- Queries (read-only) ---
// QueryURL gets the current page URL. Result: string
// Use: result, _, err := c.QUERY(webview.QueryURL{Window: "editor"})
type QueryURL struct {
Window string `json:"window"`
}
type QueryURL struct{ Window string `json:"window"` }
// QueryTitle gets the current page title. Result: string
// Use: result, _, err := c.QUERY(webview.QueryTitle{Window: "editor"})
type QueryTitle struct {
Window string `json:"window"`
}
type QueryTitle struct{ Window string `json:"window"` }
// QueryConsole gets captured console messages. Result: []ConsoleMessage
// Use: result, _, err := c.QUERY(webview.QueryConsole{Window: "editor", Level: "error", Limit: 20})
type QueryConsole struct {
Window string `json:"window"`
Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug"
@ -26,77 +19,38 @@ type QueryConsole struct {
}
// 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 {
Window string `json:"window"`
Selector string `json:"selector"`
}
// QuerySelectorAll finds all matching elements. Result: []*ElementInfo
// Use: result, _, err := c.QUERY(webview.QuerySelectorAll{Window: "editor", Selector: "button"})
type QuerySelectorAll struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// QueryDOMTree gets HTML content. Result: string (outerHTML)
// Use: result, _, err := c.QUERY(webview.QueryDOMTree{Window: "editor", Selector: "main"})
type QueryDOMTree struct {
Window string `json:"window"`
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) ---
// TaskEvaluate executes JavaScript. Result: any (JS return value)
// Use: _, _, err := c.PERFORM(webview.TaskEvaluate{Window: "editor", Script: "document.title"})
type TaskEvaluate struct {
Window string `json:"window"`
Script string `json:"script"`
}
// TaskClick clicks an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskClick{Window: "editor", Selector: "#submit"})
type TaskClick struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// TaskType types text into an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskType{Window: "editor", Selector: "#search", Text: "core"})
type TaskType struct {
Window string `json:"window"`
Selector string `json:"selector"`
@ -104,27 +58,15 @@ type TaskType struct {
}
// TaskNavigate navigates to a URL. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskNavigate{Window: "editor", URL: "https://example.com"})
type TaskNavigate struct {
Window string `json:"window"`
URL string `json:"url"`
}
// TaskScreenshot captures the page as PNG. Result: ScreenshotResult
// 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"`
}
type TaskScreenshot struct{ Window string `json:"window"` }
// 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 {
Window string `json:"window"`
X int `json:"x"`
@ -132,14 +74,12 @@ type TaskScroll struct {
}
// TaskHover hovers over an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskHover{Window: "editor", Selector: "#help"})
type TaskHover struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// 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 {
Window string `json:"window"`
Selector string `json:"selector"`
@ -147,7 +87,6 @@ type TaskSelect struct {
}
// TaskCheck checks/unchecks a checkbox. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskCheck{Window: "editor", Selector: "#accept", Checked: true})
type TaskCheck struct {
Window string `json:"window"`
Selector string `json:"selector"`
@ -155,7 +94,6 @@ type TaskCheck struct {
}
// 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 {
Window string `json:"window"`
Selector string `json:"selector"`
@ -163,7 +101,6 @@ type TaskUploadFile struct {
}
// TaskSetViewport sets the viewport dimensions. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskSetViewport{Window: "editor", Width: 1280, Height: 800})
type TaskSetViewport struct {
Window string `json:"window"`
Width int `json:"width"`
@ -171,66 +108,17 @@ type TaskSetViewport struct {
}
// TaskClearConsole clears captured console messages. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskClearConsole{Window: "editor"})
type TaskClearConsole struct {
Window string `json:"window"`
}
// TaskHighlight visually highlights an element.
// Use: _, _, err := c.PERFORM(webview.TaskHighlight{Window: "editor", Selector: "#submit", Colour: "#ffcc00"})
type TaskHighlight struct {
Window string `json:"window"`
Selector string `json:"selector"`
Colour string `json:"colour,omitempty"`
}
// TaskOpenDevTools opens the browser devtools for the target window. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskOpenDevTools{Window: "editor"})
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 {
Window string `json:"window"`
}
// TaskExportPDF exports the page to a PDF document.
// Use: result, _, err := c.PERFORM(webview.TaskExportPDF{Window: "editor"})
type TaskExportPDF struct {
Window string `json:"window"`
}
type TaskClearConsole struct{ Window string `json:"window"` }
// --- Actions (broadcast) ---
// 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 {
Window string `json:"window"`
Message ConsoleMessage `json:"message"`
}
// ActionException is broadcast when a JavaScript exception occurs.
// Use: _ = c.ACTION(webview.ActionException{Window: "editor", Exception: webview.ExceptionInfo{Text: "ReferenceError"}})
type ActionException struct {
Window string `json:"window"`
Exception ExceptionInfo `json:"exception"`
@ -239,7 +127,6 @@ type ActionException struct {
// --- Types ---
// ConsoleMessage represents a browser console message.
// Use: msg := webview.ConsoleMessage{Type: "warn", Text: "slow network"}
type ConsoleMessage struct {
Type string `json:"type"` // "log", "warn", "error", "info", "debug"
Text string `json:"text"`
@ -250,7 +137,6 @@ type ConsoleMessage struct {
}
// ElementInfo represents a DOM element.
// Use: el := webview.ElementInfo{TagName: "button", InnerText: "Save"}
type ElementInfo struct {
TagName string `json:"tagName"`
Attributes map[string]string `json:"attributes,omitempty"`
@ -260,7 +146,6 @@ type ElementInfo struct {
}
// BoundingBox represents element position and size.
// Use: box := webview.BoundingBox{X: 10, Y: 20, Width: 120, Height: 40}
type BoundingBox struct {
X float64 `json:"x"`
Y float64 `json:"y"`
@ -270,7 +155,6 @@ type BoundingBox struct {
// ExceptionInfo represents a JavaScript exception.
// Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column.
// Use: err := webview.ExceptionInfo{Text: "ReferenceError", URL: "app://editor"}
type ExceptionInfo struct {
Text string `json:"text"`
URL string `json:"url,omitempty"`
@ -281,52 +165,7 @@ type ExceptionInfo struct {
}
// ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport.
// Use: shot := webview.ScreenshotResult{Base64: "iVBORw0KGgo=", MimeType: "image/png"}
type ScreenshotResult struct {
Base64 string `json:"base64"`
MimeType string `json:"mimeType"` // always "image/png"
}
// PerformanceMetrics summarises browser performance timings.
// 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"`
MimeType string `json:"mimeType"` // always "application/pdf"
}

View file

@ -5,18 +5,9 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"math"
"reflect"
"strconv"
"strings"
"sync"
"time"
"unsafe"
gowebview "forge.lthn.ai/core/go-webview"
"forge.lthn.ai/core/go/pkg/core"
@ -43,54 +34,55 @@ type connector interface {
ClearConsole()
SetViewport(width, height int) error
UploadFile(selector string, paths []string) error
Print() error
PrintToPDF() ([]byte, error)
Close() error
}
// Options holds configuration for the webview service.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Options struct {
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
Timeout time.Duration // Operation timeout (default: 30s)
ConsoleLimit int // Max console messages per window (default: 1000)
}
// Service is a core.Service managing webview interactions via IPC.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
func defaultOptions() Options {
return Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
}
func normalizeOptions(options Options) Options {
defaults := defaultOptions()
if options.DebugURL == "" {
options.DebugURL = defaults.DebugURL
}
if options.Timeout == 0 {
options.Timeout = defaults.Timeout
}
if options.ConsoleLimit == 0 {
options.ConsoleLimit = defaults.ConsoleLimit
}
return options
}
type Service struct {
*core.ServiceRuntime[Options]
opts Options
options Options
connections map[string]connector
exceptions map[string][]ExceptionInfo
mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation
}
// Register creates a factory closure with declarative options.
// Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
func Register(options Options) func(*core.Core) (any, error) {
o := Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
if options.DebugURL != "" {
o.DebugURL = options.DebugURL
}
if options.Timeout != 0 {
o.Timeout = options.Timeout
}
if options.ConsoleLimit != 0 {
o.ConsoleLimit = options.ConsoleLimit
}
// RegisterWithOptions binds the webview service to a Core instance using a declarative Options literal.
// core.WithService(webview.RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000}))
func RegisterWithOptions(options Options) func(*core.Core) (any, error) {
o := normalizeOptions(options)
return func(c *core.Core) (any, error) {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o,
options: o,
connections: make(map[string]connector),
exceptions: make(map[string][]ExceptionInfo),
newConn: defaultNewConn(o),
}
svc.watcherSetup = svc.defaultWatcherSetup
@ -98,8 +90,19 @@ func Register(options Options) func(*core.Core) (any, error) {
}
}
// Deprecated: use RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000}).
func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) {
options := defaultOptions()
for _, fn := range optionFns {
if fn != nil {
fn(&options)
}
}
return RegisterWithOptions(options)
}
// defaultNewConn creates real go-webview connections.
func defaultNewConn(opts Options) func(string, string) (connector, error) {
func defaultNewConn(options Options) func(string, string) (connector, error) {
return func(debugURL, windowName string) (connector, error) {
// Enumerate targets, match by title/URL containing window name
targets, err := gowebview.ListTargets(debugURL)
@ -108,7 +111,7 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
}
var wsURL string
for _, t := range targets {
if t.Type == "page" && (strings.Contains(t.Title, windowName) || strings.Contains(t.URL, windowName)) {
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) {
wsURL = t.WebSocketDebuggerURL
break
}
@ -127,8 +130,8 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
}
wv, err := gowebview.New(
gowebview.WithDebugURL(debugURL),
gowebview.WithTimeout(opts.Timeout),
gowebview.WithConsoleLimit(opts.ConsoleLimit),
gowebview.WithTimeout(options.Timeout),
gowebview.WithConsoleLimit(options.ConsoleLimit),
)
if err != nil {
return nil, err
@ -176,7 +179,6 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
})
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(_ context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
@ -203,10 +205,7 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
conn.Close()
delete(s.connections, m.Name)
}
delete(s.exceptions, m.Name)
s.mu.Unlock()
case ActionException:
s.recordException(m.Window, m.Exception)
}
return nil
}
@ -226,7 +225,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
if conn, ok := s.connections[windowName]; ok {
return conn, nil
}
conn, err := s.newConn(s.opts.DebugURL, windowName)
conn, err := s.newConn(s.options.DebugURL, windowName)
if err != nil {
return nil, err
}
@ -299,64 +298,6 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
}
html, err := conn.GetHTML(selector)
return html, true, err
case QueryComputedStyle:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(computedStyleScript(q.Selector))
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:
return nil, false, nil
}
@ -402,19 +343,6 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png",
}, true, nil
case TaskScreenshotElement:
conn, err := s.getConn(t.Window)
if err != nil {
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 {
@ -459,208 +387,11 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
}
conn.ClearConsole()
return nil, true, nil
case TaskHighlight:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(highlightScript(t.Selector, t.Colour))
return nil, true, err
case TaskOpenDevTools:
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)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(networkInitScript())
return nil, true, err
case TaskClearNetworkLog:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(networkClearScript())
return nil, true, err
case TaskPrint:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Print()
case TaskExportPDF:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
pdf, err := conn.PrintToPDF()
if err != nil {
return nil, true, err
}
return PDFResult{
Base64: base64.StdEncoding.EncodeToString(pdf),
MimeType: "application/pdf",
}, 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.
type realConnector struct {
wv *gowebview.Webview
@ -675,48 +406,9 @@ func (r *realConnector) GetURL() (string, error) { return r.wv.G
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) 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) 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) PrintToPDF() ([]byte, error) {
client, err := r.cdpClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := client.Call(ctx, "Page.printToPDF", map[string]any{
"printBackground": true,
"preferCSSPageSize": true,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(string)
if !ok || data == "" {
return nil, fmt.Errorf("webview: missing PDF data")
}
return base64.StdEncoding.DecodeString(data)
}
func (r *realConnector) cdpClient() (*gowebview.CDPClient, error) {
rv := reflect.ValueOf(r.wv)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return nil, fmt.Errorf("webview: invalid connector")
}
elem := rv.Elem()
field := elem.FieldByName("client")
if !field.IsValid() || field.IsNil() {
return nil, fmt.Errorf("webview: CDP client not available")
}
ptr := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface()
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 {
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)

View file

@ -2,13 +2,7 @@
package webview
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
"forge.lthn.ai/core/go/pkg/core"
@ -18,17 +12,14 @@ import (
)
type mockConnector struct {
url string
title string
html string
evalResult any
evalFn func(script string) (any, error)
screenshot []byte
console []ConsoleMessage
elements []*ElementInfo
closed bool
pdfBytes []byte
printCalled bool
url string
title string
html string
evalResult any
screenshot []byte
console []ConsoleMessage
elements []*ElementInfo
closed bool
lastClickSel string
lastTypeSel string
@ -44,7 +35,6 @@ type mockConnector struct {
lastViewportW int
lastViewportH int
consoleClearCalled bool
lastEvalScript string
}
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
@ -65,31 +55,18 @@ func (m *mockConnector) Check(sel string, c bool) error {
m.lastCheckVal = c
return nil
}
func (m *mockConnector) Evaluate(s string) (any, error) {
m.lastEvalScript = s
if m.evalFn != nil {
return m.evalFn(s)
}
return m.evalResult, nil
}
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil }
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
func (m *mockConnector) Print() error { m.printCalled = true; return nil }
func (m *mockConnector) Close() error { m.closed = true; return nil }
func (m *mockConnector) SetViewport(w, h int) error {
m.lastViewportW = w
m.lastViewportH = h
return nil
}
func (m *mockConnector) PrintToPDF() ([]byte, error) {
if len(m.pdfBytes) == 0 {
return []byte("%PDF-1.4\n"), nil
}
return m.pdfBytes, nil
}
func (m *mockConnector) UploadFile(sel string, p []string) error {
m.lastUploadSel = sel
m.lastUploadPaths = p
@ -111,12 +88,8 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
t.Helper()
factory := Register(Options{})
c, err := core.New(
core.WithService(window.Register(window.NewMockPlatform())),
core.WithService(factory),
core.WithServiceLock(),
)
factory := RegisterWithOptions(Options{})
c, err := core.New(core.WithService(factory), core.WithServiceLock())
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "webview")
@ -125,7 +98,7 @@ func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
return svc, c
}
func TestRegister_Good(t *testing.T) {
func TestRegisterWithOptions_Good(t *testing.T) {
svc, _ := newTestService(t, &mockConnector{})
assert.NotNil(t, svc)
}
@ -174,29 +147,6 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
assert.Equal(t, "b", msgs[0].Text) // last 2
}
func TestQueryExceptions_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{})
require.NoError(t, c.ACTION(ActionException{
Window: "main",
Exception: ExceptionInfo{
Text: "boom",
URL: "https://example.com/app.js",
Line: 12,
Column: 4,
StackTrace: "Error: boom",
},
}))
result, handled, err := c.QUERY(QueryExceptions{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
exceptions, _ := result.([]ExceptionInfo)
require.Len(t, exceptions, 1)
assert.Equal(t, "boom", exceptions[0].Text)
assert.Equal(t, 12, exceptions[0].Line)
}
func TestTaskEvaluate_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{evalResult: 42})
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
@ -235,43 +185,6 @@ func TestTaskScreenshot_Good(t *testing.T) {
assert.NotEmpty(t, sr.Base64)
}
func TestTaskScreenshotElement_Good(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetRGBA(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 200, A: 255})
}
}
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, img))
mock := &mockConnector{
screenshot: buf.Bytes(),
evalFn: func(script string) (any, error) {
return map[string]any{
"left": 1.0,
"top": 1.0,
"width": 2.0,
"height": 2.0,
"devicePixelRatio": 1.0,
}, nil
},
}
_, c := newTestService(t, mock)
result, handled, err := c.PERFORM(TaskScreenshotElement{Window: "main", Selector: "#card"})
require.NoError(t, err)
assert.True(t, handled)
sr, ok := result.(ScreenshotResult)
require.True(t, ok)
raw, err := base64.StdEncoding.DecodeString(sr.Base64)
require.NoError(t, err)
decoded, err := png.Decode(bytes.NewReader(raw))
require.NoError(t, err)
assert.Equal(t, image.Rect(0, 0, 2, 2), decoded.Bounds())
}
func TestTaskClearConsole_Good(t *testing.T) {
mock := &mockConnector{}
_, c := newTestService(t, mock)
@ -281,102 +194,6 @@ func TestTaskClearConsole_Good(t *testing.T) {
assert.True(t, mock.consoleClearCalled)
}
func TestTaskDevTools_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{})
_, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("main")}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskOpenDevTools{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
_, handled, err = c.PERFORM(TaskCloseDevTools{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
}
func TestDiagnosticsQueries_Good(t *testing.T) {
mock := &mockConnector{
evalFn: func(script string) (any, error) {
switch {
case strings.Contains(script, "getComputedStyle"):
return map[string]any{"color": "rgb(1, 2, 3)"}, nil
case strings.Contains(script, "performance.getEntriesByType(\"navigation\")"):
return map[string]any{
"navigationStart": 1.0,
"domContentLoaded": 2.0,
"loadEventEnd": 3.0,
"firstPaint": 4.0,
"firstContentfulPaint": 5.0,
"usedJSHeapSize": 6.0,
"totalJSHeapSize": 7.0,
}, nil
case strings.Contains(script, "performance.getEntriesByType(\"resource\")"):
return []any{
map[string]any{"name": "app.js", "entryType": "resource", "initiatorType": "script"},
}, nil
case strings.Contains(script, "window.__coreNetworkLog"):
return []any{
map[string]any{"url": "https://example.com", "method": "GET", "status": 200, "resource": "fetch"},
}, nil
default:
return nil, nil
}
},
}
_, c := newTestService(t, mock)
style, handled, err := c.QUERY(QueryComputedStyle{Window: "main", Selector: "#app"})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "rgb(1, 2, 3)", style.(map[string]string)["color"])
perf, handled, err := c.QUERY(QueryPerformance{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, 1.0, perf.(PerformanceMetrics).NavigationStart)
resources, handled, err := c.QUERY(QueryResources{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
assert.Len(t, resources.([]ResourceEntry), 1)
network, handled, err := c.QUERY(QueryNetwork{Window: "main", Limit: 10})
require.NoError(t, err)
assert.True(t, handled)
assert.Len(t, network.([]NetworkEntry), 1)
}
func TestDiagnosticsTasks_Good(t *testing.T) {
mock := &mockConnector{pdfBytes: []byte("%PDF-1.7")}
_, c := newTestService(t, mock)
_, handled, err := c.PERFORM(TaskHighlight{Window: "main", Selector: "#app", Colour: "#00ff00"})
require.NoError(t, err)
assert.True(t, handled)
assert.Contains(t, mock.lastEvalScript, "outline")
_, handled, err = c.PERFORM(TaskInjectNetworkLogging{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
assert.Contains(t, mock.lastEvalScript, "__coreNetworkLog")
_, handled, err = c.PERFORM(TaskClearNetworkLog{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
_, handled, err = c.PERFORM(TaskPrint{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
assert.True(t, mock.printCalled)
result, handled, err := c.PERFORM(TaskExportPDF{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
pdf, ok := result.(PDFResult)
require.True(t, ok)
assert.Equal(t, "application/pdf", pdf.MimeType)
assert.NotEmpty(t, pdf.Base64)
}
func TestConnectionCleanup_Good(t *testing.T) {
mock := &mockConnector{}
_, c := newTestService(t, mock)

View file

@ -3,15 +3,16 @@ package window
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
// Layout is a named window arrangement.
// Use: layout := window.Layout{Name: "coding"}
type Layout struct {
Name string `json:"name"`
Windows map[string]WindowState `json:"windows"`
@ -20,7 +21,6 @@ type Layout struct {
}
// LayoutInfo is a summary of a layout.
// Use: info := window.LayoutInfo{Name: "coding", WindowCount: 2}
type LayoutInfo struct {
Name string `json:"name"`
WindowCount int `json:"windowCount"`
@ -29,7 +29,6 @@ type LayoutInfo struct {
}
// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json.
// Use: lm := window.NewLayoutManager()
type LayoutManager struct {
configDir string
layouts map[string]Layout
@ -37,7 +36,6 @@ type LayoutManager struct {
}
// NewLayoutManager creates a LayoutManager loading from the default config directory.
// Use: lm := window.NewLayoutManager()
func NewLayoutManager() *LayoutManager {
lm := &LayoutManager{
layouts: make(map[string]Layout),
@ -46,40 +44,39 @@ func NewLayoutManager() *LayoutManager {
if err == nil {
lm.configDir = filepath.Join(configDir, "Core")
}
lm.loadLayouts()
lm.load()
return lm
}
// NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
// Use: lm := window.NewLayoutManagerWithDir(t.TempDir())
func NewLayoutManagerWithDir(configDir string) *LayoutManager {
lm := &LayoutManager{
configDir: configDir,
layouts: make(map[string]Layout),
}
lm.loadLayouts()
lm.load()
return lm
}
func (lm *LayoutManager) layoutsFilePath() string {
func (lm *LayoutManager) filePath() string {
return filepath.Join(lm.configDir, "layouts.json")
}
func (lm *LayoutManager) loadLayouts() {
func (lm *LayoutManager) load() {
if lm.configDir == "" {
return
}
data, err := os.ReadFile(lm.layoutsFilePath())
content, err := coreio.Local.Read(lm.filePath())
if err != nil {
return
}
lm.mu.Lock()
defer lm.mu.Unlock()
_ = json.Unmarshal(data, &lm.layouts)
_ = json.Unmarshal([]byte(content), &lm.layouts)
}
func (lm *LayoutManager) saveLayouts() {
func (lm *LayoutManager) save() {
if lm.configDir == "" {
return
}
@ -89,15 +86,14 @@ func (lm *LayoutManager) saveLayouts() {
if err != nil {
return
}
_ = os.MkdirAll(lm.configDir, 0o755)
_ = os.WriteFile(lm.layoutsFilePath(), data, 0o644)
_ = coreio.Local.EnsureDir(lm.configDir)
_ = coreio.Local.Write(lm.filePath(), string(data))
}
// SaveLayout creates or updates a named layout.
// Use: _ = lm.SaveLayout("coding", windowStates)
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
if name == "" {
return fmt.Errorf("layout name cannot be empty")
return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil)
}
now := time.Now().UnixMilli()
lm.mu.Lock()
@ -114,12 +110,11 @@ func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowS
}
lm.layouts[name] = layout
lm.mu.Unlock()
lm.saveLayouts()
lm.save()
return nil
}
// GetLayout returns a layout by name.
// Use: layout, ok := lm.GetLayout("coding")
func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
lm.mu.RLock()
defer lm.mu.RUnlock()
@ -128,7 +123,6 @@ func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
}
// ListLayouts returns info summaries for all layouts.
// Use: layouts := lm.ListLayouts()
func (lm *LayoutManager) ListLayouts() []LayoutInfo {
lm.mu.RLock()
defer lm.mu.RUnlock()
@ -143,10 +137,9 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo {
}
// DeleteLayout removes a layout by name.
// Use: lm.DeleteLayout("coding")
func (lm *LayoutManager) DeleteLayout(name string) {
lm.mu.Lock()
delete(lm.layouts, name)
lm.mu.Unlock()
lm.saveLayouts()
lm.save()
}

View file

@ -1,8 +1,5 @@
// pkg/window/messages.go
package window
// WindowInfo contains information about a window.
// Use: info := window.WindowInfo{Name: "editor", Title: "Core Editor"}
type WindowInfo struct {
Name string `json:"name"`
Title string `json:"title"`
@ -10,119 +7,50 @@ type WindowInfo struct {
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
Maximized bool `json:"maximized"`
Focused bool `json:"focused"`
}
// Bounds describes the position and size of a window.
// Use: bounds := window.Bounds{X: 10, Y: 10, Width: 1280, Height: 800}
type Bounds struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// --- Queries (read-only) ---
// QueryWindowList returns all tracked windows. Result: []WindowInfo
// Use: result, _, err := c.QUERY(window.QueryWindowList{})
type QueryWindowList struct{}
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
// Use: result, _, err := c.QUERY(window.QueryWindowByName{Name: "editor"})
type QueryWindowByName struct{ Name string }
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
// Use: result, _, err := c.QUERY(window.QueryConfig{})
type QueryConfig struct{}
// QueryWindowBounds returns the current bounds for a window.
// Use: result, _, err := c.QUERY(window.QueryWindowBounds{Name: "editor"})
type QueryWindowBounds struct{ Name string }
// TaskOpenWindow opens a concrete Window descriptor.
// window.TaskOpenWindow{Window: &window.Window{Name: "settings", URL: "/", Width: 800, Height: 600}}
type TaskOpenWindow struct{ Window *Window }
// QueryFindSpace returns a suggested free placement for a new window.
// Use: result, _, err := c.QUERY(window.QueryFindSpace{Width: 1280, Height: 800})
type QueryFindSpace struct {
Width int
Height int
ScreenWidth int
ScreenHeight int
}
// QueryLayoutSuggestion returns a layout recommendation for the current screen.
// Use: result, _, err := c.QUERY(window.QueryLayoutSuggestion{WindowCount: 2})
type QueryLayoutSuggestion struct {
WindowCount int
ScreenWidth int
ScreenHeight int
}
// --- Tasks (side-effects) ---
// TaskOpenWindow creates a new window. Result: WindowInfo
// Use: _, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("editor")}})
type TaskOpenWindow struct {
Window *Window
Opts []WindowOption
}
// TaskCloseWindow closes a window after persisting state.
// Platform close events emit ActionWindowClosed through the tracked window handler.
// Use: _, _, err := c.PERFORM(window.TaskCloseWindow{Name: "editor"})
type TaskCloseWindow struct{ Name string }
// TaskSetPosition moves a window.
// Use: _, _, err := c.PERFORM(window.TaskSetPosition{Name: "editor", X: 160, Y: 120})
type TaskSetPosition struct {
Name string
X, Y int
}
// TaskSetSize resizes a window.
// Use: _, _, err := c.PERFORM(window.TaskSetSize{Name: "editor", Width: 1280, Height: 800})
type TaskSetSize struct {
Name string
Width, Height int
// W and H are compatibility aliases for older call sites.
W, H int
}
// TaskMaximise maximises a window.
// Use: _, _, err := c.PERFORM(window.TaskMaximise{Name: "editor"})
type TaskMaximise struct{ Name string }
// TaskMinimise minimises a window.
// Use: _, _, err := c.PERFORM(window.TaskMinimise{Name: "editor"})
type TaskMinimise struct{ Name string }
// TaskFocus brings a window to the front.
// Use: _, _, err := c.PERFORM(window.TaskFocus{Name: "editor"})
type TaskFocus struct{ Name string }
// TaskRestore restores a maximised or minimised window to its normal state.
// Use: _, _, err := c.PERFORM(window.TaskRestore{Name: "editor"})
type TaskRestore struct{ Name string }
// TaskSetTitle changes a window's title.
// Use: _, _, err := c.PERFORM(window.TaskSetTitle{Name: "editor", Title: "Core Editor"})
type TaskSetTitle struct {
Name string
Title string
}
// TaskSetAlwaysOnTop pins a window above others.
// Use: _, _, err := c.PERFORM(window.TaskSetAlwaysOnTop{Name: "editor", AlwaysOnTop: true})
type TaskSetAlwaysOnTop struct {
Name string
AlwaysOnTop bool
}
// TaskSetBackgroundColour updates the window background colour.
// Use: _, _, err := c.PERFORM(window.TaskSetBackgroundColour{Name: "editor", Red: 0, Green: 0, Blue: 0, Alpha: 0})
type TaskSetBackgroundColour struct {
Name string
Red uint8
@ -131,159 +59,67 @@ type TaskSetBackgroundColour struct {
Alpha uint8
}
// TaskSetOpacity updates the window opacity as a value between 0 and 1.
// Use: _, _, err := c.PERFORM(window.TaskSetOpacity{Name: "editor", Opacity: 0.85})
type TaskSetOpacity struct {
Name string
Opacity float32
}
// TaskSetVisibility shows or hides a window.
// Use: _, _, err := c.PERFORM(window.TaskSetVisibility{Name: "editor", Visible: false})
type TaskSetVisibility struct {
Name string
Visible bool
}
// TaskFullscreen enters or exits fullscreen mode.
// Use: _, _, err := c.PERFORM(window.TaskFullscreen{Name: "editor", Fullscreen: true})
type TaskFullscreen struct {
Name string
Fullscreen bool
}
// --- Layout Queries ---
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
// Use: result, _, err := c.QUERY(window.QueryLayoutList{})
type QueryLayoutList struct{}
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
// Use: result, _, err := c.QUERY(window.QueryLayoutGet{Name: "coding"})
type QueryLayoutGet struct{ Name string }
// --- Layout Tasks ---
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
// Use: _, _, err := c.PERFORM(window.TaskSaveLayout{Name: "coding"})
type TaskSaveLayout struct{ Name string }
// TaskRestoreLayout restores a saved layout by name.
// Use: _, _, err := c.PERFORM(window.TaskRestoreLayout{Name: "coding"})
type TaskRestoreLayout struct{ Name string }
// TaskDeleteLayout removes a saved layout by name.
// Use: _, _, err := c.PERFORM(window.TaskDeleteLayout{Name: "coding"})
type TaskDeleteLayout struct{ Name string }
// TaskTileWindows arranges windows in a tiling mode.
// Use: _, _, err := c.PERFORM(window.TaskTileWindows{Mode: "grid"})
type TaskTileWindows struct {
Mode string // "left-right", "grid", "left-half", "right-half", etc.
Windows []string // window names; empty = all
}
// TaskSnapWindow snaps a window to a screen edge/corner.
// Use: _, _, err := c.PERFORM(window.TaskSnapWindow{Name: "editor", Position: "left"})
type TaskStackWindows struct {
Windows []string // window names; empty = all
OffsetX int
OffsetY int
}
type TaskSnapWindow struct {
Name string // window name
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
}
// TaskArrangePair places two windows side-by-side in a balanced split.
// Use: _, _, err := c.PERFORM(window.TaskArrangePair{First: "editor", Second: "terminal"})
type TaskArrangePair struct {
First string
Second string
}
// TaskBesideEditor places a target window beside an editor/IDE window.
// Use: _, _, err := c.PERFORM(window.TaskBesideEditor{Editor: "editor", Window: "terminal"})
type TaskBesideEditor struct {
Editor string
Window string
}
// TaskStackWindows cascades windows with a shared offset.
// Use: _, _, err := c.PERFORM(window.TaskStackWindows{Windows: []string{"editor", "terminal"}})
type TaskStackWindows struct {
Windows []string
OffsetX int
OffsetY int
}
// TaskApplyWorkflow applies a predefined workflow layout to windows.
// Use: _, _, err := c.PERFORM(window.TaskApplyWorkflow{Workflow: window.WorkflowCoding})
type TaskApplyWorkflow struct {
Workflow WorkflowLayout
Windows []string
Workflow string
Windows []string // window names; empty = all
}
// TaskSaveConfig persists this service's config section via the display orchestrator.
// Use: _, _, err := c.PERFORM(window.TaskSaveConfig{Value: map[string]any{"default_width": 1280}})
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }
// --- Actions (broadcasts) ---
// ActionWindowOpened is broadcast when a window is created.
// Use: _ = c.ACTION(window.ActionWindowOpened{Name: "editor"})
type ActionWindowOpened struct{ Name string }
// ActionWindowClosed is broadcast when a window is closed.
// Use: _ = c.ACTION(window.ActionWindowClosed{Name: "editor"})
type ActionWindowClosed struct{ Name string }
// ActionWindowMoved is broadcast when a window is moved.
// Use: _ = c.ACTION(window.ActionWindowMoved{Name: "editor", X: 160, Y: 120})
type ActionWindowMoved struct {
Name string
X, Y int
}
// ActionWindowResized is broadcast when a window is resized.
// Use: _ = c.ACTION(window.ActionWindowResized{Name: "editor", Width: 1280, Height: 800})
type ActionWindowResized struct {
Name string
Width, Height int
// W and H are compatibility aliases for older listeners.
W, H int
}
// ActionWindowFocused is broadcast when a window gains focus.
// Use: _ = c.ACTION(window.ActionWindowFocused{Name: "editor"})
type ActionWindowFocused struct{ Name string }
// ActionWindowBlurred is broadcast when a window loses focus.
// Use: _ = c.ACTION(window.ActionWindowBlurred{Name: "editor"})
type ActionWindowBlurred struct{ Name string }
// ActionFilesDropped is broadcast when files are dropped onto a window.
// Use: _ = c.ACTION(window.ActionFilesDropped{Name: "editor", Paths: []string{"/tmp/report.pdf"}})
type ActionFilesDropped struct {
Name string `json:"name"` // window name
Paths []string `json:"paths"`
TargetID string `json:"targetId,omitempty"`
}
// SpaceInfo describes a suggested empty area on the screen.
// Use: info := window.SpaceInfo{X: 160, Y: 120, Width: 1280, Height: 800}
type SpaceInfo struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
ScreenWidth int `json:"screenWidth"`
ScreenHeight int `json:"screenHeight"`
Reason string `json:"reason,omitempty"`
}
// LayoutSuggestion describes a recommended layout for a screen.
// Use: suggestion := window.LayoutSuggestion{Mode: "side-by-side"}
type LayoutSuggestion struct {
Mode string `json:"mode"`
Columns int `json:"columns"`
Rows int `json:"rows"`
PrimaryWidth int `json:"primaryWidth"`
SecondaryWidth int `json:"secondaryWidth"`
Description string `json:"description"`
}

View file

@ -1,36 +1,25 @@
// pkg/window/mock_platform.go
package window
// MockPlatform is an exported mock for cross-package integration tests.
// Use: platform := window.NewMockPlatform()
// For internal tests, use the unexported mockPlatform in mock_test.go.
type MockPlatform struct {
Windows []*MockWindow
}
// NewMockPlatform creates a window platform mock.
// Use: platform := window.NewMockPlatform()
func NewMockPlatform() *MockPlatform {
return &MockPlatform{}
}
// CreateWindow creates an in-memory window for tests.
// Use: w := platform.CreateWindow(window.PlatformWindowOptions{Name: "editor"})
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &MockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
}
m.Windows = append(m.Windows, w)
return w
}
// GetWindows returns all tracked mock windows.
// Use: windows := platform.GetWindows()
func (m *MockPlatform) GetWindows() []PlatformWindow {
out := make([]PlatformWindow, len(m.Windows))
for i, w := range m.Windows {
@ -39,16 +28,12 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
return out
}
// MockWindow is an in-memory window handle used by tests.
// Use: w := &window.MockWindow{}
type MockWindow struct {
name, title, url string
width, height, x, y int
maximised, minimised bool
focused bool
maximised, focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
opacity float32
backgroundColour [4]uint8
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
@ -58,46 +43,26 @@ func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Title() string { return w.title }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) IsVisible() bool { return w.visible }
func (w *MockWindow) IsMinimised() bool { return w.minimised }
func (w *MockWindow) IsMaximised() bool { return w.maximised }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
func (w *MockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
func (w *MockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() {
w.closed = true
w.emit(WindowEvent{Type: "close", Name: w.name})
}
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OpenDevTools() {}
func (w *MockWindow) CloseDevTools() {}
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}
func (w *MockWindow) emit(e WindowEvent) {
for _, h := range w.eventHandlers {
h(e)
}
}
func (w *MockWindow) emitFileDrop(paths []string, targetID string) {
for _, h := range w.fileDropHandlers {
h(paths, targetID)
}
}

View file

@ -1,4 +1,3 @@
// pkg/window/mock_test.go
package window
type mockPlatform struct {
@ -9,14 +8,11 @@ func newMockPlatform() *mockPlatform {
return &mockPlatform{}
}
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &mockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
}
m.windows = append(m.windows, w)
return w
@ -33,13 +29,12 @@ func (m *mockPlatform) GetWindows() []PlatformWindow {
type mockWindow struct {
name, title, url string
width, height, x, y int
maximised, minimised bool
focused bool
maximised, focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
opacity float32
devtoolsOpen bool
backgroundColour [4]uint8
closed bool
minimised bool
fullscreened bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
}
@ -48,31 +43,23 @@ func (w *mockWindow) Name() string { return w.name }
func (w *mockWindow) Title() string { return w.title }
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
func (w *mockWindow) IsVisible() bool { return w.visible }
func (w *mockWindow) IsMinimised() bool { return w.minimised }
func (w *mockWindow) IsMaximised() bool { return w.maximised }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
func (w *mockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
func (w *mockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
func (w *mockWindow) Maximise() { w.maximised = true }
func (w *mockWindow) Restore() { w.maximised = false }
func (w *mockWindow) Minimise() { w.minimised = true }
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() {
w.closed = true
w.emit(WindowEvent{Type: "close", Name: w.name})
}
func (w *mockWindow) Close() { w.closed = true }
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() {}
func (w *mockWindow) UnFullscreen() {}
func (w *mockWindow) OpenDevTools() { w.devtoolsOpen = true }
func (w *mockWindow) CloseDevTools() { w.devtoolsOpen = false }
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}

View file

@ -1,92 +1,69 @@
// pkg/window/options.go
package window
// WindowOption is a functional option applied to a Window descriptor.
// WindowOption is the compatibility layer for option-chain callers.
// Prefer a Window literal with Manager.CreateWindow.
type WindowOption func(*Window) error
// ApplyOptions creates a Window and applies all options in order.
// Use: w, err := window.ApplyOptions(window.WithName("editor"), window.WithURL("/editor"))
func ApplyOptions(opts ...WindowOption) (*Window, error) {
// Deprecated: use Manager.CreateWindow(Window{Name: "settings", URL: "/", Width: 800, Height: 600}).
func ApplyOptions(options ...WindowOption) (*Window, error) {
w := &Window{}
for _, opt := range opts {
if opt == nil {
for _, option := range options {
if option == nil {
continue
}
if err := opt(w); err != nil {
if err := option(w); err != nil {
return nil, err
}
}
return w, nil
}
// WithName sets the window name.
// Use: window.WithName("editor")
// Compatibility helpers for callers still using option chains.
func WithName(name string) WindowOption {
return func(w *Window) error { w.Name = name; return nil }
}
// WithTitle sets the window title.
// Use: window.WithTitle("Core Editor")
func WithTitle(title string) WindowOption {
return func(w *Window) error { w.Title = title; return nil }
}
// WithURL sets the initial window URL.
// Use: window.WithURL("/editor")
func WithURL(url string) WindowOption {
return func(w *Window) error { w.URL = url; return nil }
}
// WithSize sets the initial window size.
// Use: window.WithSize(1280, 800)
func WithSize(width, height int) WindowOption {
return func(w *Window) error { w.Width = width; w.Height = height; return nil }
}
// WithPosition sets the initial window position.
// Use: window.WithPosition(160, 120)
func WithPosition(x, y int) WindowOption {
return func(w *Window) error { w.X = x; w.Y = y; return nil }
}
// WithMinSize sets the minimum window size.
// Use: window.WithMinSize(640, 480)
func WithMinSize(width, height int) WindowOption {
return func(w *Window) error { w.MinWidth = width; w.MinHeight = height; return nil }
}
// WithMaxSize sets the maximum window size.
// Use: window.WithMaxSize(1920, 1080)
func WithMaxSize(width, height int) WindowOption {
return func(w *Window) error { w.MaxWidth = width; w.MaxHeight = height; return nil }
}
// WithFrameless toggles the native window frame.
// Use: window.WithFrameless(true)
func WithFrameless(frameless bool) WindowOption {
return func(w *Window) error { w.Frameless = frameless; return nil }
}
// WithHidden starts the window hidden.
// Use: window.WithHidden(true)
func WithHidden(hidden bool) WindowOption {
return func(w *Window) error { w.Hidden = hidden; return nil }
}
// WithAlwaysOnTop keeps the window above other windows.
// Use: window.WithAlwaysOnTop(true)
func WithAlwaysOnTop(alwaysOnTop bool) WindowOption {
return func(w *Window) error { w.AlwaysOnTop = alwaysOnTop; return nil }
}
// WithBackgroundColour sets the window background colour with alpha.
// Use: window.WithBackgroundColour(0, 0, 0, 0)
func WithBackgroundColour(r, g, b, a uint8) WindowOption {
return func(w *Window) error { w.BackgroundColour = [4]uint8{r, g, b, a}; return nil }
}
// WithFileDrop enables drag-and-drop file handling.
// Use: window.WithFileDrop(true)
func WithFileDrop(enabled bool) WindowOption {
return func(w *Window) error { w.EnableFileDrop = enabled; return nil }
}

View file

@ -0,0 +1,334 @@
// pkg/window/persistence_test.go
package window
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- StateManager Persistence Tests ---
func TestStateManager_SetAndGet_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
state := WindowState{
X: 150, Y: 250, Width: 1024, Height: 768,
Maximized: true, Screen: "primary", URL: "/app",
}
sm.SetState("editor", state)
got, ok := sm.GetState("editor")
require.True(t, ok)
assert.Equal(t, 150, got.X)
assert.Equal(t, 250, got.Y)
assert.Equal(t, 1024, got.Width)
assert.Equal(t, 768, got.Height)
assert.True(t, got.Maximized)
assert.Equal(t, "primary", got.Screen)
assert.Equal(t, "/app", got.URL)
assert.NotZero(t, got.UpdatedAt, "UpdatedAt should be set by SetState")
}
func TestStateManager_UpdatePosition_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{X: 0, Y: 0, Width: 800, Height: 600})
sm.UpdatePosition("win", 300, 400)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.Equal(t, 300, got.X)
assert.Equal(t, 400, got.Y)
// Width/Height should remain unchanged
assert.Equal(t, 800, got.Width)
assert.Equal(t, 600, got.Height)
}
func TestStateManager_UpdateSize_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600})
sm.UpdateSize("win", 1920, 1080)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.Equal(t, 1920, got.Width)
assert.Equal(t, 1080, got.Height)
// Position should remain unchanged
assert.Equal(t, 100, got.X)
assert.Equal(t, 200, got.Y)
}
func TestStateManager_UpdateMaximized_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{Width: 800, Height: 600, Maximized: false})
sm.UpdateMaximized("win", true)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.True(t, got.Maximized)
sm.UpdateMaximized("win", false)
got, ok = sm.GetState("win")
require.True(t, ok)
assert.False(t, got.Maximized)
}
func TestStateManager_CaptureState_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
pw := &mockWindow{
name: "captured", x: 75, y: 125,
width: 1440, height: 900, maximised: true,
}
sm.CaptureState(pw)
got, ok := sm.GetState("captured")
require.True(t, ok)
assert.Equal(t, 75, got.X)
assert.Equal(t, 125, got.Y)
assert.Equal(t, 1440, got.Width)
assert.Equal(t, 900, got.Height)
assert.True(t, got.Maximized)
assert.NotZero(t, got.UpdatedAt)
}
func TestStateManager_ApplyState_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("target", WindowState{X: 55, Y: 65, Width: 700, Height: 500})
w := &Window{Name: "target", Width: 1280, Height: 800, X: 0, Y: 0}
sm.ApplyState(w)
assert.Equal(t, 55, w.X)
assert.Equal(t, 65, w.Y)
assert.Equal(t, 700, w.Width)
assert.Equal(t, 500, w.Height)
}
func TestStateManager_ApplyState_Good_NoState(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20}
sm.ApplyState(w)
// Window should remain unchanged when no state is saved
assert.Equal(t, 10, w.X)
assert.Equal(t, 20, w.Y)
assert.Equal(t, 1280, w.Width)
assert.Equal(t, 800, w.Height)
}
func TestStateManager_ListStates_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("alpha", WindowState{Width: 100})
sm.SetState("beta", WindowState{Width: 200})
sm.SetState("gamma", WindowState{Width: 300})
names := sm.ListStates()
assert.Len(t, names, 3)
assert.Contains(t, names, "alpha")
assert.Contains(t, names, "beta")
assert.Contains(t, names, "gamma")
}
func TestStateManager_Clear_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("a", WindowState{Width: 100})
sm.SetState("b", WindowState{Width: 200})
sm.SetState("c", WindowState{Width: 300})
sm.Clear()
names := sm.ListStates()
assert.Empty(t, names)
_, ok := sm.GetState("a")
assert.False(t, ok)
}
func TestStateManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
// First manager: write state and force sync to disk
sm1 := NewStateManagerWithDir(dir)
sm1.SetState("persist-win", WindowState{
X: 42, Y: 84, Width: 500, Height: 300,
Maximized: true, Screen: "secondary", URL: "/settings",
})
sm1.ForceSync()
// Second manager: load from the same directory
sm2 := NewStateManagerWithDir(dir)
got, ok := sm2.GetState("persist-win")
require.True(t, ok)
assert.Equal(t, 42, got.X)
assert.Equal(t, 84, got.Y)
assert.Equal(t, 500, got.Width)
assert.Equal(t, 300, got.Height)
assert.True(t, got.Maximized)
assert.Equal(t, "secondary", got.Screen)
assert.Equal(t, "/settings", got.URL)
assert.NotZero(t, got.UpdatedAt)
}
func TestStateManager_SetPath_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom", "window-state.json")
sm := NewStateManagerWithDir(dir)
sm.SetPath(path)
sm.SetState("custom", WindowState{Width: 640, Height: 480})
sm.ForceSync()
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(content), "custom")
}
// --- LayoutManager Persistence Tests ---
func TestLayoutManager_SaveAndGet_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
windows := map[string]WindowState{
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
"terminal": {X: 960, Y: 0, Width: 960, Height: 540},
"browser": {X: 960, Y: 540, Width: 960, Height: 540},
}
err := lm.SaveLayout("coding", windows)
require.NoError(t, err)
layout, ok := lm.GetLayout("coding")
require.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 3)
assert.Equal(t, 960, layout.Windows["editor"].Width)
assert.Equal(t, 1080, layout.Windows["editor"].Height)
assert.Equal(t, 960, layout.Windows["terminal"].X)
assert.NotZero(t, layout.CreatedAt)
assert.NotZero(t, layout.UpdatedAt)
assert.Equal(t, layout.CreatedAt, layout.UpdatedAt, "CreatedAt and UpdatedAt should match on first save")
}
func TestLayoutManager_SaveLayout_EmptyName_Bad(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
err := lm.SaveLayout("", map[string]WindowState{
"win": {Width: 800},
})
assert.Error(t, err)
}
func TestLayoutManager_SaveLayout_Update_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
// First save
err := lm.SaveLayout("evolving", map[string]WindowState{
"win1": {Width: 800, Height: 600},
})
require.NoError(t, err)
first, ok := lm.GetLayout("evolving")
require.True(t, ok)
originalCreatedAt := first.CreatedAt
originalUpdatedAt := first.UpdatedAt
// Small delay to ensure UpdatedAt differs
time.Sleep(2 * time.Millisecond)
// Second save with same name but different windows
err = lm.SaveLayout("evolving", map[string]WindowState{
"win1": {Width: 1024, Height: 768},
"win2": {Width: 640, Height: 480},
})
require.NoError(t, err)
updated, ok := lm.GetLayout("evolving")
require.True(t, ok)
// CreatedAt should be preserved from the original save
assert.Equal(t, originalCreatedAt, updated.CreatedAt, "CreatedAt should be preserved on update")
// UpdatedAt should be newer
assert.GreaterOrEqual(t, updated.UpdatedAt, originalUpdatedAt, "UpdatedAt should advance on update")
// Windows should reflect the second save
assert.Len(t, updated.Windows, 2)
assert.Equal(t, 1024, updated.Windows["win1"].Width)
}
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{
"editor": {Width: 960}, "terminal": {Width: 960},
}))
require.NoError(t, lm.SaveLayout("presenting", map[string]WindowState{
"slides": {Width: 1920},
}))
require.NoError(t, lm.SaveLayout("debugging", map[string]WindowState{
"code": {Width: 640}, "debugger": {Width: 640}, "console": {Width: 640},
}))
infos := lm.ListLayouts()
assert.Len(t, infos, 3)
// Build a lookup map for assertions regardless of order
byName := make(map[string]LayoutInfo)
for _, info := range infos {
byName[info.Name] = info
}
assert.Equal(t, 2, byName["coding"].WindowCount)
assert.Equal(t, 1, byName["presenting"].WindowCount)
assert.Equal(t, 3, byName["debugging"].WindowCount)
}
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
require.NoError(t, lm.SaveLayout("temporary", map[string]WindowState{
"win": {Width: 800},
}))
// Verify it exists
_, ok := lm.GetLayout("temporary")
require.True(t, ok)
lm.DeleteLayout("temporary")
// Verify it is gone
_, ok = lm.GetLayout("temporary")
assert.False(t, ok)
// Verify list is empty
assert.Empty(t, lm.ListLayouts())
}
func TestLayoutManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
// First manager: save layout to disk
lm1 := NewLayoutManagerWithDir(dir)
err := lm1.SaveLayout("persisted", map[string]WindowState{
"main": {X: 0, Y: 0, Width: 1280, Height: 800},
"sidebar": {X: 1280, Y: 0, Width: 640, Height: 800},
})
require.NoError(t, err)
// Second manager: load from the same directory
lm2 := NewLayoutManagerWithDir(dir)
layout, ok := lm2.GetLayout("persisted")
require.True(t, ok)
assert.Equal(t, "persisted", layout.Name)
assert.Len(t, layout.Windows, 2)
assert.Equal(t, 1280, layout.Windows["main"].Width)
assert.Equal(t, 800, layout.Windows["main"].Height)
assert.Equal(t, 640, layout.Windows["sidebar"].Width)
assert.NotZero(t, layout.CreatedAt)
assert.NotZero(t, layout.UpdatedAt)
}

View file

@ -2,14 +2,12 @@
package window
// Platform abstracts the windowing backend (Wails v3).
// Use: var p window.Platform
type Platform interface {
CreateWindow(opts PlatformWindowOptions) PlatformWindow
CreateWindow(options PlatformWindowOptions) PlatformWindow
GetWindows() []PlatformWindow
}
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
// Use: opts := window.PlatformWindowOptions{Name: "editor", URL: "/editor"}
type PlatformWindowOptions struct {
Name string
Title string
@ -27,7 +25,6 @@ type PlatformWindowOptions struct {
}
// PlatformWindow is a live window handle from the backend.
// Use: var w window.PlatformWindow
type PlatformWindow interface {
// Identity
Name() string
@ -36,8 +33,6 @@ type PlatformWindow interface {
// Queries
Position() (int, int)
Size() (int, int)
IsVisible() bool
IsMinimised() bool
IsMaximised() bool
IsFocused() bool
@ -46,7 +41,6 @@ type PlatformWindow interface {
SetPosition(x, y int)
SetSize(width, height int)
SetBackgroundColour(r, g, b, a uint8)
SetOpacity(opacity float32)
SetVisibility(visible bool)
SetAlwaysOnTop(alwaysOnTop bool)
@ -60,8 +54,6 @@ type PlatformWindow interface {
Hide()
Fullscreen()
UnFullscreen()
OpenDevTools()
CloseDevTools()
// Events
OnWindowEvent(handler func(event WindowEvent))
@ -71,7 +63,6 @@ type PlatformWindow interface {
}
// WindowEvent is emitted by the backend for window state changes.
// Use: evt := window.WindowEvent{Type: "focus", Name: "editor"}
type WindowEvent struct {
Type string // "focus", "blur", "move", "resize", "close"
Name string // window name

View file

@ -1,11 +1,9 @@
// pkg/window/register.go
package window
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
// Use: core.WithService(window.Register(platform))
// Register(p) binds the window service to a Core instance.
// core.WithService(window.Register(window.NewWailsPlatform(app)))
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,86 +1,64 @@
// pkg/window/service.go
package window
import (
"context"
"fmt"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
)
// Options holds configuration for the window service.
// Use: svc, err := window.Register(platform)(core.New())
type Options struct{}
// Service is a core.Service managing window lifecycle via IPC.
// Use: core.WithService(window.Register(window.NewMockPlatform()))
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
// Use: svc, err := window.Register(platform)(core.New())
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
// OnStartup queries config from the display orchestrator and registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
// Query config — display registers its handler before us (registration order guarantee).
// If display is not registered, handled=false and we skip config.
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if wCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(wCfg)
if windowConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(windowConfig)
}
}
// Register QUERY and TASK handlers manually.
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
// do NOT call RegisterAction here or actions will double-fire.
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if width, ok := cfg["default_width"]; ok {
func (s *Service) applyConfig(configData map[string]any) {
if width, ok := configData["default_width"]; ok {
if width, ok := width.(int); ok {
s.manager.SetDefaultWidth(width)
}
}
if height, ok := cfg["default_height"]; ok {
if height, ok := configData["default_height"]; ok {
if height, ok := height.(int); ok {
s.manager.SetDefaultHeight(height)
}
}
if stateFile, ok := cfg["state_file"]; ok {
if stateFile, ok := configData["state_file"]; ok {
if stateFile, ok := stateFile.(string); ok {
s.manager.State().SetPath(stateFile)
}
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// Use: _ = svc.HandleIPCEvents(core, msg)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryWindowList:
return s.queryWindowList(), true, nil
case QueryWindowByName:
return s.queryWindowByName(q.Name), true, nil
case QueryWindowBounds:
if info := s.queryWindowByName(q.Name); info != nil {
return &Bounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}, true, nil
}
return (*Bounds)(nil), true, nil
case QueryLayoutList:
return s.manager.Layout().ListLayouts(), true, nil
case QueryLayoutGet:
@ -89,18 +67,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
return (*Layout)(nil), true, nil
}
return &l, true, nil
case QueryFindSpace:
screenW, screenH := q.ScreenWidth, q.ScreenHeight
if screenW <= 0 || screenH <= 0 {
screenW, screenH = s.primaryScreenSize()
}
return s.manager.FindSpace(screenW, screenH, q.Width, q.Height), true, nil
case QueryLayoutSuggestion:
screenW, screenH := q.ScreenWidth, q.ScreenHeight
if screenW <= 0 || screenH <= 0 {
screenW, screenH = s.primaryScreenSize()
}
return s.manager.SuggestLayout(screenW, screenH, q.WindowCount), true, nil
default:
return nil, false, nil
}
@ -114,14 +80,7 @@ func (s *Service) queryWindowList() []WindowInfo {
x, y := pw.Position()
w, h := pw.Size()
result = append(result, WindowInfo{
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
})
@ -138,14 +97,7 @@ func (s *Service) queryWindowByName(name string) *WindowInfo {
x, y := pw.Position()
w, h := pw.Size()
return &WindowInfo{
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
@ -162,7 +114,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height, t.W, t.H)
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
@ -177,8 +129,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
case TaskSetBackgroundColour:
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
case TaskSetOpacity:
return nil, true, s.taskSetOpacity(t.Name, t.Opacity)
case TaskSetVisibility:
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
case TaskFullscreen:
@ -192,14 +142,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, nil
case TaskTileWindows:
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
case TaskSnapWindow:
return nil, true, s.taskSnapWindow(t.Name, t.Position)
case TaskArrangePair:
return nil, true, s.taskArrangePair(t.First, t.Second)
case TaskBesideEditor:
return nil, true, s.taskBesideEditor(t.Editor, t.Window)
case TaskStackWindows:
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
case TaskSnapWindow:
return nil, true, s.taskSnapWindow(t.Name, t.Position)
case TaskApplyWorkflow:
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
default:
@ -207,34 +153,50 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
var (
pw PlatformWindow
err error
)
if t.Window != nil {
spec := *t.Window
pw, err = s.manager.Create(&spec)
} else {
pw, err = s.manager.Open(t.Opts...)
func (s *Service) primaryScreenArea() (int, int, int, int) {
const fallbackX = 0
const fallbackY = 0
const fallbackWidth = 1920
const fallbackHeight = 1080
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
if err != nil || !handled {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
primary, ok := result.(*screen.Screen)
if !ok || primary == nil {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
x := primary.WorkArea.X
y := primary.WorkArea.Y
width := primary.WorkArea.Width
height := primary.WorkArea.Height
if width <= 0 || height <= 0 {
x = primary.Bounds.X
y = primary.Bounds.Y
width = primary.Bounds.Width
height = primary.Bounds.Height
}
if width <= 0 || height <= 0 {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
return x, y, width, height
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
if t.Window == nil {
return nil, true, coreerr.E("window.taskOpenWindow", "window descriptor is required", nil)
}
pw, err := s.manager.CreateWindow(*t.Window)
if err != nil {
return nil, true, err
}
x, y := pw.Position()
w, h := pw.Size()
info := WindowInfo{
Name: pw.Name(),
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(pw)
@ -262,7 +224,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
@ -280,39 +242,30 @@ func (s *Service) trackWindow(pw PlatformWindow) {
func (s *Service) taskCloseWindow(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskClose", "window not found: "+name, nil)
}
// Persist state BEFORE closing (spec requirement)
s.manager.State().CaptureState(pw)
pw.Close()
s.manager.Remove(name)
_ = s.Core().ACTION(ActionWindowClosed{Name: name})
return nil
}
func (s *Service) taskSetPosition(name string, x, y int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskSetPosition", "window not found: "+name, nil)
}
pw.SetPosition(x, y)
s.manager.State().UpdatePosition(name, x, y)
return nil
}
func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbackHeight int) error {
func (s *Service) taskSetSize(name string, width, height int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
if width == 0 && height == 0 {
width, height = fallbackWidth, fallbackHeight
} else {
if width == 0 {
width = fallbackWidth
}
if height == 0 {
height = fallbackHeight
}
return coreerr.E("window.taskSetSize", "window not found: "+name, nil)
}
pw.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
@ -322,7 +275,7 @@ func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbac
func (s *Service) taskMaximise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskMaximise", "window not found: "+name, nil)
}
pw.Maximise()
s.manager.State().UpdateMaximized(name, true)
@ -332,7 +285,7 @@ func (s *Service) taskMaximise(name string) error {
func (s *Service) taskMinimise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskMinimise", "window not found: "+name, nil)
}
pw.Minimise()
return nil
@ -341,7 +294,7 @@ func (s *Service) taskMinimise(name string) error {
func (s *Service) taskFocus(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskFocus", "window not found: "+name, nil)
}
pw.Focus()
return nil
@ -350,7 +303,7 @@ func (s *Service) taskFocus(name string) error {
func (s *Service) taskRestore(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskRestore", "window not found: "+name, nil)
}
pw.Restore()
s.manager.State().UpdateMaximized(name, false)
@ -360,7 +313,7 @@ func (s *Service) taskRestore(name string) error {
func (s *Service) taskSetTitle(name, title string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskSetTitle", "window not found: "+name, nil)
}
pw.SetTitle(title)
return nil
@ -369,7 +322,7 @@ func (s *Service) taskSetTitle(name, title string) error {
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskSetAlwaysOnTop", "window not found: "+name, nil)
}
pw.SetAlwaysOnTop(alwaysOnTop)
return nil
@ -378,28 +331,16 @@ func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskSetBackgroundColour", "window not found: "+name, nil)
}
pw.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetOpacity(name string, opacity float32) error {
if opacity < 0 || opacity > 1 {
return fmt.Errorf("opacity must be between 0 and 1")
}
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetOpacity(opacity)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskSetVisibility", "window not found: "+name, nil)
}
pw.SetVisibility(visible)
return nil
@ -408,7 +349,7 @@ func (s *Service) taskSetVisibility(name string, visible bool) error {
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
return coreerr.E("window.taskFullscreen", "window not found: "+name, nil)
}
if fullscreen {
pw.Fullscreen()
@ -433,16 +374,13 @@ func (s *Service) taskSaveLayout(name string) error {
func (s *Service) taskRestoreLayout(name string) error {
layout, ok := s.manager.Layout().GetLayout(name)
if !ok {
return fmt.Errorf("layout not found: %s", name)
return coreerr.E("window.taskRestoreLayout", "layout not found: "+name, nil)
}
for winName, state := range layout.Windows {
pw, found := s.manager.Get(winName)
if !found {
continue
}
if pw.IsMaximised() || pw.IsMinimised() {
pw.Restore()
}
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
if state.Maximized {
@ -450,6 +388,7 @@ func (s *Service) taskRestoreLayout(name string) error {
} else {
pw.Restore()
}
s.manager.State().CaptureState(pw)
}
return nil
}
@ -465,13 +404,21 @@ var tileModeMap = map[string]TileMode{
func (s *Service) taskTileWindows(mode string, names []string) error {
tm, ok := tileModeMap[mode]
if !ok {
return fmt.Errorf("unknown tile mode: %s", mode)
return coreerr.E("window.taskTileWindows", "unknown tile mode: "+mode, nil)
}
if len(names) == 0 {
names = s.manager.List()
}
screenW, screenH := s.primaryScreenSize()
return s.manager.TileWindows(tm, names, screenW, screenH)
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY)
}
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
if len(names) == 0 {
names = s.manager.List()
}
originX, originY, _, _ := s.primaryScreenArea()
return s.manager.StackWindows(names, offsetX, offsetY, originX, originY)
}
var snapPosMap = map[string]SnapPosition{
@ -485,104 +432,32 @@ var snapPosMap = map[string]SnapPosition{
func (s *Service) taskSnapWindow(name, position string) error {
pos, ok := snapPosMap[position]
if !ok {
return fmt.Errorf("unknown snap position: %s", position)
return coreerr.E("window.taskSnapWindow", "unknown snap position: "+position, nil)
}
screenW, screenH := s.primaryScreenSize()
return s.manager.SnapWindow(name, pos, screenW, screenH)
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
}
func (s *Service) taskArrangePair(first, second string) error {
screenW, screenH := s.primaryScreenSize()
return s.manager.ArrangePair(first, second, screenW, screenH)
var workflowLayoutMap = map[string]WorkflowLayout{
"coding": WorkflowCoding,
"debugging": WorkflowDebugging,
"presenting": WorkflowPresenting,
"side-by-side": WorkflowSideBySide,
}
func (s *Service) taskBesideEditor(editorName, windowName string) error {
screenW, screenH := s.primaryScreenSize()
if editorName == "" {
editorName = s.detectEditorWindow()
func (s *Service) taskApplyWorkflow(workflow string, names []string) error {
layout, ok := workflowLayoutMap[workflow]
if !ok {
return coreerr.E("window.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
}
if editorName == "" {
return fmt.Errorf("editor window not found")
}
if windowName == "" {
windowName = s.detectCompanionWindow(editorName)
}
if windowName == "" {
return fmt.Errorf("companion window not found")
}
return s.manager.BesideEditor(editorName, windowName, screenW, screenH)
}
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
if len(names) == 0 {
names = s.manager.List()
}
return s.manager.StackWindows(names, offsetX, offsetY)
}
func (s *Service) taskApplyWorkflow(workflow WorkflowLayout, names []string) error {
screenW, screenH := s.primaryScreenSize()
if len(names) == 0 {
names = s.manager.List()
}
return s.manager.ApplyWorkflow(workflow, names, screenW, screenH)
}
func (s *Service) detectEditorWindow() string {
for _, info := range s.queryWindowList() {
if looksLikeEditor(info.Name, info.Title) {
return info.Name
}
}
return ""
}
func (s *Service) detectCompanionWindow(editorName string) string {
for _, info := range s.queryWindowList() {
if info.Name == editorName {
continue
}
if !looksLikeEditor(info.Name, info.Title) {
return info.Name
}
}
return ""
}
func looksLikeEditor(name, title string) bool {
return containsAny(name, "editor", "ide", "code", "workspace") || containsAny(title, "editor", "ide", "code")
}
func containsAny(value string, needles ...string) bool {
lower := strings.ToLower(value)
for _, needle := range needles {
if strings.Contains(lower, needle) {
return true
}
}
return false
}
func (s *Service) primaryScreenSize() (int, int) {
result, handled, err := s.Core().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
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY)
}
// Manager returns the underlying window Manager for direct access.
// Use: mgr := svc.Manager()
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -0,0 +1,129 @@
package window
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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]
}
}
return nil
}
func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) {
t.Helper()
c, err := core.New(
core.WithService(screen.Register(&mockScreenPlatform{screens: screens})),
core.WithService(Register(newMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "window")
return svc, c
}
func TestTaskTileWindows_Good_UsesPrimaryScreenSize(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400})
_ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400})
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
require.NoError(t, err)
left := result.(*WindowInfo)
assert.Equal(t, 0, left.X)
assert.Equal(t, 1000, left.Width)
assert.Equal(t, 1000, left.Height)
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
require.NoError(t, err)
right := result.(*WindowInfo)
assert.Equal(t, 1000, right.X)
assert.Equal(t, 1000, right.Width)
assert.Equal(t, 1000, right.Height)
}
func TestTaskSnapWindow_Good_UsesPrimaryScreenSize(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_ = requireOpenWindow(t, c, Window{Name: "snap", Width: 400, Height: 300})
_, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "left"})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "snap"})
require.NoError(t, err)
info := result.(*WindowInfo)
assert.Equal(t, 0, info.X)
assert.Equal(t, 0, info.Y)
assert.Equal(t, 1000, info.Width)
assert.Equal(t, 1000, info.Height)
}
func TestTaskTileWindows_Good_UsesPrimaryWorkAreaOrigin(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 100, Y: 50, Width: 2000, Height: 1000},
},
})
_ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400})
_ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400})
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
require.NoError(t, err)
left := result.(*WindowInfo)
assert.Equal(t, 100, left.X)
assert.Equal(t, 50, left.Y)
assert.Equal(t, 1000, left.Width)
assert.Equal(t, 1000, left.Height)
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
require.NoError(t, err)
right := result.(*WindowInfo)
assert.Equal(t, 1100, right.X)
assert.Equal(t, 50, right.Y)
assert.Equal(t, 1000, right.Width)
assert.Equal(t, 1000, right.Height)
}

View file

@ -6,7 +6,6 @@ import (
"testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -23,39 +22,12 @@ func newTestWindowService(t *testing.T) (*Service, *core.Core) {
return svc, c
}
type testScreenPlatform struct {
screens []screen.Screen
}
func (p *testScreenPlatform) GetAll() []screen.Screen { return p.screens }
func (p *testScreenPlatform) GetPrimary() *screen.Screen {
for i := range p.screens {
if p.screens[i].IsPrimary {
return &p.screens[i]
}
}
return nil
}
func newTestWindowServiceWithScreen(t *testing.T) (*Service, *core.Core) {
func requireOpenWindow(t *testing.T, c *core.Core, window Window) WindowInfo {
t.Helper()
c, err := core.New(
core.WithService(Register(newMockPlatform())),
core.WithService(screen.Register(&testScreenPlatform{
screens: []screen.Screen{{
ID: "primary", Name: "Primary", IsPrimary: true,
Size: screen.Size{Width: 2560, Height: 1440},
Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
}},
})),
core.WithServiceLock(),
)
result, handled, err := c.PERFORM(TaskOpenWindow{Window: &window})
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "window")
return svc, c
require.True(t, handled)
return result.(WindowInfo)
}
func TestRegister_Good(t *testing.T) {
@ -64,25 +36,10 @@ func TestRegister_Good(t *testing.T) {
assert.NotNil(t, svc.manager)
}
func TestApplyConfig_Good(t *testing.T) {
svc, _ := newTestWindowService(t)
svc.applyConfig(map[string]any{
"default_width": 1500,
"default_height": 900,
})
pw, err := svc.manager.Open()
require.NoError(t, err)
w, h := pw.Size()
assert.Equal(t, 1500, w)
assert.Equal(t, 900, h)
}
func TestTaskOpenWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("test"), WithURL("/")},
Window: &Window{Name: "test", URL: "/"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -90,16 +47,12 @@ func TestTaskOpenWindow_Good(t *testing.T) {
assert.Equal(t, "test", info.Name)
}
func TestTaskOpenWindowDescriptor_Good(t *testing.T) {
func TestTaskOpenWindow_Bad_MissingWindow(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480},
})
require.NoError(t, err)
result, handled, err := c.PERFORM(TaskOpenWindow{})
assert.True(t, handled)
info := result.(WindowInfo)
assert.Equal(t, "descriptor", info.Name)
assert.Equal(t, "Descriptor", info.Title)
assert.Error(t, err)
assert.Nil(t, result)
}
func TestTaskOpenWindow_Bad(t *testing.T) {
@ -112,38 +65,25 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
func TestQueryWindowList_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
_, _, _ = c.PERFORM(TaskMinimise{Name: "b"})
_ = requireOpenWindow(t, c, Window{Name: "a"})
_ = requireOpenWindow(t, c, Window{Name: "b"})
result, handled, err := c.QUERY(QueryWindowList{})
require.NoError(t, err)
assert.True(t, handled)
list := result.([]WindowInfo)
assert.Len(t, list, 2)
byName := make(map[string]WindowInfo, len(list))
for _, info := range list {
byName[info.Name] = info
}
assert.True(t, byName["a"].Visible)
assert.False(t, byName["a"].Minimized)
assert.False(t, byName["b"].Visible)
assert.True(t, byName["b"].Minimized)
}
func TestQueryWindowByName_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_ = requireOpenWindow(t, c, Window{Name: "test"})
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
info := result.(*WindowInfo)
assert.Equal(t, "test", info.Name)
assert.True(t, info.Visible)
assert.False(t, info.Minimized)
}
func TestQueryWindowByName_Bad(t *testing.T) {
@ -156,7 +96,7 @@ func TestQueryWindowByName_Bad(t *testing.T) {
func TestTaskCloseWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
require.NoError(t, err)
@ -176,7 +116,7 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
func TestTaskSetPosition_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
require.NoError(t, err)
@ -190,9 +130,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
func TestTaskSetSize_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
require.NoError(t, err)
assert.True(t, handled)
@ -202,181 +142,9 @@ func TestTaskSetSize_Good(t *testing.T) {
assert.Equal(t, 600, info.Height)
}
func TestTaskMinimiseAndVisibility_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
info := result.(*WindowInfo)
assert.True(t, info.Minimized)
assert.False(t, info.Visible)
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ = c.QUERY(QueryWindowByName{Name: "test"})
info = result.(*WindowInfo)
assert.True(t, info.Visible)
}
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
require.NoError(t, err)
assert.True(t, handled)
svc := core.MustServiceFor[*Service](c, "window")
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.True(t, pw.(*mockWindow).alwaysOnTop)
}
func TestTaskSetBackgroundColour_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
})
require.NoError(t, err)
assert.True(t, handled)
svc := core.MustServiceFor[*Service](c, "window")
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.Equal(t, [4]uint8{10, 20, 30, 40}, pw.(*mockWindow).backgroundColor)
}
func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("left")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("right")}})
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
require.NoError(t, err)
assert.True(t, handled)
left, _, _ := c.QUERY(QueryWindowByName{Name: "left"})
right, _, _ := c.QUERY(QueryWindowByName{Name: "right"})
leftInfo := left.(*WindowInfo)
rightInfo := right.(*WindowInfo)
assert.Equal(t, 1280, leftInfo.Width)
assert.Equal(t, 1280, rightInfo.Width)
assert.Equal(t, 0, leftInfo.X)
assert.Equal(t, 1280, rightInfo.X)
}
func TestTaskTileWindows_ResetsMaximizedState(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("left")}})
_, _, _ = c.PERFORM(TaskMaximise{Name: "left"})
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-half", Windows: []string{"left"}})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ := c.QUERY(QueryWindowByName{Name: "left"})
info := result.(*WindowInfo)
assert.False(t, info.Maximized)
assert.Equal(t, 0, info.X)
assert.Equal(t, 1280, info.Width)
}
func TestTaskSetOpacity_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetOpacity{Name: "test", Opacity: 0.65})
require.NoError(t, err)
assert.True(t, handled)
svc := core.MustServiceFor[*Service](c, "window")
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.InDelta(t, 0.65, pw.(*mockWindow).opacity, 0.0001)
}
func TestTaskSetOpacity_BadRange(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetOpacity{Name: "test", Opacity: 1.5})
require.Error(t, err)
assert.True(t, handled)
}
func TestTaskStackWindows_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("one")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("two")}})
_, handled, err := c.PERFORM(TaskStackWindows{
Windows: []string{"one", "two"},
OffsetX: 20,
OffsetY: 30,
})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ := c.QUERY(QueryWindowByName{Name: "two"})
info := result.(*WindowInfo)
assert.Equal(t, 20, info.X)
assert.Equal(t, 30, info.Y)
}
func TestTaskApplyWorkflow_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("assistant")}})
_, handled, err := c.PERFORM(TaskApplyWorkflow{
Workflow: WorkflowCoding,
Windows: []string{"editor", "assistant"},
})
require.NoError(t, err)
assert.True(t, handled)
editorResult, _, _ := c.QUERY(QueryWindowByName{Name: "editor"})
assistantResult, _, _ := c.QUERY(QueryWindowByName{Name: "assistant"})
editor := editorResult.(*WindowInfo)
assistant := assistantResult.(*WindowInfo)
assert.Greater(t, editor.Width, assistant.Width)
assert.Equal(t, editor.Width, assistant.X)
}
func TestTaskRestoreLayout_ClearsMaximizedState(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor")}})
_, _, _ = c.PERFORM(TaskMaximise{Name: "editor"})
svc := core.MustServiceFor[*Service](c, "window")
err := svc.Manager().Layout().SaveLayout("restore", map[string]WindowState{
"editor": {X: 12, Y: 34, Width: 640, Height: 480, Maximized: false},
})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "restore"})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ := c.QUERY(QueryWindowByName{Name: "editor"})
info := result.(*WindowInfo)
assert.False(t, info.Maximized)
assert.Equal(t, 12, info.X)
assert.Equal(t, 640, info.Width)
}
func TestTaskMaximise_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
require.NoError(t, err)
@ -391,10 +159,7 @@ func TestFileDrop_Good(t *testing.T) {
_, c := newTestWindowService(t)
// Open a window
result, _, _ := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("drop-test")},
})
info := result.(WindowInfo)
info := requireOpenWindow(t, c, Window{Name: "drop-test"})
assert.Equal(t, "drop-test", info.Name)
// Capture broadcast actions
@ -422,3 +187,338 @@ func TestFileDrop_Good(t *testing.T) {
assert.Equal(t, "upload-zone", dropped.TargetID)
mu.Unlock()
}
// --- TaskMinimise ---
func TestTaskMinimise_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.minimised)
}
func TestTaskMinimise_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskMinimise{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskFocus ---
func TestTaskFocus_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskFocus{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.focused)
}
func TestTaskFocus_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskFocus{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskRestore ---
func TestTaskRestore_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
// First maximise, then restore
_, _, _ = c.PERFORM(TaskMaximise{Name: "test"})
_, handled, err := c.PERFORM(TaskRestore{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.False(t, mw.maximised)
// Verify state was updated
state, ok := svc.Manager().State().GetState("test")
assert.True(t, ok)
assert.False(t, state.Maximized)
}
func TestTaskRestore_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskRestore{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetTitle ---
func TestTaskSetTitle_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.Equal(t, "New Title", pw.Title())
}
func TestTaskSetTitle_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetTitle{Name: "nonexistent", Title: "Nope"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetAlwaysOnTop ---
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.alwaysOnTop)
}
func TestTaskSetAlwaysOnTop_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetBackgroundColour ---
func TestTaskSetBackgroundColour_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.Equal(t, [4]uint8{10, 20, 30, 40}, mw.backgroundColour)
}
func TestTaskSetBackgroundColour_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetVisibility ---
func TestTaskSetVisibility_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.visible)
// Now hide it
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: false})
require.NoError(t, err)
assert.True(t, handled)
assert.False(t, mw.visible)
}
func TestTaskSetVisibility_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "nonexistent", Visible: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskFullscreen ---
func TestTaskFullscreen_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
// Enter fullscreen
_, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.fullscreened)
// Exit fullscreen
_, handled, err = c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: false})
require.NoError(t, err)
assert.True(t, handled)
assert.False(t, mw.fullscreened)
}
func TestTaskFullscreen_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskFullscreen{Name: "nonexistent", Fullscreen: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSaveLayout ---
func TestTaskSaveLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 960, Height: 1080, X: 0, Y: 0})
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 960, Height: 1080, X: 960, Y: 0})
_, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"})
require.NoError(t, err)
assert.True(t, handled)
// Verify layout was saved with correct window states
layout, ok := svc.Manager().Layout().GetLayout("coding")
assert.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 2)
editorState, ok := layout.Windows["editor"]
assert.True(t, ok)
assert.Equal(t, 0, editorState.X)
assert.Equal(t, 960, editorState.Width)
termState, ok := layout.Windows["terminal"]
assert.True(t, ok)
assert.Equal(t, 960, termState.X)
assert.Equal(t, 960, termState.Width)
}
func TestTaskSaveLayout_Bad(t *testing.T) {
_, c := newTestWindowService(t)
// Saving an empty layout with empty name returns an error from LayoutManager
_, handled, err := c.PERFORM(TaskSaveLayout{Name: ""})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskRestoreLayout ---
func TestTaskRestoreLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
// Open windows
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600, X: 0, Y: 0})
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600, X: 0, Y: 0})
// Save a layout with specific positions
_, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"})
// Move the windows to different positions
_, _, _ = c.PERFORM(TaskSetPosition{Name: "editor", X: 500, Y: 500})
_, _, _ = c.PERFORM(TaskSetPosition{Name: "terminal", X: 600, Y: 600})
// Restore the layout
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "coding"})
require.NoError(t, err)
assert.True(t, handled)
// Verify windows were moved back to saved positions
pw, ok := svc.Manager().Get("editor")
require.True(t, ok)
x, y := pw.Position()
assert.Equal(t, 0, x)
assert.Equal(t, 0, y)
pw2, ok := svc.Manager().Get("terminal")
require.True(t, ok)
x2, y2 := pw2.Position()
assert.Equal(t, 0, x2)
assert.Equal(t, 0, y2)
editorState, ok := svc.Manager().State().GetState("editor")
require.True(t, ok)
assert.Equal(t, 0, editorState.X)
assert.Equal(t, 0, editorState.Y)
terminalState, ok := svc.Manager().State().GetState("terminal")
require.True(t, ok)
assert.Equal(t, 0, terminalState.X)
assert.Equal(t, 0, terminalState.Y)
}
func TestTaskRestoreLayout_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskStackWindows ---
func TestTaskStackWindows_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "s1", Width: 800, Height: 600})
_ = requireOpenWindow(t, c, Window{Name: "s2", Width: 800, Height: 600})
_, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("s2")
require.True(t, ok)
x, y := pw.Position()
assert.Equal(t, 25, x)
assert.Equal(t, 35, y)
}
// --- TaskApplyWorkflow ---
func TestTaskApplyWorkflow_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600})
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600})
_, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"})
require.NoError(t, err)
assert.True(t, handled)
editor, ok := svc.Manager().Get("editor")
require.True(t, ok)
x, y := editor.Position()
assert.Equal(t, 0, x)
assert.Equal(t, 0, y)
terminal, ok := svc.Manager().Get("terminal")
require.True(t, ok)
x, y = terminal.Position()
assert.Equal(t, 960, x)
assert.Equal(t, 0, y)
}

View file

@ -7,11 +7,12 @@ import (
"path/filepath"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
)
// WindowState holds the persisted position/size of a window.
// JSON tags match existing window_state.json format for backward compat.
// Use: state := window.WindowState{X: 10, Y: 20, Width: 1280, Height: 800}
type WindowState struct {
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
@ -24,7 +25,6 @@ type WindowState struct {
}
// StateManager persists window positions to ~/.config/Core/window_state.json.
// Use: sm := window.NewStateManager()
type StateManager struct {
configDir string
statePath string
@ -34,7 +34,6 @@ type StateManager struct {
}
// NewStateManager creates a StateManager loading from the default config directory.
// Use: sm := window.NewStateManager()
func NewStateManager() *StateManager {
sm := &StateManager{
states: make(map[string]WindowState),
@ -49,7 +48,6 @@ func NewStateManager() *StateManager {
// NewStateManagerWithDir creates a StateManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
// Use: sm := window.NewStateManagerWithDir(t.TempDir())
func NewStateManagerWithDir(configDir string) *StateManager {
sm := &StateManager{
configDir: configDir,
@ -73,8 +71,6 @@ func (sm *StateManager) dataDir() string {
return sm.configDir
}
// SetPath overrides the persisted state file path.
// Use: sm.SetPath(filepath.Join(t.TempDir(), "window_state.json"))
func (sm *StateManager) SetPath(path string) {
if path == "" {
return
@ -94,13 +90,13 @@ func (sm *StateManager) load() {
if sm.configDir == "" && sm.statePath == "" {
return
}
data, err := os.ReadFile(sm.filePath())
content, err := coreio.Local.Read(sm.filePath())
if err != nil {
return
}
sm.mu.Lock()
defer sm.mu.Unlock()
_ = json.Unmarshal(data, &sm.states)
_ = json.Unmarshal([]byte(content), &sm.states)
}
func (sm *StateManager) save() {
@ -113,8 +109,10 @@ func (sm *StateManager) save() {
if err != nil {
return
}
_ = os.MkdirAll(sm.dataDir(), 0o755)
_ = os.WriteFile(sm.filePath(), data, 0o644)
if dir := sm.dataDir(); dir != "" {
_ = coreio.Local.EnsureDir(dir)
}
_ = coreio.Local.Write(sm.filePath(), string(data))
}
func (sm *StateManager) scheduleSave() {
@ -125,7 +123,6 @@ func (sm *StateManager) scheduleSave() {
}
// GetState returns the saved state for a window name.
// Use: state, ok := sm.GetState("editor")
func (sm *StateManager) GetState(name string) (WindowState, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
@ -134,7 +131,6 @@ func (sm *StateManager) GetState(name string) (WindowState, bool) {
}
// SetState saves state for a window name (debounced disk write).
// Use: sm.SetState("editor", window.WindowState{Width: 1280, Height: 800})
func (sm *StateManager) SetState(name string, state WindowState) {
state.UpdatedAt = time.Now().UnixMilli()
sm.mu.Lock()
@ -144,7 +140,6 @@ func (sm *StateManager) SetState(name string, state WindowState) {
}
// UpdatePosition updates only the position fields.
// Use: sm.UpdatePosition("editor", 160, 120)
func (sm *StateManager) UpdatePosition(name string, x, y int) {
sm.mu.Lock()
s := sm.states[name]
@ -157,7 +152,6 @@ func (sm *StateManager) UpdatePosition(name string, x, y int) {
}
// UpdateSize updates only the size fields.
// Use: sm.UpdateSize("editor", 1280, 800)
func (sm *StateManager) UpdateSize(name string, width, height int) {
sm.mu.Lock()
s := sm.states[name]
@ -170,7 +164,6 @@ func (sm *StateManager) UpdateSize(name string, width, height int) {
}
// UpdateMaximized updates the maximized flag.
// Use: sm.UpdateMaximized("editor", true)
func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
sm.mu.Lock()
s := sm.states[name]
@ -182,7 +175,6 @@ func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
}
// CaptureState snapshots the current state from a PlatformWindow.
// Use: sm.CaptureState(pw)
func (sm *StateManager) CaptureState(pw PlatformWindow) {
x, y := pw.Position()
w, h := pw.Size()
@ -193,7 +185,6 @@ func (sm *StateManager) CaptureState(pw PlatformWindow) {
}
// ApplyState restores saved position/size to a Window descriptor.
// Use: sm.ApplyState(&window.Window{Name: "editor"})
func (sm *StateManager) ApplyState(w *Window) {
s, ok := sm.GetState(w.Name)
if !ok {
@ -210,7 +201,6 @@ func (sm *StateManager) ApplyState(w *Window) {
}
// ListStates returns all stored window names.
// Use: names := sm.ListStates()
func (sm *StateManager) ListStates() []string {
sm.mu.RLock()
defer sm.mu.RUnlock()
@ -222,7 +212,6 @@ func (sm *StateManager) ListStates() []string {
}
// Clear removes all stored states.
// Use: sm.Clear()
func (sm *StateManager) Clear() {
sm.mu.Lock()
sm.states = make(map[string]WindowState)
@ -231,7 +220,6 @@ func (sm *StateManager) Clear() {
}
// ForceSync writes state to disk immediately.
// Use: sm.ForceSync()
func (sm *StateManager) ForceSync() {
if sm.saveTimer != nil {
sm.saveTimer.Stop()

View file

@ -1,22 +1,9 @@
// pkg/window/tiling.go
package window
import "fmt"
// normalizeWindowForLayout clears transient maximise/minimise state before
// applying a new geometry. This keeps layout helpers effective even when a
// window was previously maximised.
func normalizeWindowForLayout(pw PlatformWindow) {
if pw == nil {
return
}
if pw.IsMaximised() || pw.IsMinimised() {
pw.Restore()
}
}
import coreerr "forge.lthn.ai/core/go-log"
// TileMode defines how windows are arranged.
// Use: mode := window.TileModeLeftRight
type TileMode int
const (
@ -40,12 +27,9 @@ var tileModeNames = map[TileMode]string{
TileModeLeftRight: "left-right", TileModeGrid: "grid",
}
// String returns the canonical layout name for the tile mode.
// Use: label := window.TileModeGrid.String()
func (m TileMode) String() string { return tileModeNames[m] }
// SnapPosition defines where a window snaps to.
// Use: pos := window.SnapRight
type SnapPosition int
const (
@ -60,8 +44,17 @@ const (
SnapCenter
)
var snapPositionNames = map[SnapPosition]string{
SnapLeft: "left", SnapRight: "right",
SnapTop: "top", SnapBottom: "bottom",
SnapTopLeft: "top-left", SnapTopRight: "top-right",
SnapBottomLeft: "bottom-left", SnapBottomRight: "bottom-right",
SnapCenter: "center",
}
func (p SnapPosition) String() string { return snapPositionNames[p] }
// WorkflowLayout is a predefined arrangement for common tasks.
// Use: workflow := window.WorkflowCoding
type WorkflowLayout int
const (
@ -76,33 +69,38 @@ var workflowNames = map[WorkflowLayout]string{
WorkflowPresenting: "presenting", WorkflowSideBySide: "side-by-side",
}
// String returns the canonical workflow name.
// Use: label := window.WorkflowCoding.String()
func (w WorkflowLayout) String() string { return workflowNames[w] }
// ParseWorkflowLayout converts a workflow name into its enum value.
// Use: workflow, ok := window.ParseWorkflowLayout("coding")
func ParseWorkflowLayout(name string) (WorkflowLayout, bool) {
for workflow, workflowName := range workflowNames {
if workflowName == name {
return workflow, true
}
func layoutOrigin(origin []int) (int, int) {
if len(origin) == 0 {
return 0, 0
}
return WorkflowCoding, false
if len(origin) == 1 {
return origin[0], 0
}
return origin[0], origin[1]
}
func (m *Manager) captureState(pw PlatformWindow) {
if m.state == nil || pw == nil {
return
}
m.state.CaptureState(pw)
}
// TileWindows arranges the named windows in the given mode across the screen area.
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
windows := make([]PlatformWindow, 0, len(names))
for _, name := range names {
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil)
}
windows = append(windows, pw)
}
if len(windows) == 0 {
return fmt.Errorf("no windows to tile")
return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil)
}
halfW, halfH := screenW/2, screenH/2
@ -111,9 +109,9 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
case TileModeLeftRight:
w := screenW / len(windows)
for i, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(i*w, 0)
pw.SetPosition(originX+i*w, originY)
pw.SetSize(w, screenH)
m.captureState(pw)
}
case TileModeGrid:
cols := 2
@ -122,133 +120,128 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
}
cellW := screenW / cols
for i, pw := range windows {
normalizeWindowForLayout(pw)
row := i / cols
col := i % cols
rows := (len(windows) + cols - 1) / cols
cellH := screenH / rows
pw.SetPosition(col*cellW, row*cellH)
pw.SetPosition(originX+col*cellW, originY+row*cellH)
pw.SetSize(cellW, cellH)
m.captureState(pw)
}
case TileModeLeftHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(halfW, screenH)
m.captureState(pw)
}
case TileModeRightHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetPosition(originX+halfW, originY)
pw.SetSize(halfW, screenH)
m.captureState(pw)
}
case TileModeTopHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(screenW, halfH)
m.captureState(pw)
}
case TileModeBottomHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetPosition(originX, originY+halfH)
pw.SetSize(screenW, halfH)
m.captureState(pw)
}
case TileModeTopLeft:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(halfW, halfH)
m.captureState(pw)
}
case TileModeTopRight:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetPosition(originX+halfW, originY)
pw.SetSize(halfW, halfH)
m.captureState(pw)
}
case TileModeBottomLeft:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetPosition(originX, originY+halfH)
pw.SetSize(halfW, halfH)
m.captureState(pw)
}
case TileModeBottomRight:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, halfH)
pw.SetPosition(originX+halfW, originY+halfH)
pw.SetSize(halfW, halfH)
m.captureState(pw)
}
}
return nil
}
// SnapWindow snaps a window to a screen edge/corner/centre.
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil)
}
halfW, halfH := screenW/2, screenH/2
switch pos {
case SnapLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(halfW, screenH)
case SnapRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetPosition(originX+halfW, originY)
pw.SetSize(halfW, screenH)
case SnapTop:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(screenW, halfH)
case SnapBottom:
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetPosition(originX, originY+halfH)
pw.SetSize(screenW, halfH)
case SnapTopLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(halfW, halfH)
case SnapTopRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetPosition(originX+halfW, originY)
pw.SetSize(halfW, halfH)
case SnapBottomLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetPosition(originX, originY+halfH)
pw.SetSize(halfW, halfH)
case SnapBottomRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, halfH)
pw.SetPosition(originX+halfW, originY+halfH)
pw.SetSize(halfW, halfH)
case SnapCenter:
normalizeWindowForLayout(pw)
cw, ch := pw.Size()
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
pw.SetPosition(originX+(screenW-cw)/2, originY+(screenH-ch)/2)
}
m.captureState(pw)
return nil
}
// StackWindows cascades windows with an offset.
func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error {
originX, originY := layoutOrigin(origin)
for i, name := range names {
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil)
}
normalizeWindowForLayout(pw)
pw.SetPosition(i*offsetX, i*offsetY)
pw.SetPosition(originX+i*offsetX, originY+i*offsetY)
m.captureState(pw)
}
return nil
}
// ApplyWorkflow arranges windows in a predefined workflow layout.
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
if len(names) == 0 {
return fmt.Errorf("no windows for workflow")
return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil)
}
switch workflow {
@ -256,41 +249,41 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
// 70/30 split — main editor + terminal
mainW := screenW * 70 / 100
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(mainW, screenH)
m.captureState(pw)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(mainW, 0)
pw.SetPosition(originX+mainW, originY)
pw.SetSize(screenW-mainW, screenH)
m.captureState(pw)
}
}
case WorkflowDebugging:
// 60/40 split
mainW := screenW * 60 / 100
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(mainW, screenH)
m.captureState(pw)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(mainW, 0)
pw.SetPosition(originX+mainW, originY)
pw.SetSize(screenW-mainW, screenH)
m.captureState(pw)
}
}
case WorkflowPresenting:
// Maximise first window
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetPosition(originX, originY)
pw.SetSize(screenW, screenH)
m.captureState(pw)
}
case WorkflowSideBySide:
return m.TileWindows(TileModeLeftRight, names, screenW, screenH)
return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY)
}
return nil
}

View file

@ -7,51 +7,45 @@ import (
)
// WailsPlatform implements Platform using Wails v3.
// Use: platform := window.NewWailsPlatform(app)
type WailsPlatform struct {
app *application.App
}
// NewWailsPlatform creates a Wails-backed Platform.
// Use: platform := window.NewWailsPlatform(app)
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
// CreateWindow opens a new Wails window from platform options.
// Use: w := wp.CreateWindow(window.PlatformWindowOptions{Name: "editor", URL: "/editor"})
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (wp *WailsPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
wOpts := application.WebviewWindowOptions{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
MinWidth: opts.MinWidth,
MinHeight: opts.MinHeight,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
Frameless: opts.Frameless,
Hidden: opts.Hidden,
AlwaysOnTop: opts.AlwaysOnTop,
DisableResize: opts.DisableResize,
EnableFileDrop: opts.EnableFileDrop,
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
Name: options.Name,
Title: options.Title,
URL: options.URL,
Width: options.Width,
Height: options.Height,
X: options.X,
Y: options.Y,
MinWidth: options.MinWidth,
MinHeight: options.MinHeight,
MaxWidth: options.MaxWidth,
MaxHeight: options.MaxHeight,
Frameless: options.Frameless,
Hidden: options.Hidden,
AlwaysOnTop: options.AlwaysOnTop,
DisableResize: options.DisableResize,
EnableFileDrop: options.EnableFileDrop,
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
}
w := wp.app.Window.NewWithOptions(wOpts)
return &wailsWindow{w: w, title: opts.Title}
return &wailsWindow{w: w, title: options.Title}
}
// GetWindows returns the live Wails windows.
// Use: windows := wp.GetWindows()
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
all := wp.app.Window.GetAll()
out := make([]PlatformWindow, 0, len(all))
for _, w := range all {
if wv, ok := w.(*application.WebviewWindow); ok {
out = append(out, &wailsWindow{w: wv, title: wv.Name()})
out = append(out, &wailsWindow{w: wv})
}
}
return out
@ -64,20 +58,10 @@ type wailsWindow struct {
title string
}
func (ww *wailsWindow) Name() string { return ww.w.Name() }
func (ww *wailsWindow) Title() string {
if ww.title != "" {
return ww.title
}
if ww.w != nil {
return ww.w.Name()
}
return ""
}
func (ww *wailsWindow) Name() string { return ww.w.Name() }
func (ww *wailsWindow) Title() string { return ww.title }
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
func (ww *wailsWindow) IsVisible() bool { return ww.w.IsVisible() }
func (ww *wailsWindow) IsMinimised() bool { return ww.w.IsMinimised() }
func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() }
func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() }
func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) }
@ -86,7 +70,6 @@ func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height)
func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {
ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
}
func (ww *wailsWindow) SetOpacity(opacity float32) { ww.w.SetOpacity(opacity) }
func (ww *wailsWindow) SetVisibility(visible bool) {
if visible {
ww.w.Show()
@ -104,8 +87,6 @@ func (ww *wailsWindow) Show() { ww.w.Show() }
func (ww *wailsWindow) Hide() { ww.w.Hide() }
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() }
func (ww *wailsWindow) CloseDevTools() { ww.w.CloseDevTools() }
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
name := ww.w.Name()
@ -130,8 +111,8 @@ func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
data["y"] = y
case "resize":
w, h := ww.w.Size()
data["w"] = w
data["h"] = h
data["width"] = w
data["height"] = h
}
handler(WindowEvent{
Type: typeName,

View file

@ -2,13 +2,12 @@
package window
import (
"fmt"
"math"
"sync"
coreerr "forge.lthn.ai/core/go-log"
)
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
// Use: spec := &window.Window{Name: "editor", URL: "/editor"}
type Window struct {
Name string
Title string
@ -26,7 +25,6 @@ type Window struct {
}
// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend.
// Use: opts := spec.ToPlatformOptions()
func (w *Window) ToPlatformOptions() PlatformWindowOptions {
return PlatformWindowOptions{
Name: w.Name, Title: w.Title, URL: w.URL,
@ -40,7 +38,6 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions {
}
// Manager manages window lifecycle through a Platform backend.
// Use: mgr := window.NewManager(platform)
type Manager struct {
platform Platform
state *StateManager
@ -52,7 +49,7 @@ type Manager struct {
}
// NewManager creates a window Manager with the given platform backend.
// Use: mgr := window.NewManager(platform)
// window.NewManager(window.NewWailsPlatform(app))
func NewManager(platform Platform) *Manager {
return &Manager{
platform: platform,
@ -63,7 +60,7 @@ func NewManager(platform Platform) *Manager {
}
// NewManagerWithDir creates a window Manager with a custom config directory for state/layout persistence.
// Use: mgr := window.NewManagerWithDir(platform, t.TempDir())
// window.NewManagerWithDir(window.NewMockPlatform(), t.TempDir())
func NewManagerWithDir(platform Platform, configDir string) *Manager {
return &Manager{
platform: platform,
@ -73,73 +70,85 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
}
}
// SetDefaultWidth overrides the fallback width used when a window is created without one.
// Use: mgr.SetDefaultWidth(1280)
func (m *Manager) SetDefaultWidth(width int) {
if width > 0 {
m.defaultWidth = width
}
}
// SetDefaultHeight overrides the fallback height used when a window is created without one.
// Use: mgr.SetDefaultHeight(800)
func (m *Manager) SetDefaultHeight(height int) {
if height > 0 {
m.defaultHeight = height
}
}
// Open creates a window using functional options, applies saved state, and tracks it.
// Use: _, err := mgr.Open(window.WithName("editor"), window.WithURL("/editor"))
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(opts...)
// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}).
func (m *Manager) Open(options ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(options...)
if err != nil {
return nil, fmt.Errorf("window.Manager.Open: %w", err)
return nil, coreerr.E("window.Manager.Open", "failed to apply options", err)
}
return m.Create(w)
return m.CreateWindow(*w)
}
// Create creates a window from a Window descriptor.
// Use: _, err := mgr.Create(&window.Window{Name: "editor", URL: "/editor"})
// CreateWindow creates a window from a Window descriptor.
// window.NewManager(window.NewWailsPlatform(app)).CreateWindow(window.Window{Name: "settings", URL: "/settings", Width: 800, Height: 600})
func (m *Manager) CreateWindow(spec Window) (PlatformWindow, error) {
_, pw, err := m.createWindow(spec)
return pw, err
}
// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}).
func (m *Manager) Create(w *Window) (PlatformWindow, error) {
if w.Name == "" {
w.Name = "main"
if w == nil {
return nil, coreerr.E("window.Manager.Create", "window descriptor is required", nil)
}
if w.Title == "" {
w.Title = "Core"
spec, pw, err := m.createWindow(*w)
if err != nil {
return nil, err
}
if w.Width == 0 {
if m.defaultWidth > 0 {
w.Width = m.defaultWidth
} else {
w.Width = 1280
}
}
if w.Height == 0 {
if m.defaultHeight > 0 {
w.Height = m.defaultHeight
} else {
w.Height = 800
}
}
if w.URL == "" {
w.URL = "/"
}
// Apply saved state if available
m.state.ApplyState(w)
pw := m.platform.CreateWindow(w.ToPlatformOptions())
m.mu.Lock()
m.windows[w.Name] = pw
m.mu.Unlock()
*w = spec
return pw, nil
}
func (m *Manager) createWindow(spec Window) (Window, PlatformWindow, error) {
if spec.Name == "" {
spec.Name = "main"
}
if spec.Title == "" {
spec.Title = "Core"
}
if spec.Width == 0 {
if m.defaultWidth > 0 {
spec.Width = m.defaultWidth
} else {
spec.Width = 1280
}
}
if spec.Height == 0 {
if m.defaultHeight > 0 {
spec.Height = m.defaultHeight
} else {
spec.Height = 800
}
}
if spec.URL == "" {
spec.URL = "/"
}
// Apply saved state if available.
m.state.ApplyState(&spec)
pw := m.platform.CreateWindow(spec.ToPlatformOptions())
m.mu.Lock()
m.windows[spec.Name] = pw
m.mu.Unlock()
return spec, pw, nil
}
// Get returns a tracked window by name.
// Use: pw, ok := mgr.Get("editor")
func (m *Manager) Get(name string) (PlatformWindow, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -148,7 +157,6 @@ func (m *Manager) Get(name string) (PlatformWindow, bool) {
}
// List returns all tracked window names.
// Use: names := mgr.List()
func (m *Manager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
@ -160,7 +168,6 @@ func (m *Manager) List() []string {
}
// Remove stops tracking a window by name.
// Use: mgr.Remove("editor")
func (m *Manager) Remove(name string) {
m.mu.Lock()
delete(m.windows, name)
@ -168,171 +175,16 @@ func (m *Manager) Remove(name string) {
}
// Platform returns the underlying platform for direct access.
// Use: platform := mgr.Platform()
func (m *Manager) Platform() Platform {
return m.platform
}
// State returns the state manager for window persistence.
// Use: state := mgr.State()
func (m *Manager) State() *StateManager {
return m.state
}
// Layout returns the layout manager.
// Use: layouts := mgr.Layout()
func (m *Manager) Layout() *LayoutManager {
return m.layout
}
// SuggestLayout returns a simple layout recommendation for the given screen.
// Use: suggestion := mgr.SuggestLayout(1920, 1080, 2)
func (m *Manager) SuggestLayout(screenW, screenH, windowCount int) LayoutSuggestion {
if windowCount <= 1 {
return LayoutSuggestion{
Mode: "single",
Columns: 1,
Rows: 1,
PrimaryWidth: screenW,
SecondaryWidth: 0,
Description: "Focus the primary window and keep the screen uncluttered.",
}
}
if windowCount == 2 {
return LayoutSuggestion{
Mode: "side-by-side",
Columns: 2,
Rows: 1,
PrimaryWidth: screenW / 2,
SecondaryWidth: screenW - (screenW / 2),
Description: "Split the screen into two equal panes.",
}
}
if windowCount <= 4 {
return LayoutSuggestion{
Mode: "quadrants",
Columns: 2,
Rows: 2,
PrimaryWidth: screenW / 2,
SecondaryWidth: screenW / 2,
Description: "Use a 2x2 grid for the active windows.",
}
}
cols := 3
rows := int(math.Ceil(float64(windowCount) / float64(cols)))
return LayoutSuggestion{
Mode: "grid",
Columns: cols,
Rows: rows,
PrimaryWidth: screenW / cols,
SecondaryWidth: screenW / cols,
Description: "Use a dense grid to keep every window visible.",
}
}
// FindSpace returns a free placement suggestion for a new window.
// Use: info := mgr.FindSpace(1920, 1080, 1280, 800)
func (m *Manager) FindSpace(screenW, screenH, width, height int) SpaceInfo {
if width <= 0 {
width = screenW / 2
}
if height <= 0 {
height = screenH / 2
}
occupied := make([]struct {
x, y, w, h int
}, 0)
for _, name := range m.List() {
pw, ok := m.Get(name)
if !ok {
continue
}
x, y := pw.Position()
w, h := pw.Size()
occupied = append(occupied, struct {
x, y, w, h int
}{x: x, y: y, w: w, h: h})
}
step := int(math.Max(40, math.Min(float64(width), float64(height))/6))
if step < 40 {
step = 40
}
for y := 0; y+height <= screenH; y += step {
for x := 0; x+width <= screenW; x += step {
if !intersectsAny(x, y, width, height, occupied) {
return SpaceInfo{
X: x, Y: y, Width: width, Height: height,
ScreenWidth: screenW, ScreenHeight: screenH,
Reason: "first available gap",
}
}
}
}
return SpaceInfo{
X: (screenW - width) / 2, Y: (screenH - height) / 2,
Width: width, Height: height,
ScreenWidth: screenW, ScreenHeight: screenH,
Reason: "center fallback",
}
}
// ArrangePair places two windows side-by-side with a balanced split.
// Use: _ = mgr.ArrangePair("editor", "terminal", 1920, 1080)
func (m *Manager) ArrangePair(first, second string, screenW, screenH int) error {
left, ok := m.Get(first)
if !ok {
return fmt.Errorf("window %q not found", first)
}
right, ok := m.Get(second)
if !ok {
return fmt.Errorf("window %q not found", second)
}
leftW := screenW / 2
rightW := screenW - leftW
left.SetPosition(0, 0)
left.SetSize(leftW, screenH)
right.SetPosition(leftW, 0)
right.SetSize(rightW, screenH)
return nil
}
// BesideEditor places a target window beside an editor window, using a 70/30 split.
// Use: _ = mgr.BesideEditor("editor", "terminal", 1920, 1080)
func (m *Manager) BesideEditor(editorName, windowName string, screenW, screenH int) error {
editor, ok := m.Get(editorName)
if !ok {
return fmt.Errorf("window %q not found", editorName)
}
target, ok := m.Get(windowName)
if !ok {
return fmt.Errorf("window %q not found", windowName)
}
editorW := screenW * 70 / 100
if editorW <= 0 {
editorW = screenW / 2
}
targetW := screenW - editorW
editor.SetPosition(0, 0)
editor.SetSize(editorW, screenH)
target.SetPosition(editorW, 0)
target.SetSize(targetW, screenH)
return nil
}
func intersectsAny(x, y, w, h int, occupied []struct{ x, y, w, h int }) bool {
for _, r := range occupied {
if x < r.x+r.w && x+w > r.x && y < r.y+r.h && y+h > r.y {
return true
}
}
return false
}

View file

@ -2,15 +2,13 @@
package window
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/application"
)
func TestWindowDefaults(t *testing.T) {
func TestWindowDefaults_Good(t *testing.T) {
w := &Window{}
assert.Equal(t, "", w.Name)
assert.Equal(t, 0, w.Width)
@ -93,39 +91,48 @@ func newTestManager() (*Manager, *mockPlatform) {
return m, p
}
func TestManager_Open_Good(t *testing.T) {
m, p := newTestManager()
pw, err := m.Open(WithName("test"), WithTitle("Test"), WithURL("/test"), WithSize(800, 600))
func requireCreateWindow(t *testing.T, m *Manager, w Window) PlatformWindow {
t.Helper()
pw, err := m.CreateWindow(w)
require.NoError(t, err)
return pw
}
func TestManager_CreateWindow_Good(t *testing.T) {
m, p := newTestManager()
pw := requireCreateWindow(t, m, Window{
Name: "test",
Title: "Test",
URL: "/test",
Width: 800,
Height: 600,
})
assert.NotNil(t, pw)
assert.Equal(t, "test", pw.Name())
assert.Len(t, p.windows, 1)
}
func TestManager_Open_Defaults_Good(t *testing.T) {
func TestManager_CreateWindow_Defaults_Good(t *testing.T) {
m, _ := newTestManager()
pw, err := m.Open()
require.NoError(t, err)
pw := requireCreateWindow(t, m, Window{})
assert.Equal(t, "main", pw.Name())
w, h := pw.Size()
assert.Equal(t, 1280, w)
assert.Equal(t, 800, h)
}
func TestManager_DefaultSizeOverrides_Good(t *testing.T) {
func TestManager_CreateWindow_CustomDefaults_Good(t *testing.T) {
m, _ := newTestManager()
m.SetDefaultWidth(1440)
m.SetDefaultHeight(900)
pw, err := m.Open()
require.NoError(t, err)
pw := requireCreateWindow(t, m, Window{})
w, h := pw.Size()
assert.Equal(t, 1440, w)
assert.Equal(t, 900, h)
}
func TestManager_Open_Bad(t *testing.T) {
func TestManager_Open_Compatibility_Bad(t *testing.T) {
m, _ := newTestManager()
_, err := m.Open(func(w *Window) error { return assert.AnError })
assert.Error(t, err)
@ -133,7 +140,7 @@ func TestManager_Open_Bad(t *testing.T) {
func TestManager_Get_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("findme"))
_ = requireCreateWindow(t, m, Window{Name: "findme"})
pw, ok := m.Get("findme")
assert.True(t, ok)
assert.Equal(t, "findme", pw.Name())
@ -147,8 +154,8 @@ func TestManager_Get_Bad(t *testing.T) {
func TestManager_List_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("a"))
_, _ = m.Open(WithName("b"))
_ = requireCreateWindow(t, m, Window{Name: "a"})
_ = requireCreateWindow(t, m, Window{Name: "b"})
names := m.List()
assert.Len(t, names, 2)
assert.Contains(t, names, "a")
@ -157,181 +164,12 @@ func TestManager_List_Good(t *testing.T) {
func TestManager_Remove_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("temp"))
_ = requireCreateWindow(t, m, Window{Name: "temp"})
m.Remove("temp")
_, ok := m.Get("temp")
assert.False(t, ok)
}
func TestWailsWindow_DevToolsToggle_Good(t *testing.T) {
app := application.NewApp()
platform := NewWailsPlatform(app)
pw := platform.CreateWindow(PlatformWindowOptions{Name: "devtools"})
ww, ok := pw.(*wailsWindow)
require.True(t, ok)
ww.OpenDevTools()
assert.True(t, ww.w.DevToolsOpen())
ww.CloseDevTools()
assert.False(t, ww.w.DevToolsOpen())
}
func TestWailsPlatform_GetWindows_TitleFallback_Good(t *testing.T) {
app := application.NewApp()
platform := NewWailsPlatform(app)
pw := platform.CreateWindow(PlatformWindowOptions{Name: "fallback"})
require.NotNil(t, pw)
windows := platform.GetWindows()
require.Len(t, windows, 1)
assert.Equal(t, "fallback", windows[0].Title())
}
// --- StateManager Tests ---
// newTestStateManager creates a clean StateManager with a temp dir for testing.
func newTestStateManager(t *testing.T) *StateManager {
return &StateManager{
configDir: t.TempDir(),
states: make(map[string]WindowState),
}
}
func TestStateManager_SetGet_Good(t *testing.T) {
sm := newTestStateManager(t)
state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false}
sm.SetState("main", state)
got, ok := sm.GetState("main")
assert.True(t, ok)
assert.Equal(t, 100, got.X)
assert.Equal(t, 800, got.Width)
}
func TestStateManager_SetGet_Bad(t *testing.T) {
sm := newTestStateManager(t)
_, ok := sm.GetState("nonexistent")
assert.False(t, ok)
}
func TestStateManager_CaptureState_Good(t *testing.T) {
sm := newTestStateManager(t)
w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true}
sm.CaptureState(w)
got, ok := sm.GetState("cap")
assert.True(t, ok)
assert.Equal(t, 50, got.X)
assert.Equal(t, 1024, got.Width)
assert.True(t, got.Maximized)
}
func TestStateManager_ApplyState_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480})
w := &Window{Name: "win", Width: 1280, Height: 800}
sm.ApplyState(w)
assert.Equal(t, 10, w.X)
assert.Equal(t, 20, w.Y)
assert.Equal(t, 640, w.Width)
assert.Equal(t, 480, w.Height)
}
func TestStateManager_ListStates_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("a", WindowState{Width: 100})
sm.SetState("b", WindowState{Width: 200})
names := sm.ListStates()
assert.Len(t, names, 2)
}
func TestStateManager_Clear_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("a", WindowState{Width: 100})
sm.Clear()
names := sm.ListStates()
assert.Empty(t, names)
}
func TestStateManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
sm1 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300})
sm1.ForceSync()
sm2 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
sm2.load()
got, ok := sm2.GetState("persist")
assert.True(t, ok)
assert.Equal(t, 42, got.X)
assert.Equal(t, 500, got.Width)
}
func TestStateManager_SetPath_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom-window-state.json")
sm := &StateManager{states: make(map[string]WindowState)}
sm.SetPath(path)
sm.SetState("custom", WindowState{X: 11, Y: 22, Width: 333, Height: 444})
sm.ForceSync()
reloaded := &StateManager{states: make(map[string]WindowState)}
reloaded.SetPath(path)
got, ok := reloaded.GetState("custom")
require.True(t, ok)
assert.Equal(t, 11, got.X)
assert.Equal(t, 333, got.Width)
}
// --- LayoutManager Tests ---
// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing.
func newTestLayoutManager(t *testing.T) *LayoutManager {
return &LayoutManager{
configDir: t.TempDir(),
layouts: make(map[string]Layout),
}
}
func TestLayoutManager_SaveGet_Good(t *testing.T) {
lm := newTestLayoutManager(t)
states := map[string]WindowState{
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
"terminal": {X: 960, Y: 0, Width: 960, Height: 1080},
}
err := lm.SaveLayout("coding", states)
require.NoError(t, err)
layout, ok := lm.GetLayout("coding")
assert.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 2)
}
func TestLayoutManager_GetLayout_Bad(t *testing.T) {
lm := newTestLayoutManager(t)
_, ok := lm.GetLayout("nonexistent")
assert.False(t, ok)
}
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
lm := newTestLayoutManager(t)
_ = lm.SaveLayout("a", map[string]WindowState{})
_ = lm.SaveLayout("b", map[string]WindowState{})
layouts := lm.ListLayouts()
assert.Len(t, layouts, 2)
}
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
lm := newTestLayoutManager(t)
_ = lm.SaveLayout("temp", map[string]WindowState{})
lm.DeleteLayout("temp")
_, ok := lm.GetLayout("temp")
assert.False(t, ok)
}
// --- Tiling Tests ---
func TestTileMode_String_Good(t *testing.T) {
@ -341,8 +179,8 @@ func TestTileMode_String_Good(t *testing.T) {
func TestManager_TileWindows_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("a"), WithSize(800, 600))
_, _ = m.Open(WithName("b"), WithSize(800, 600))
_ = requireCreateWindow(t, m, Window{Name: "a", Width: 800, Height: 600})
_ = requireCreateWindow(t, m, Window{Name: "b", Width: 800, Height: 600})
err := m.TileWindows(TileModeLeftRight, []string{"a", "b"}, 1920, 1080)
require.NoError(t, err)
a, _ := m.Get("a")
@ -361,7 +199,7 @@ func TestManager_TileWindows_Bad(t *testing.T) {
func TestManager_SnapWindow_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("snap"), WithSize(800, 600))
_ = requireCreateWindow(t, m, Window{Name: "snap", Width: 800, Height: 600})
err := m.SnapWindow("snap", SnapLeft, 1920, 1080)
require.NoError(t, err)
w, _ := m.Get("snap")
@ -373,8 +211,8 @@ func TestManager_SnapWindow_Good(t *testing.T) {
func TestManager_StackWindows_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("s1"), WithSize(800, 600))
_, _ = m.Open(WithName("s2"), WithSize(800, 600))
_ = requireCreateWindow(t, m, Window{Name: "s1", Width: 800, Height: 600})
_ = requireCreateWindow(t, m, Window{Name: "s2", Width: 800, Height: 600})
err := m.StackWindows([]string{"s1", "s2"}, 30, 30)
require.NoError(t, err)
s2, _ := m.Get("s2")
@ -388,42 +226,184 @@ func TestWorkflowLayout_Good(t *testing.T) {
assert.Equal(t, "debugging", WorkflowDebugging.String())
}
func TestManager_SuggestLayout_Good(t *testing.T) {
m, _ := newTestManager()
suggestion := m.SuggestLayout(1920, 1080, 3)
assert.Equal(t, "quadrants", suggestion.Mode)
assert.Equal(t, 2, suggestion.Columns)
// --- Comprehensive Tiling Tests ---
func TestTileWindows_AllModes_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
halfW, halfH := screenW/2, screenH/2
tests := []struct {
name string
mode TileMode
wantX int
wantY int
wantWidth int
wantHeight int
}{
{"LeftHalf", TileModeLeftHalf, 0, 0, halfW, screenH},
{"RightHalf", TileModeRightHalf, halfW, 0, halfW, screenH},
{"TopHalf", TileModeTopHalf, 0, 0, screenW, halfH},
{"BottomHalf", TileModeBottomHalf, 0, halfH, screenW, halfH},
{"TopLeft", TileModeTopLeft, 0, 0, halfW, halfH},
{"TopRight", TileModeTopRight, halfW, 0, halfW, halfH},
{"BottomLeft", TileModeBottomLeft, 0, halfH, halfW, halfH},
{"BottomRight", TileModeBottomRight, halfW, halfH, halfW, halfH},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_ = requireCreateWindow(t, m, Window{Name: "win", Width: 800, Height: 600})
err := m.TileWindows(tc.mode, []string{"win"}, screenW, screenH)
require.NoError(t, err)
pw, ok := m.Get("win")
require.True(t, ok)
x, y := pw.Position()
w, h := pw.Size()
assert.Equal(t, tc.wantX, x, "x position")
assert.Equal(t, tc.wantY, y, "y position")
assert.Equal(t, tc.wantWidth, w, "width")
assert.Equal(t, tc.wantHeight, h, "height")
})
}
}
func TestManager_FindSpace_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("one"), WithPosition(0, 0), WithSize(800, 600))
space := m.FindSpace(1920, 1080, 400, 300)
assert.GreaterOrEqual(t, space.X, 0)
assert.GreaterOrEqual(t, space.Y, 0)
func TestSnapWindow_AllPositions_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
halfW, halfH := screenW/2, screenH/2
tests := []struct {
name string
pos SnapPosition
initW int
initH int
wantX int
wantY int
wantWidth int
wantHeight int
}{
{"Right", SnapRight, 800, 600, halfW, 0, halfW, screenH},
{"Top", SnapTop, 800, 600, 0, 0, screenW, halfH},
{"Bottom", SnapBottom, 800, 600, 0, halfH, screenW, halfH},
{"TopLeft", SnapTopLeft, 800, 600, 0, 0, halfW, halfH},
{"TopRight", SnapTopRight, 800, 600, halfW, 0, halfW, halfH},
{"BottomLeft", SnapBottomLeft, 800, 600, 0, halfH, halfW, halfH},
{"BottomRight", SnapBottomRight, 800, 600, halfW, halfH, halfW, halfH},
{"Center", SnapCenter, 800, 600, (screenW - 800) / 2, (screenH - 600) / 2, 800, 600},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_ = requireCreateWindow(t, m, Window{Name: "snap", Width: tc.initW, Height: tc.initH})
err := m.SnapWindow("snap", tc.pos, screenW, screenH)
require.NoError(t, err)
pw, ok := m.Get("snap")
require.True(t, ok)
x, y := pw.Position()
w, h := pw.Size()
assert.Equal(t, tc.wantX, x, "x position")
assert.Equal(t, tc.wantY, y, "y position")
assert.Equal(t, tc.wantWidth, w, "width")
assert.Equal(t, tc.wantHeight, h, "height")
})
}
}
func TestManager_ArrangePair_Good(t *testing.T) {
func TestStackWindows_ThreeWindows_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("left"), WithSize(800, 600))
_, _ = m.Open(WithName("right"), WithSize(800, 600))
err := m.ArrangePair("left", "right", 1920, 1080)
names := []string{"s1", "s2", "s3"}
for _, name := range names {
_ = requireCreateWindow(t, m, Window{Name: name, Width: 800, Height: 600})
}
err := m.StackWindows(names, 30, 30)
require.NoError(t, err)
left, _ := m.Get("left")
x, _ := left.Position()
assert.Equal(t, 0, x)
for i, name := range names {
pw, ok := m.Get(name)
require.True(t, ok, "window %s should exist", name)
x, y := pw.Position()
assert.Equal(t, i*30, x, "window %s x position", name)
assert.Equal(t, i*30, y, "window %s y position", name)
}
}
func TestManager_BesideEditor_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("editor"))
_, _ = m.Open(WithName("assistant"))
err := m.BesideEditor("editor", "assistant", 1920, 1080)
require.NoError(t, err)
editor, _ := m.Get("editor")
assistant, _ := m.Get("assistant")
ex, _ := editor.Size()
ax, _ := assistant.Position()
assert.Greater(t, ex, 0)
assert.Greater(t, ax, 0)
func TestApplyWorkflow_AllLayouts_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
tests := []struct {
name string
workflow WorkflowLayout
// Expected positions/sizes for the first two windows.
// For WorkflowSideBySide, TileWindows(LeftRight) divides equally.
win0X, win0Y, win0W, win0H int
win1X, win1Y, win1W, win1H int
}{
{
"Coding",
WorkflowCoding,
0, 0, 1344, screenH, // 70% of 1920 = 1344
1344, 0, screenW - 1344, screenH, // remaining 30%
},
{
"Debugging",
WorkflowDebugging,
0, 0, 1152, screenH, // 60% of 1920 = 1152
1152, 0, screenW - 1152, screenH, // remaining 40%
},
{
"Presenting",
WorkflowPresenting,
0, 0, screenW, screenH, // maximised
0, 0, 800, 600, // second window untouched
},
{
"SideBySide",
WorkflowSideBySide,
0, 0, 960, screenH, // left half (1920/2)
960, 0, 960, screenH, // right half
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_ = requireCreateWindow(t, m, Window{Name: "editor", Width: 800, Height: 600})
_ = requireCreateWindow(t, m, Window{Name: "terminal", Width: 800, Height: 600})
err := m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH)
require.NoError(t, err)
pw0, ok := m.Get("editor")
require.True(t, ok)
x0, y0 := pw0.Position()
w0, h0 := pw0.Size()
assert.Equal(t, tc.win0X, x0, "editor x")
assert.Equal(t, tc.win0Y, y0, "editor y")
assert.Equal(t, tc.win0W, w0, "editor width")
assert.Equal(t, tc.win0H, h0, "editor height")
pw1, ok := m.Get("terminal")
require.True(t, ok)
x1, y1 := pw1.Position()
w1, h1 := pw1.Size()
assert.Equal(t, tc.win1X, x1, "terminal x")
assert.Equal(t, tc.win1Y, y1, "terminal y")
assert.Equal(t, tc.win1W, w1, "terminal width")
assert.Equal(t, tc.win1H, h1, "terminal height")
})
}
}
func TestApplyWorkflow_Empty_Bad(t *testing.T) {
m, _ := newTestManager()
err := m.ApplyWorkflow(WorkflowCoding, []string{}, 1920, 1080)
assert.Error(t, err)
}

View file

@ -0,0 +1,778 @@
package application
import (
"sync"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/events"
)
// Context mirrors the callback context type exposed by Wails.
//
// item.OnClick(func(ctx *Context) { openPrefs() })
type Context struct {
clickedMenuItem *MenuItem
contextMenuData *ContextMenuData
checked bool
}
func newContext() *Context { return &Context{} }
func (ctx *Context) withClickedMenuItem(item *MenuItem) *Context {
ctx.clickedMenuItem = item
return ctx
}
func (ctx *Context) withContextMenuData(data *ContextMenuData) *Context {
ctx.contextMenuData = data
return ctx
}
func (ctx *Context) withChecked(checked bool) *Context {
ctx.checked = checked
return ctx
}
// Logger is a minimal logger surface used by the GUI packages.
type Logger struct{}
func (l Logger) Info(message string, args ...any) {}
// RGBA stores a colour with alpha.
//
// colour := NewRGBA(255, 128, 0, 255) // opaque orange
type RGBA struct {
Red, Green, Blue, Alpha uint8
}
// NewRGBA constructs an RGBA value.
//
// colour := NewRGBA(255, 128, 0, 255) // opaque orange
func NewRGBA(red, green, blue, alpha uint8) RGBA {
return RGBA{Red: red, Green: green, Blue: blue, Alpha: alpha}
}
// Menu is a menu tree used by the GUI wrappers.
//
// menu := NewMenu()
// menu.Add("Save").SetAccelerator("CmdOrCtrl+S").OnClick(func(ctx *Context) { save() })
type Menu struct {
label string
Items []*MenuItem
}
// NewMenu creates a new, empty Menu.
//
// menu := NewMenu()
func NewMenu() *Menu { return &Menu{} }
// Add appends a new text item with the given label.
func (m *Menu) Add(label string) *MenuItem {
item := NewMenuItem(label)
item.disabled = false
m.Items = append(m.Items, item)
return item
}
// AddSeparator appends a separator item.
func (m *Menu) AddSeparator() {
m.Items = append(m.Items, NewMenuItemSeparator())
}
// AddSubmenu appends a submenu item and returns the child Menu.
func (m *Menu) AddSubmenu(label string) *Menu {
item := NewSubMenuItem(label)
m.Items = append(m.Items, item)
return item.submenu
}
// AddRole appends a platform-role item.
func (m *Menu) AddRole(role Role) {
m.Items = append(m.Items, NewRole(role))
}
// AppendItem appends an already-constructed MenuItem.
func (m *Menu) AppendItem(item *MenuItem) {
m.Items = append(m.Items, item)
}
// Clone returns a deep copy of the menu tree.
func (m *Menu) Clone() *Menu {
cloned := &Menu{label: m.label}
for _, item := range m.Items {
cloned.Items = append(cloned.Items, item.Clone())
}
return cloned
}
// Destroy frees all items in the menu.
func (m *Menu) Destroy() {
for _, item := range m.Items {
item.Destroy()
}
m.Items = nil
}
func (m *Menu) setContextData(data *ContextMenuData) {
for _, item := range m.Items {
item.contextMenuData = data
if item.submenu != nil {
item.submenu.setContextData(data)
}
}
}
// MenuManager owns the application menu.
//
// app.Menu.SetApplicationMenu(menu)
type MenuManager struct {
applicationMenu *Menu
}
func (m *MenuManager) SetApplicationMenu(menu *Menu) { m.applicationMenu = menu }
// SystemTray represents a tray instance.
type SystemTray struct {
icon []byte
templateIcon []byte
tooltip string
label string
menu *Menu
attachedWindow Window
}
func (t *SystemTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) }
func (t *SystemTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]byte(nil), data...) }
func (t *SystemTray) SetTooltip(text string) { t.tooltip = text }
func (t *SystemTray) SetLabel(text string) { t.label = text }
func (t *SystemTray) SetMenu(menu *Menu) { t.menu = menu }
// AttachWindow associates a window with the tray icon (shown on click).
func (t *SystemTray) AttachWindow(w Window) { t.attachedWindow = w }
// SystemTrayManager creates tray instances.
type SystemTrayManager struct{}
func (m *SystemTrayManager) New() *SystemTray { return &SystemTray{} }
// WindowEventContext carries drag-and-drop details for a window event.
type WindowEventContext struct {
droppedFiles []string
dropDetails *DropTargetDetails
}
func (c *WindowEventContext) DroppedFiles() []string {
return append([]string(nil), c.droppedFiles...)
}
func (c *WindowEventContext) DropTargetDetails() *DropTargetDetails {
if c.dropDetails == nil {
return nil
}
details := *c.dropDetails
return &details
}
// DropTargetDetails mirrors the fields consumed by the GUI wrappers.
type DropTargetDetails struct {
ElementID string
}
// WindowEvent mirrors the event object passed to window callbacks.
type WindowEvent struct {
ctx *WindowEventContext
}
func (e *WindowEvent) Context() *WindowEventContext {
if e.ctx == nil {
e.ctx = &WindowEventContext{}
}
return e.ctx
}
// WebviewWindow is a lightweight, in-memory window implementation
// that satisfies the Window interface.
type WebviewWindow struct {
mu sync.RWMutex
opts WebviewWindowOptions
windowID uint
title string
posX, posY int
sizeW, sizeH int
maximised bool
focused bool
visible bool
alwaysOnTop bool
isFullscreen bool
closed bool
zoom float64
resizable bool
ignoreMouseEvents bool
enabled bool
eventHandlers map[events.WindowEventType][]func(*WindowEvent)
}
var globalWindowID uint
var globalWindowIDMu sync.Mutex
func nextWindowID() uint {
globalWindowIDMu.Lock()
defer globalWindowIDMu.Unlock()
globalWindowID++
return globalWindowID
}
func newWebviewWindow(options WebviewWindowOptions) *WebviewWindow {
return &WebviewWindow{
opts: options,
windowID: nextWindowID(),
title: options.Title,
posX: options.X,
posY: options.Y,
sizeW: options.Width,
sizeH: options.Height,
visible: !options.Hidden,
alwaysOnTop: options.AlwaysOnTop,
zoom: options.Zoom,
resizable: !options.DisableResize,
enabled: true,
eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)),
}
}
// ID returns the unique numeric identifier for the window.
func (w *WebviewWindow) ID() uint { return w.windowID }
// Name returns the window name set in WebviewWindowOptions.
func (w *WebviewWindow) Name() string { return w.opts.Name }
// Show makes the window visible and returns it for chaining.
func (w *WebviewWindow) Show() Window {
w.mu.Lock()
w.visible = true
w.mu.Unlock()
return w
}
// Hide makes the window invisible and returns it for chaining.
func (w *WebviewWindow) Hide() Window {
w.mu.Lock()
w.visible = false
w.mu.Unlock()
return w
}
// IsVisible reports whether the window is currently visible.
func (w *WebviewWindow) IsVisible() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.visible
}
// Close marks the window as closed.
func (w *WebviewWindow) Close() {
w.mu.Lock()
w.closed = true
w.mu.Unlock()
}
// Focus marks the window as focused.
func (w *WebviewWindow) Focus() {
w.mu.Lock()
w.focused = true
w.mu.Unlock()
}
// Run is a no-op in the stub (the real implementation enters the run loop).
func (w *WebviewWindow) Run() {}
// Center is a no-op in the stub.
func (w *WebviewWindow) Center() {}
// Position returns the current x/y position.
func (w *WebviewWindow) Position() (int, int) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.posX, w.posY
}
// RelativePosition returns the position relative to the screen.
func (w *WebviewWindow) RelativePosition() (int, int) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.posX, w.posY
}
// Size returns the current width and height.
func (w *WebviewWindow) Size() (int, int) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.sizeW, w.sizeH
}
// Width returns the current window width.
func (w *WebviewWindow) Width() int {
w.mu.RLock()
defer w.mu.RUnlock()
return w.sizeW
}
// Height returns the current window height.
func (w *WebviewWindow) Height() int {
w.mu.RLock()
defer w.mu.RUnlock()
return w.sizeH
}
// Bounds returns the window's position and size as a Rect.
func (w *WebviewWindow) Bounds() Rect {
w.mu.RLock()
defer w.mu.RUnlock()
return Rect{X: w.posX, Y: w.posY, Width: w.sizeW, Height: w.sizeH}
}
// SetPosition sets the top-left corner position.
func (w *WebviewWindow) SetPosition(x, y int) {
w.mu.Lock()
w.posX, w.posY = x, y
w.mu.Unlock()
}
// SetRelativePosition sets position relative to the screen and returns the window.
func (w *WebviewWindow) SetRelativePosition(x, y int) Window {
w.SetPosition(x, y)
return w
}
// SetSize sets the window dimensions and returns the window.
func (w *WebviewWindow) SetSize(width, height int) Window {
w.mu.Lock()
w.sizeW, w.sizeH = width, height
w.mu.Unlock()
return w
}
// SetBounds sets position and size in one call.
func (w *WebviewWindow) SetBounds(bounds Rect) {
w.mu.Lock()
w.posX, w.posY = bounds.X, bounds.Y
w.sizeW, w.sizeH = bounds.Width, bounds.Height
w.mu.Unlock()
}
// SetMaxSize is a no-op in the stub.
func (w *WebviewWindow) SetMaxSize(maxWidth, maxHeight int) Window { return w }
// SetMinSize is a no-op in the stub.
func (w *WebviewWindow) SetMinSize(minWidth, minHeight int) Window { return w }
// EnableSizeConstraints is a no-op in the stub.
func (w *WebviewWindow) EnableSizeConstraints() {}
// DisableSizeConstraints is a no-op in the stub.
func (w *WebviewWindow) DisableSizeConstraints() {}
// Resizable reports whether the user can resize the window.
func (w *WebviewWindow) Resizable() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.resizable
}
// SetResizable enables or disables user resizing and returns the window.
func (w *WebviewWindow) SetResizable(b bool) Window {
w.mu.Lock()
w.resizable = b
w.mu.Unlock()
return w
}
// Maximise maximises the window and returns it.
func (w *WebviewWindow) Maximise() Window {
w.mu.Lock()
w.maximised = true
w.mu.Unlock()
return w
}
// UnMaximise restores from maximised state.
func (w *WebviewWindow) UnMaximise() {
w.mu.Lock()
w.maximised = false
w.mu.Unlock()
}
// ToggleMaximise toggles between maximised and normal.
func (w *WebviewWindow) ToggleMaximise() {
w.mu.Lock()
w.maximised = !w.maximised
w.mu.Unlock()
}
// IsMaximised reports whether the window is maximised.
func (w *WebviewWindow) IsMaximised() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.maximised
}
// Minimise minimises the window and returns it.
func (w *WebviewWindow) Minimise() Window {
w.mu.Lock()
w.visible = false
w.mu.Unlock()
return w
}
// UnMinimise restores from minimised state.
func (w *WebviewWindow) UnMinimise() {
w.mu.Lock()
w.visible = true
w.mu.Unlock()
}
// IsMinimised always returns false in the stub.
func (w *WebviewWindow) IsMinimised() bool { return false }
// Fullscreen enters fullscreen and returns the window.
func (w *WebviewWindow) Fullscreen() Window {
w.mu.Lock()
w.isFullscreen = true
w.mu.Unlock()
return w
}
// UnFullscreen exits fullscreen.
func (w *WebviewWindow) UnFullscreen() {
w.mu.Lock()
w.isFullscreen = false
w.mu.Unlock()
}
// ToggleFullscreen toggles between fullscreen and normal.
func (w *WebviewWindow) ToggleFullscreen() {
w.mu.Lock()
w.isFullscreen = !w.isFullscreen
w.mu.Unlock()
}
// IsFullscreen reports whether the window is in fullscreen mode.
func (w *WebviewWindow) IsFullscreen() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.isFullscreen
}
// Restore exits both fullscreen and maximised states.
func (w *WebviewWindow) Restore() {
w.mu.Lock()
w.maximised = false
w.isFullscreen = false
w.mu.Unlock()
}
// SnapAssist is a no-op in the stub.
func (w *WebviewWindow) SnapAssist() {}
// SetTitle updates the window title and returns the window.
func (w *WebviewWindow) SetTitle(title string) Window {
w.mu.Lock()
w.title = title
w.mu.Unlock()
return w
}
// SetURL is a no-op in the stub.
func (w *WebviewWindow) SetURL(s string) Window { return w }
// SetHTML is a no-op in the stub.
func (w *WebviewWindow) SetHTML(html string) Window { return w }
// SetMinimiseButtonState is a no-op in the stub.
func (w *WebviewWindow) SetMinimiseButtonState(state ButtonState) Window { return w }
// SetMaximiseButtonState is a no-op in the stub.
func (w *WebviewWindow) SetMaximiseButtonState(state ButtonState) Window { return w }
// SetCloseButtonState is a no-op in the stub.
func (w *WebviewWindow) SetCloseButtonState(state ButtonState) Window { return w }
// SetMenu is a no-op in the stub.
func (w *WebviewWindow) SetMenu(menu *Menu) {}
// ShowMenuBar is a no-op in the stub.
func (w *WebviewWindow) ShowMenuBar() {}
// HideMenuBar is a no-op in the stub.
func (w *WebviewWindow) HideMenuBar() {}
// ToggleMenuBar is a no-op in the stub.
func (w *WebviewWindow) ToggleMenuBar() {}
// SetBackgroundColour is a no-op in the stub.
func (w *WebviewWindow) SetBackgroundColour(colour RGBA) Window { return w }
// SetAlwaysOnTop sets the always-on-top flag and returns the window.
func (w *WebviewWindow) SetAlwaysOnTop(b bool) Window {
w.mu.Lock()
w.alwaysOnTop = b
w.mu.Unlock()
return w
}
// SetFrameless is a no-op in the stub.
func (w *WebviewWindow) SetFrameless(frameless bool) Window { return w }
// ToggleFrameless is a no-op in the stub.
func (w *WebviewWindow) ToggleFrameless() {}
// SetIgnoreMouseEvents sets the mouse-event passthrough flag and returns the window.
func (w *WebviewWindow) SetIgnoreMouseEvents(ignore bool) Window {
w.mu.Lock()
w.ignoreMouseEvents = ignore
w.mu.Unlock()
return w
}
// IsIgnoreMouseEvents reports whether mouse events are being ignored.
func (w *WebviewWindow) IsIgnoreMouseEvents() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.ignoreMouseEvents
}
// SetContentProtection is a no-op in the stub.
func (w *WebviewWindow) SetContentProtection(protection bool) Window { return w }
// GetZoom returns the current zoom magnification.
func (w *WebviewWindow) GetZoom() float64 {
w.mu.RLock()
defer w.mu.RUnlock()
return w.zoom
}
// SetZoom sets the zoom magnification and returns the window.
func (w *WebviewWindow) SetZoom(magnification float64) Window {
w.mu.Lock()
w.zoom = magnification
w.mu.Unlock()
return w
}
// Zoom is a no-op in the stub.
func (w *WebviewWindow) Zoom() {}
// ZoomIn is a no-op in the stub.
func (w *WebviewWindow) ZoomIn() {}
// ZoomOut is a no-op in the stub.
func (w *WebviewWindow) ZoomOut() {}
// ZoomReset resets zoom to 1.0 and returns the window.
func (w *WebviewWindow) ZoomReset() Window {
w.mu.Lock()
w.zoom = 1.0
w.mu.Unlock()
return w
}
// GetBorderSizes returns zero insets in the stub.
func (w *WebviewWindow) GetBorderSizes() *LRTB { return &LRTB{} }
// GetScreen returns nil in the stub.
func (w *WebviewWindow) GetScreen() (*Screen, error) { return nil, nil }
// ExecJS is a no-op in the stub.
func (w *WebviewWindow) ExecJS(js string) {}
// EmitEvent always returns false in the stub.
func (w *WebviewWindow) EmitEvent(name string, data ...any) bool { return false }
// DispatchWailsEvent is a no-op in the stub.
func (w *WebviewWindow) DispatchWailsEvent(event *CustomEvent) {}
// OnWindowEvent registers an event callback and returns an unsubscribe function.
func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
w.mu.Lock()
w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback)
w.mu.Unlock()
return func() {}
}
// RegisterHook is an alias for OnWindowEvent.
func (w *WebviewWindow) RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
return w.OnWindowEvent(eventType, callback)
}
// handleDragAndDropMessage is a no-op in the stub.
func (w *WebviewWindow) handleDragAndDropMessage(filenames []string, dropTarget *DropTargetDetails) {}
// InitiateFrontendDropProcessing is a no-op in the stub.
func (w *WebviewWindow) InitiateFrontendDropProcessing(filenames []string, x int, y int) {}
// HandleMessage is a no-op in the stub.
func (w *WebviewWindow) HandleMessage(message string) {}
// HandleWindowEvent is a no-op in the stub.
func (w *WebviewWindow) HandleWindowEvent(id uint) {}
// HandleKeyEvent is a no-op in the stub.
func (w *WebviewWindow) HandleKeyEvent(acceleratorString string) {}
// OpenContextMenu is a no-op in the stub.
func (w *WebviewWindow) OpenContextMenu(data *ContextMenuData) {}
// AttachModal is a no-op in the stub.
func (w *WebviewWindow) AttachModal(modalWindow Window) {}
// OpenDevTools is a no-op in the stub.
func (w *WebviewWindow) OpenDevTools() {}
// Print always returns nil in the stub.
func (w *WebviewWindow) Print() error { return nil }
// Flash is a no-op in the stub.
func (w *WebviewWindow) Flash(enabled bool) {}
// IsFocused reports whether the window has focus.
func (w *WebviewWindow) IsFocused() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.focused
}
// NativeWindow returns nil in the stub.
func (w *WebviewWindow) NativeWindow() unsafe.Pointer { return nil }
// SetEnabled sets the window's enabled state.
func (w *WebviewWindow) SetEnabled(enabled bool) {
w.mu.Lock()
w.enabled = enabled
w.mu.Unlock()
}
// Reload is a no-op in the stub.
func (w *WebviewWindow) Reload() {}
// ForceReload is a no-op in the stub.
func (w *WebviewWindow) ForceReload() {}
// Info is a no-op in the stub.
func (w *WebviewWindow) Info(message string, args ...any) {}
// Error is a no-op in the stub.
func (w *WebviewWindow) Error(message string, args ...any) {}
// shouldUnconditionallyClose always returns false in the stub.
func (w *WebviewWindow) shouldUnconditionallyClose() bool { return false }
// Internal editing stubs — satisfy the Window interface.
func (w *WebviewWindow) cut() {}
func (w *WebviewWindow) copy() {}
func (w *WebviewWindow) paste() {}
func (w *WebviewWindow) undo() {}
func (w *WebviewWindow) redo() {}
func (w *WebviewWindow) delete() {}
func (w *WebviewWindow) selectAll() {}
// Title returns the current window title.
func (w *WebviewWindow) Title() string {
w.mu.RLock()
defer w.mu.RUnlock()
return w.title
}
// WindowManager manages in-memory windows.
//
// win := app.Window.NewWithOptions(application.WebviewWindowOptions{Title: "Main"})
type WindowManager struct {
mu sync.RWMutex
windows map[uint]*WebviewWindow
}
func (wm *WindowManager) init() {
if wm.windows == nil {
wm.windows = make(map[uint]*WebviewWindow)
}
}
// NewWithOptions creates and registers a new window.
func (wm *WindowManager) NewWithOptions(options WebviewWindowOptions) *WebviewWindow {
window := newWebviewWindow(options)
wm.mu.Lock()
wm.init()
wm.windows[window.windowID] = window
wm.mu.Unlock()
return window
}
// New creates a window with default options.
func (wm *WindowManager) New() *WebviewWindow {
return wm.NewWithOptions(WebviewWindowOptions{})
}
// GetAll returns all managed windows as the Window interface slice.
func (wm *WindowManager) GetAll() []Window {
wm.mu.RLock()
defer wm.mu.RUnlock()
out := make([]Window, 0, len(wm.windows))
for _, window := range wm.windows {
out = append(out, window)
}
return out
}
// GetByName finds a window by name, returning it and whether it was found.
func (wm *WindowManager) GetByName(name string) (Window, bool) {
wm.mu.RLock()
defer wm.mu.RUnlock()
for _, window := range wm.windows {
if window.opts.Name == name {
return window, true
}
}
return nil, false
}
// GetByID finds a window by its numeric ID.
func (wm *WindowManager) GetByID(id uint) (Window, bool) {
wm.mu.RLock()
defer wm.mu.RUnlock()
window, exists := wm.windows[id]
return window, exists
}
// Remove unregisters a window by ID.
func (wm *WindowManager) Remove(windowID uint) {
wm.mu.Lock()
wm.init()
delete(wm.windows, windowID)
wm.mu.Unlock()
}
// App is the top-level application object used by the GUI packages.
//
// app := &application.App{}
// win := app.Window.NewWithOptions(application.WebviewWindowOptions{Title: "Main"})
type App struct {
Logger Logger
Window WindowManager
Menu MenuManager
SystemTray SystemTrayManager
Dialog DialogManager
Event EventManager
Browser BrowserManager
Clipboard ClipboardManager
ContextMenu ContextMenuManager
Environment EnvironmentManager
Screen ScreenManager
KeyBinding KeyBindingManager
}
// Quit is a no-op in the stub.
func (a *App) Quit() {}
// NewMenu creates a new empty Menu.
func (a *App) NewMenu() *Menu {
return NewMenu()
}

View file

@ -0,0 +1,348 @@
package application
import (
"net/http"
"time"
)
// Options is the top-level application configuration passed to New().
// app := application.New(application.Options{Name: "MyApp", Services: []Service{...}})
type Options struct {
// Name is displayed in the default about box.
Name string
// Description is displayed in the default about box.
Description string
// Icon is the application icon shown in the about box.
Icon []byte
// Mac contains macOS-specific options.
Mac MacOptions
// Windows contains Windows-specific options.
Windows WindowsOptions
// Linux contains Linux-specific options.
Linux LinuxOptions
// IOS contains iOS-specific options.
IOS IOSOptions
// Android contains Android-specific options.
Android AndroidOptions
// Services lists bound Go services exposed to the frontend.
Services []Service
// MarshalError serialises service method errors to JSON.
// Return nil to fall back to the default error handler.
MarshalError func(error) []byte
// BindAliases maps alias IDs to bound method IDs.
// Example: map[uint32]uint32{1: 1411160069}
BindAliases map[uint32]uint32
// Assets configures the embedded asset server.
Assets AssetOptions
// Flags are key/value pairs available to the frontend at startup.
Flags map[string]any
// PanicHandler is called when an unhandled panic occurs.
PanicHandler func(*PanicDetails)
// DisableDefaultSignalHandler prevents Wails from handling OS signals.
DisableDefaultSignalHandler bool
// KeyBindings maps accelerator strings to window callbacks.
// Example: map[string]func(Window){"Ctrl+Q": func(w Window) { w.Close() }}
KeyBindings map[string]func(window Window)
// OnShutdown is called before the application terminates.
OnShutdown func()
// PostShutdown is called after shutdown, just before process exit.
PostShutdown func()
// ShouldQuit is called when the user attempts to quit.
// Return false to prevent the application from quitting.
ShouldQuit func() bool
// RawMessageHandler handles raw messages from the frontend.
RawMessageHandler func(window Window, message string, originInfo *OriginInfo)
// WarningHandler is called when a non-fatal warning occurs.
WarningHandler func(string)
// ErrorHandler is called when a non-fatal error occurs.
ErrorHandler func(err error)
// FileAssociations lists file extensions associated with this application.
// Each extension must include the leading dot, e.g. ".txt".
FileAssociations []string
// SingleInstance configures single-instance enforcement.
SingleInstance *SingleInstanceOptions
// Server configures the headless HTTP server (enabled via the "server" build tag).
Server ServerOptions
}
// ServerOptions configures the headless HTTP server started in server mode.
// opts.Server = application.ServerOptions{Host: "0.0.0.0", Port: 8080}
type ServerOptions struct {
// Host is the bind address. Defaults to "localhost".
Host string
// Port is the TCP port. Defaults to 8080.
Port int
// ReadTimeout is the maximum duration for reading a complete request.
ReadTimeout time.Duration
// WriteTimeout is the maximum duration before timing out a response write.
WriteTimeout time.Duration
// IdleTimeout is the maximum duration to wait for the next request.
IdleTimeout time.Duration
// ShutdownTimeout is the maximum duration to wait for active connections on shutdown.
ShutdownTimeout time.Duration
// TLS configures HTTPS. If nil, plain HTTP is used.
TLS *TLSOptions
}
// TLSOptions configures HTTPS for the headless server.
// opts.Server.TLS = &application.TLSOptions{CertFile: "cert.pem", KeyFile: "key.pem"}
type TLSOptions struct {
// CertFile is the path to the TLS certificate file.
CertFile string
// KeyFile is the path to the TLS private key file.
KeyFile string
}
// AssetOptions configures the embedded asset server.
// opts.Assets = application.AssetOptions{Handler: http.FileServer(http.FS(assets))}
type AssetOptions struct {
// Handler serves all content to the WebView.
Handler http.Handler
// Middleware injects into the asset server request chain before Wails internals.
Middleware Middleware
// DisableLogging suppresses per-request asset server log output.
DisableLogging bool
}
// Middleware is an HTTP middleware function for the asset server.
// type Middleware func(next http.Handler) http.Handler
type Middleware func(next http.Handler) http.Handler
// ChainMiddleware composes multiple Middleware values into a single Middleware.
// chain := application.ChainMiddleware(authMiddleware, loggingMiddleware)
func ChainMiddleware(middleware ...Middleware) Middleware {
return func(handler http.Handler) http.Handler {
for index := len(middleware) - 1; index >= 0; index-- {
handler = middleware[index](handler)
}
return handler
}
}
// PanicDetails carries information about an unhandled panic.
// opts.PanicHandler = func(d *application.PanicDetails) { log(d.StackTrace) }
type PanicDetails struct {
StackTrace string
Error error
FullStackTrace string
}
// OriginInfo carries frame-origin metadata for raw message handling.
// opts.RawMessageHandler = func(w Window, msg string, o *application.OriginInfo) { ... }
type OriginInfo struct {
Origin string
TopOrigin string
IsMainFrame bool
}
// SingleInstanceOptions configures single-instance enforcement.
// opts.SingleInstance = &application.SingleInstanceOptions{UniqueID: "com.example.myapp"}
type SingleInstanceOptions struct {
// UniqueID identifies the application instance, e.g. "com.example.myapp".
UniqueID string
// OnSecondInstanceLaunch is called when a second instance attempts to start.
OnSecondInstanceLaunch func(data SecondInstanceData)
// AdditionalData passes custom data from the second instance to the first.
AdditionalData map[string]string
}
// SecondInstanceData describes a second-instance launch event.
type SecondInstanceData struct {
Args []string `json:"args"`
WorkingDir string `json:"workingDir"`
AdditionalData map[string]string `json:"additionalData,omitempty"`
}
// ActivationPolicy controls when a macOS application activates.
// opts.Mac.ActivationPolicy = application.ActivationPolicyAccessory
type ActivationPolicy int
const (
// ActivationPolicyRegular is used for applications with a main window.
ActivationPolicyRegular ActivationPolicy = iota
// ActivationPolicyAccessory is used for menu-bar or background applications.
ActivationPolicyAccessory
// ActivationPolicyProhibited prevents the application from activating.
ActivationPolicyProhibited
)
// MacOptions contains macOS-specific application options.
// opts.Mac = application.MacOptions{ActivationPolicy: application.ActivationPolicyRegular}
type MacOptions struct {
// ActivationPolicy controls how and when the application becomes active.
ActivationPolicy ActivationPolicy
// ApplicationShouldTerminateAfterLastWindowClosed quits the app when
// the last window closes, matching standard macOS behaviour.
ApplicationShouldTerminateAfterLastWindowClosed bool
}
// WindowsOptions contains Windows-specific application options.
// opts.Windows = application.WindowsOptions{WndClass: "MyAppWindow"}
type WindowsOptions struct {
// WndClass is the Windows window class name. Defaults to "WailsWebviewWindow".
WndClass string
// WndProcInterceptor intercepts all Win32 messages in the main message loop.
WndProcInterceptor func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (returnCode uintptr, shouldReturn bool)
// DisableQuitOnLastWindowClosed prevents auto-quit when the last window closes.
DisableQuitOnLastWindowClosed bool
// WebviewUserDataPath sets the WebView2 user-data directory.
// Defaults to %APPDATA%\[BinaryName.exe].
WebviewUserDataPath string
// WebviewBrowserPath sets the directory containing WebView2 executables.
// Defaults to the system-installed WebView2.
WebviewBrowserPath string
// EnabledFeatures lists WebView2 feature flags to enable.
EnabledFeatures []string
// DisabledFeatures lists WebView2 feature flags to disable.
DisabledFeatures []string
// AdditionalBrowserArgs are extra Chromium command-line arguments.
// Each argument must include the "--" prefix, e.g. "--remote-debugging-port=9222".
AdditionalBrowserArgs []string
}
// LinuxOptions contains Linux-specific application options.
// opts.Linux = application.LinuxOptions{ProgramName: "myapp"}
type LinuxOptions struct {
// DisableQuitOnLastWindowClosed prevents auto-quit when the last window closes.
DisableQuitOnLastWindowClosed bool
// ProgramName is passed to g_set_prgname() for window grouping in GTK.
ProgramName string
}
// IOSOptions contains iOS-specific application options.
// opts.IOS = application.IOSOptions{EnableInlineMediaPlayback: true}
type IOSOptions struct {
// DisableInputAccessoryView hides the Next/Previous/Done toolbar above the keyboard.
DisableInputAccessoryView bool
// DisableScroll disables WebView scrolling.
DisableScroll bool
// DisableBounce disables the iOS overscroll bounce effect.
DisableBounce bool
// DisableScrollIndicators hides scroll indicator bars.
DisableScrollIndicators bool
// EnableBackForwardNavigationGestures enables swipe navigation gestures.
EnableBackForwardNavigationGestures bool
// DisableLinkPreview disables long-press link preview (peek and pop).
DisableLinkPreview bool
// EnableInlineMediaPlayback allows video to play inline rather than full-screen.
EnableInlineMediaPlayback bool
// EnableAutoplayWithoutUserAction allows media to autoplay without a user gesture.
EnableAutoplayWithoutUserAction bool
// DisableInspectable disables the Safari remote web inspector.
DisableInspectable bool
// UserAgent overrides the WKWebView user agent string.
UserAgent string
// ApplicationNameForUserAgent appends a name to the user agent. Defaults to "wails.io".
ApplicationNameForUserAgent string
// AppBackgroundColourSet enables BackgroundColour for the main iOS window.
AppBackgroundColourSet bool
// BackgroundColour is applied to the iOS app window before any WebView is created.
BackgroundColour RGBA
// EnableNativeTabs enables a native UITabBar at the bottom of the screen.
EnableNativeTabs bool
// NativeTabsItems configures the labels and SF Symbol icons for the native UITabBar.
NativeTabsItems []NativeTabItem
}
// NativeTabItem describes a single tab in the iOS native UITabBar.
// item := application.NativeTabItem{Title: "Home", SystemImage: application.NativeTabIconHouse}
type NativeTabItem struct {
Title string `json:"Title"`
SystemImage NativeTabIcon `json:"SystemImage"`
}
// NativeTabIcon is an SF Symbols name used for a UITabBar icon.
// tab := application.NativeTabItem{SystemImage: application.NativeTabIconGear}
type NativeTabIcon string
const (
NativeTabIconNone NativeTabIcon = ""
NativeTabIconHouse NativeTabIcon = "house"
NativeTabIconGear NativeTabIcon = "gear"
NativeTabIconStar NativeTabIcon = "star"
NativeTabIconPerson NativeTabIcon = "person"
NativeTabIconBell NativeTabIcon = "bell"
NativeTabIconMagnify NativeTabIcon = "magnifyingglass"
NativeTabIconList NativeTabIcon = "list.bullet"
NativeTabIconFolder NativeTabIcon = "folder"
)
// AndroidOptions contains Android-specific application options.
// opts.Android = application.AndroidOptions{EnableZoom: true}
type AndroidOptions struct {
// DisableScroll disables WebView scrolling.
DisableScroll bool
// DisableOverscroll disables the overscroll bounce effect.
DisableOverscroll bool
// EnableZoom enables pinch-to-zoom in the WebView.
EnableZoom bool
// UserAgent overrides the WebView user agent string.
UserAgent string
// BackgroundColour sets the WebView background colour.
BackgroundColour RGBA
// DisableHardwareAcceleration disables GPU acceleration for the WebView.
DisableHardwareAcceleration bool
}

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