Compare commits
28 commits
dev
...
feat/wails
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd0e7b025 | ||
|
|
f1f7fcfab9 | ||
|
|
22668f7faa | ||
|
|
18a455b460 | ||
|
|
84ec201a05 | ||
|
|
bb5122580a | ||
|
|
484f7138ee | ||
|
|
de6c6c3405 | ||
|
|
dcd2c9bcb8 | ||
|
|
31f6e0b67e | ||
|
|
0f6400052c | ||
|
|
ab0722d19e | ||
|
|
ee34ed5b26 | ||
|
|
fc94b4d2a5 | ||
|
|
82f427dc12 | ||
|
|
13a493f57d | ||
| 2001e7ed87 | |||
|
|
089bdacadb | ||
|
|
bff6c5b7d3 | ||
|
|
02ea237a1d | ||
|
|
0e624bfb7b | ||
|
|
35f8f5ec51 | ||
|
|
2b6e6cee0b | ||
|
|
da22bedbc6 | ||
|
|
bd58099c17 | ||
| 9177ea99c9 | |||
|
|
a36392ec08 | ||
|
|
b559562dd9 |
123 changed files with 11828 additions and 2745 deletions
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "internal/wails3"]
|
||||||
|
path = internal/wails3
|
||||||
|
url = https://github.com/wailsapp/wails.git
|
||||||
|
branch = v3-alpha
|
||||||
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -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.
|
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 |
|
| Package | Responsibility |
|
||||||
|------|---------------|
|
|---------|---------------|
|
||||||
| `display.go` | Service struct, lifecycle (`Startup`), window CRUD, screen queries, tiling/snapping/layout, workflow presets |
|
| `display` | Orchestrator service — bridges sub-service IPC to WebSocket events, menu/tray setup, config persistence |
|
||||||
| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` |
|
| `window` | Window lifecycle, `Manager`, `StateManager` (position persistence), `LayoutManager` (named arrangements), tiling/snapping |
|
||||||
| `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
|
| `menu` | Application menu construction via platform abstraction |
|
||||||
| `layout.go` | `LayoutManager` — save/restore named window arrangements |
|
| `systray` | System tray icon, tooltip, menu via platform abstraction |
|
||||||
| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events |
|
| `dialog` | File open/save, message, confirm, and prompt dialogs |
|
||||||
| `interfaces.go` | Abstract interfaces + Wails adapter implementations |
|
| `clipboard` | Clipboard read/write/clear |
|
||||||
| `actions.go` | `ActionOpenWindow` IPC message type |
|
| `notification` | System notifications with permission management |
|
||||||
| `menu.go` | Application menu construction |
|
| `screen` | Screen/monitor queries (list, primary, at-point, work areas) |
|
||||||
| `tray.go` | System tray setup |
|
| `environment` | Theme detection (dark/light) and OS environment info |
|
||||||
| `dialog.go` | File/directory dialogs |
|
| `keybinding` | Global keyboard shortcut registration |
|
||||||
| `clipboard.go` | Clipboard read/write |
|
| `contextmenu` | Named context menu registration and lifecycle |
|
||||||
| `notification.go` | System notifications |
|
| `browser` | Open URLs and files in the default browser |
|
||||||
| `theme.go` | Dark/light mode detection |
|
| `dock` | macOS dock icon visibility and badge |
|
||||||
| `mocks_test.go` | Mock implementations of all interfaces for testing |
|
| `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
|
### Patterns used throughout
|
||||||
|
|
||||||
- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions`
|
- **Platform abstraction**: Each sub-service defines a `Platform` interface and `PlatformWindow`/`PlatformTray`/etc. types; tests inject mocks
|
||||||
- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
|
- **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)
|
- **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
|
## Testing
|
||||||
|
|
||||||
- Framework: `testify` (assert + require)
|
- Framework: `testify` (assert + require)
|
||||||
- Pattern: `newServiceWithMockApp(t)` creates a `Service` with mock Wails app — no real window system needed
|
- Each sub-package has its own `*_test.go` with mock platform implementations
|
||||||
- `newTestCore(t)` creates a real `core.Core` instance for integration-style tests
|
- `pkg/window`: `NewManagerWithDir` / `NewStateManagerWithDir` / `NewLayoutManagerWithDir` accept custom config dirs for isolated tests
|
||||||
- Some tests use `defer func() { recover() }()` to handle nil panics from mock methods that return nil pointers (e.g., `Dialog().Info()`)
|
- `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
|
## CI/CD
|
||||||
|
|
||||||
|
|
@ -82,9 +87,13 @@ Both use reusable workflows from `core/go-devops`.
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `forge.lthn.ai/core/go` — Core framework with service container and DI
|
- `forge.lthn.ai/core/go` — Core framework with service container and DI
|
||||||
|
- `forge.lthn.ai/core/go-log` — Structured errors (`coreerr.E()`)
|
||||||
|
- `forge.lthn.ai/core/go-io` — Filesystem abstraction (`coreio.Local`)
|
||||||
|
- `forge.lthn.ai/core/config` — Configuration file management
|
||||||
- `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74)
|
- `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74)
|
||||||
- `github.com/gorilla/websocket` — WebSocket for real-time events
|
- `github.com/gorilla/websocket` — WebSocket for real-time events
|
||||||
- `github.com/stretchr/testify` — Test assertions
|
- `github.com/stretchr/testify` — Test assertions
|
||||||
|
- `github.com/modelcontextprotocol/go-sdk` — MCP tool registration
|
||||||
|
|
||||||
## Repository migration note
|
## Repository migration note
|
||||||
|
|
||||||
|
|
|
||||||
440
docs/RFC-CORE-008-AGENT-EXPERIENCE.md
Normal file
440
docs/RFC-CORE-008-AGENT-EXPERIENCE.md
Normal 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
|
||||||
3
docs/ref/wails-v3/go.mod
Normal file
3
docs/ref/wails-v3/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/wailsapp/wails/v3
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
9
docs/ref/wails-v3/src/application/assets/index.html
Normal file
9
docs/ref/wails-v3/src/application/assets/index.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Wails Assets Placeholder</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
go.mod
49
go.mod
|
|
@ -3,75 +3,44 @@ module forge.lthn.ai/core/gui
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/config v0.1.8
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
forge.lthn.ai/core/go v0.3.3
|
|
||||||
forge.lthn.ai/core/go-webview v0.1.7
|
forge.lthn.ai/core/go-webview v0.1.7
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/leaanthony/u v1.1.1
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/wailsapp/wails/v3 => ./stubs/wails
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dappco.re/go/core/io v0.2.0 // indirect
|
||||||
|
dappco.re/go/core/log v0.1.0 // indirect
|
||||||
|
forge.lthn.ai/core/config v0.1.8 // indirect
|
||||||
|
forge.lthn.ai/core/go v0.3.3 // indirect
|
||||||
forge.lthn.ai/core/go-io v0.1.7 // indirect
|
forge.lthn.ai/core/go-io v0.1.7 // indirect
|
||||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
|
||||||
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
|
||||||
github.com/go-git/go-billy/v5 v5.8.0 // indirect
|
|
||||||
github.com/go-git/go-git/v5 v5.17.0 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
|
||||||
github.com/lmittmann/tint v1.1.3 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/samber/lo v1.53.0 // indirect
|
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/spf13/viper v1.21.0 // indirect
|
github.com/spf13/viper v1.21.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
137
go.sum
137
go.sum
|
|
@ -1,5 +1,9 @@
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
|
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
|
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||||
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
||||||
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
||||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||||
|
|
@ -10,130 +14,39 @@ forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
|
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
|
||||||
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
|
||||||
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
|
||||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
|
||||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
|
||||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
|
||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
|
||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
|
||||||
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
|
|
||||||
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
|
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
|
||||||
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
|
|
||||||
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
|
||||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
|
||||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
|
||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
|
||||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
|
||||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
|
||||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
|
||||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
|
||||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
|
@ -142,60 +55,24 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
|
||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
|
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
|
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
|
||||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
1
internal/wails3
Submodule
1
internal/wails3
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit bb4fbf95744fafe5acf84e143a419bfffc2159e6
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
// pkg/browser/register.go
|
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the browser service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(browser.Register(wailsBrowser))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,34 @@
|
||||||
// pkg/browser/service.go
|
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the browser service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service that delegates browser/file-open operations
|
|
||||||
// to the platform. It is stateless — no queries, no actions.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
s.Core().Action("browser.openURL", func(_ context.Context, opts core.Options) core.Result {
|
||||||
s.Core().RegisterTask(s.handleTask)
|
if err := s.platform.OpenURL(opts.String("url")); err != nil {
|
||||||
return nil
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("browser.openFile", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
if err := s.platform.OpenFile(opts.String("path")); err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Task Handlers ---
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskOpenURL:
|
|
||||||
return nil, true, s.platform.OpenURL(t.URL)
|
|
||||||
case TaskOpenFile:
|
|
||||||
return nil, true, s.platform.OpenFile(t.Path)
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
core "dappco.re/go/core"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -30,12 +29,11 @@ func (m *mockPlatform) OpenFile(path string) error {
|
||||||
|
|
||||||
func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mp)),
|
core.WithService(Register(mp)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "browser")
|
svc := core.MustServiceFor[*Service](c, "browser")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
@ -51,42 +49,48 @@ func TestTaskOpenURL_Good(t *testing.T) {
|
||||||
mp := &mockPlatform{}
|
mp := &mockPlatform{}
|
||||||
_, c := newTestBrowserService(t, mp)
|
_, c := newTestBrowserService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
|
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions(
|
||||||
require.NoError(t, err)
|
core.Option{Key: "url", Value: "https://example.com"},
|
||||||
assert.True(t, handled)
|
))
|
||||||
|
require.True(t, r.OK)
|
||||||
assert.Equal(t, "https://example.com", mp.lastURL)
|
assert.Equal(t, "https://example.com", mp.lastURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenURL_Bad_PlatformError(t *testing.T) {
|
func TestTaskOpenURL_Bad_PlatformError(t *testing.T) {
|
||||||
mp := &mockPlatform{urlErr: errors.New("browser not found")}
|
mp := &mockPlatform{urlErr: core.NewError("browser not found")}
|
||||||
_, c := newTestBrowserService(t, mp)
|
_, c := newTestBrowserService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
|
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions(
|
||||||
assert.True(t, handled)
|
core.Option{Key: "url", Value: "https://example.com"},
|
||||||
assert.Error(t, err)
|
))
|
||||||
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenFile_Good(t *testing.T) {
|
func TestTaskOpenFile_Good(t *testing.T) {
|
||||||
mp := &mockPlatform{}
|
mp := &mockPlatform{}
|
||||||
_, c := newTestBrowserService(t, mp)
|
_, c := newTestBrowserService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskOpenFile{Path: "/tmp/readme.txt"})
|
r := c.Action("browser.openFile").Run(context.Background(), core.NewOptions(
|
||||||
require.NoError(t, err)
|
core.Option{Key: "path", Value: "/tmp/readme.txt"},
|
||||||
assert.True(t, handled)
|
))
|
||||||
|
require.True(t, r.OK)
|
||||||
assert.Equal(t, "/tmp/readme.txt", mp.lastPath)
|
assert.Equal(t, "/tmp/readme.txt", mp.lastPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenFile_Bad_PlatformError(t *testing.T) {
|
func TestTaskOpenFile_Bad_PlatformError(t *testing.T) {
|
||||||
mp := &mockPlatform{fileErr: errors.New("file not found")}
|
mp := &mockPlatform{fileErr: core.NewError("file not found")}
|
||||||
_, c := newTestBrowserService(t, mp)
|
_, c := newTestBrowserService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskOpenFile{Path: "/nonexistent"})
|
r := c.Action("browser.openFile").Run(context.Background(), core.NewOptions(
|
||||||
assert.True(t, handled)
|
core.Option{Key: "path", Value: "/nonexistent"},
|
||||||
assert.Error(t, err)
|
))
|
||||||
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenURL_Bad_NoService(t *testing.T) {
|
func TestTaskOpenURL_Bad_NoService(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.PERFORM(TaskOpenURL{URL: "https://example.com"})
|
r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions(
|
||||||
assert.False(t, handled)
|
core.Option{Key: "url", Value: "https://example.com"},
|
||||||
|
))
|
||||||
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,57 +4,50 @@ package clipboard
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the clipboard service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing clipboard operations via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the clipboard service to a Core instance.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
// c.WithService(clipboard.Register(wailsClipboard))
|
||||||
return func(c *core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return &Service{
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("clipboard.setText", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
success := s.platform.SetText(opts.String("text"))
|
||||||
|
return core.Result{Value: success, OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("clipboard.clear", func(_ context.Context, _ core.Options) core.Result {
|
||||||
|
success := s.platform.SetText("")
|
||||||
|
return core.Result{Value: success, OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryText:
|
case QueryText:
|
||||||
text, ok := s.platform.Text()
|
text, ok := s.platform.Text()
|
||||||
return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil
|
return core.Result{Value: ClipboardContent{Text: text, HasContent: ok && text != ""}, OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskSetText:
|
|
||||||
return s.platform.SetText(t.Text), true, nil
|
|
||||||
case TaskClear:
|
|
||||||
return s.platform.SetText(""), true, nil
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -24,12 +24,11 @@ func (m *mockPlatform) SetText(text string) bool {
|
||||||
|
|
||||||
func newTestService(t *testing.T) (*Service, *core.Core) {
|
func newTestService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(&mockPlatform{text: "hello", ok: true})),
|
core.WithService(Register(&mockPlatform{text: "hello", ok: true})),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "clipboard")
|
svc := core.MustServiceFor[*Service](c, "clipboard")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
@ -41,41 +40,40 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryText_Good(t *testing.T) {
|
func TestQueryText_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryText{})
|
r := c.QUERY(QueryText{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
content := r.Value.(ClipboardContent)
|
||||||
content := result.(ClipboardContent)
|
|
||||||
assert.Equal(t, "hello", content.Text)
|
assert.Equal(t, "hello", content.Text)
|
||||||
assert.True(t, content.HasContent)
|
assert.True(t, content.HasContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryText_Bad(t *testing.T) {
|
func TestQueryText_Bad(t *testing.T) {
|
||||||
// No clipboard service registered
|
// No clipboard service registered
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.QUERY(QueryText{})
|
r := c.QUERY(QueryText{})
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetText_Good(t *testing.T) {
|
func TestTaskSetText_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskSetText{Text: "world"})
|
r := c.Action("clipboard.setText").Run(context.Background(), core.NewOptions(
|
||||||
require.NoError(t, err)
|
core.Option{Key: "text", Value: "world"},
|
||||||
assert.True(t, handled)
|
))
|
||||||
assert.Equal(t, true, result)
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, true, r.Value)
|
||||||
|
|
||||||
// Verify via query
|
// Verify via query
|
||||||
r, _, _ := c.QUERY(QueryText{})
|
qr := c.QUERY(QueryText{})
|
||||||
assert.Equal(t, "world", r.(ClipboardContent).Text)
|
assert.Equal(t, "world", qr.Value.(ClipboardContent).Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskClear_Good(t *testing.T) {
|
func TestTaskClear_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
_, handled, err := c.PERFORM(TaskClear{})
|
r := c.Action("clipboard.clear").Run(context.Background(), core.NewOptions())
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify empty
|
// Verify empty
|
||||||
r, _, _ := c.QUERY(QueryText{})
|
qr := c.QUERY(QueryText{})
|
||||||
assert.Equal(t, "", r.(ClipboardContent).Text)
|
assert.Equal(t, "", qr.Value.(ClipboardContent).Text)
|
||||||
assert.False(t, r.(ClipboardContent).HasContent)
|
assert.False(t, qr.Value.(ClipboardContent).HasContent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
// pkg/contextmenu/messages.go
|
|
||||||
package contextmenu
|
package contextmenu
|
||||||
|
|
||||||
import "errors"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// ErrMenuNotFound is returned when attempting to remove or get a menu
|
var ErrorMenuNotFound = core.E("contextmenu", "menu not found", nil)
|
||||||
// that does not exist in the registry.
|
|
||||||
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
|
|
||||||
|
|
||||||
// --- Queries ---
|
// QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found)
|
||||||
|
|
||||||
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
|
|
||||||
type QueryGet struct {
|
type QueryGet struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
@ -17,26 +12,34 @@ type QueryGet struct {
|
||||||
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
||||||
type QueryList struct{}
|
type QueryList struct{}
|
||||||
|
|
||||||
// --- Tasks ---
|
// QueryGetAll returns all registered context menus. Equivalent to QueryList.
|
||||||
|
// Result: map[string]ContextMenuDef
|
||||||
|
type QueryGetAll struct{}
|
||||||
|
|
||||||
// TaskAdd registers a context menu. Result: nil
|
// TaskAdd registers a named context menu. Replaces if already exists.
|
||||||
// If a menu with the same name already exists it is replaced (remove + re-add).
|
|
||||||
type TaskAdd struct {
|
type TaskAdd struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Menu ContextMenuDef `json:"menu"`
|
Menu ContextMenuDef `json:"menu"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskRemove unregisters a context menu. Result: nil
|
// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing.
|
||||||
// Returns ErrMenuNotFound if the menu does not exist.
|
|
||||||
type TaskRemove struct {
|
type TaskRemove struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// TaskUpdate replaces an existing context menu's definition. Error: ErrorMenuNotFound if missing.
|
||||||
|
type TaskUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Menu ContextMenuDef `json:"menu"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskDestroy removes a context menu and releases all associated resources.
|
||||||
|
// Error: ErrorMenuNotFound if missing.
|
||||||
|
type TaskDestroy struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
// ActionItemClicked is broadcast when a context menu item is clicked.
|
// ActionItemClicked is broadcast when a context menu item is clicked.
|
||||||
// The Data field is populated from the CSS --custom-contextmenu-data property
|
|
||||||
// on the element that triggered the context menu.
|
|
||||||
type ActionItemClicked struct {
|
type ActionItemClicked struct {
|
||||||
MenuName string `json:"menuName"`
|
MenuName string `json:"menuName"`
|
||||||
ActionID string `json:"actionId"`
|
ActionID string `json:"actionId"`
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
// pkg/contextmenu/register.go
|
|
||||||
package contextmenu
|
package contextmenu
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the context menu service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(contextmenu.Register(wailsContextMenu))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
menus: make(map[string]ContextMenuDef),
|
registeredMenus: make(map[string]ContextMenuDef),
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,84 +3,89 @@ package contextmenu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the context menu service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing context menus via IPC.
|
|
||||||
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
|
|
||||||
// and delegates platform-level registration to the Platform interface.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
menus map[string]ContextMenuDef
|
registeredMenus map[string]ContextMenuDef
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("contextmenu.add", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
t, _ := opts.Get("task").Value.(TaskAdd)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskAdd(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("contextmenu.remove", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskRemove)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskRemove(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("contextmenu.update", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskUpdate)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskUpdate(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("contextmenu.destroy", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskDestroy)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskDestroy(t))
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) OnShutdown(_ context.Context) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
// Destroy all registered menus on shutdown to release platform resources
|
||||||
return nil
|
for name := range s.registeredMenus {
|
||||||
|
_ = s.platform.Remove(name)
|
||||||
|
}
|
||||||
|
s.registeredMenus = make(map[string]ContextMenuDef)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Query Handlers ---
|
// --- Query Handlers ---
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q := q.(type) {
|
switch q := q.(type) {
|
||||||
case QueryGet:
|
case QueryGet:
|
||||||
return s.queryGet(q), true, nil
|
return core.Result{Value: s.queryGet(q), OK: true}
|
||||||
case QueryList:
|
case QueryList:
|
||||||
return s.queryList(), true, nil
|
return core.Result{Value: s.queryList(), OK: true}
|
||||||
|
case QueryGetAll:
|
||||||
|
return core.Result{Value: s.queryList(), OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryGet returns a single menu definition by name, or nil if not found.
|
|
||||||
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
||||||
menu, ok := s.menus[q.Name]
|
menu, ok := s.registeredMenus[q.Name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &menu
|
return &menu
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryList returns a copy of all registered menus.
|
|
||||||
func (s *Service) queryList() map[string]ContextMenuDef {
|
func (s *Service) queryList() map[string]ContextMenuDef {
|
||||||
result := make(map[string]ContextMenuDef, len(s.menus))
|
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
|
||||||
for k, v := range s.menus {
|
for k, v := range s.registeredMenus {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Task Handlers ---
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskAdd:
|
|
||||||
return nil, true, s.taskAdd(t)
|
|
||||||
case TaskRemove:
|
|
||||||
return nil, true, s.taskRemove(t)
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) taskAdd(t TaskAdd) error {
|
func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
// If menu already exists, remove it first (replace semantics)
|
// If menu already exists, remove it first (replace semantics)
|
||||||
if _, exists := s.menus[t.Name]; exists {
|
if _, exists := s.registeredMenus[t.Name]; exists {
|
||||||
_ = s.platform.Remove(t.Name)
|
_ = s.platform.Remove(t.Name)
|
||||||
delete(s.menus, t.Name)
|
delete(s.registeredMenus, t.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register on platform with a callback that broadcasts ActionItemClicked
|
// Register on platform with a callback that broadcasts ActionItemClicked
|
||||||
|
|
@ -92,23 +97,61 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskRemove(t TaskRemove) error {
|
func (s *Service) taskRemove(t TaskRemove) error {
|
||||||
if _, exists := s.menus[t.Name]; !exists {
|
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||||
return ErrMenuNotFound
|
return ErrorMenuNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.platform.Remove(t.Name)
|
err := s.platform.Remove(t.Name)
|
||||||
if err != nil {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskUpdate(t TaskUpdate) error {
|
||||||
|
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||||
|
return ErrorMenuNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-register with updated definition — remove then add
|
||||||
|
if err := s.platform.Remove(t.Name); err != nil {
|
||||||
|
return coreerr.E("contextmenu.taskUpdate", "platform remove failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.platform.Add(t.Name, t.Menu, func(menuName, actionID, data string) {
|
||||||
|
_ = s.Core().ACTION(ActionItemClicked{
|
||||||
|
MenuName: menuName,
|
||||||
|
ActionID: actionID,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("contextmenu.taskUpdate", "platform add failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.registeredMenus[t.Name] = t.Menu
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskDestroy(t TaskDestroy) error {
|
||||||
|
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||||
|
return ErrorMenuNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.platform.Remove(t.Name); err != nil {
|
||||||
|
return coreerr.E("contextmenu.taskDestroy", "platform remove failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.registeredMenus, t.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -83,16 +83,22 @@ func (m *mockPlatform) simulateClick(menuName, actionID, data string) {
|
||||||
|
|
||||||
func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mp)),
|
core.WithService(Register(mp)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "contextmenu")
|
svc := core.MustServiceFor[*Service](c, "contextmenu")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskRun runs a named action with a task struct and returns the result.
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
svc, _ := newTestContextMenuService(t, mp)
|
svc, _ := newTestContextMenuService(t, mp)
|
||||||
|
|
@ -104,7 +110,7 @@ func TestTaskAdd_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskAdd{
|
r := taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "file-menu",
|
Name: "file-menu",
|
||||||
Menu: ContextMenuDef{
|
Menu: ContextMenuDef{
|
||||||
Name: "file-menu",
|
Name: "file-menu",
|
||||||
|
|
@ -114,8 +120,7 @@ func TestTaskAdd_Good(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify menu registered on platform
|
// Verify menu registered on platform
|
||||||
_, ok := mp.Get("file-menu")
|
_, ok := mp.Get("file-menu")
|
||||||
|
|
@ -127,22 +132,22 @@ func TestTaskAdd_Good_ReplaceExisting(t *testing.T) {
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
// Add initial menu
|
// Add initial menu
|
||||||
_, _, _ = c.PERFORM(TaskAdd{
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "ctx",
|
Name: "ctx",
|
||||||
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}},
|
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Replace with new menu
|
// Replace with new menu
|
||||||
_, handled, err := c.PERFORM(TaskAdd{
|
r := taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "ctx",
|
Name: "ctx",
|
||||||
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}},
|
Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify registry has new menu
|
// Verify registry has new menu
|
||||||
result, _, _ := c.QUERY(QueryGet{Name: "ctx"})
|
qr := c.QUERY(QueryGet{Name: "ctx"})
|
||||||
def := result.(*ContextMenuDef)
|
require.True(t, qr.OK)
|
||||||
|
def := qr.Value.(*ContextMenuDef)
|
||||||
require.Len(t, def.Items, 1)
|
require.Len(t, def.Items, 1)
|
||||||
assert.Equal(t, "B", def.Items[0].Label)
|
assert.Equal(t, "B", def.Items[0].Label)
|
||||||
}
|
}
|
||||||
|
|
@ -152,33 +157,33 @@ func TestTaskRemove_Good(t *testing.T) {
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
// Add then remove
|
// Add then remove
|
||||||
_, _, _ = c.PERFORM(TaskAdd{
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Menu: ContextMenuDef{Name: "test"},
|
Menu: ContextMenuDef{Name: "test"},
|
||||||
})
|
})
|
||||||
_, handled, err := c.PERFORM(TaskRemove{Name: "test"})
|
r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify removed from registry
|
// Verify removed from registry
|
||||||
result, _, _ := c.QUERY(QueryGet{Name: "test"})
|
qr := c.QUERY(QueryGet{Name: "test"})
|
||||||
assert.Nil(t, result)
|
assert.Nil(t, qr.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "nonexistent"})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.ErrorIs(t, err, ErrMenuNotFound)
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryGet_Good(t *testing.T) {
|
func TestQueryGet_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "my-menu",
|
Name: "my-menu",
|
||||||
Menu: ContextMenuDef{
|
Menu: ContextMenuDef{
|
||||||
Name: "my-menu",
|
Name: "my-menu",
|
||||||
|
|
@ -186,10 +191,9 @@ func TestQueryGet_Good(t *testing.T) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryGet{Name: "my-menu"})
|
r := c.QUERY(QueryGet{Name: "my-menu"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
def := r.Value.(*ContextMenuDef)
|
||||||
def := result.(*ContextMenuDef)
|
|
||||||
assert.Equal(t, "my-menu", def.Name)
|
assert.Equal(t, "my-menu", def.Name)
|
||||||
assert.Len(t, def.Items, 1)
|
assert.Len(t, def.Items, 1)
|
||||||
}
|
}
|
||||||
|
|
@ -198,23 +202,21 @@ func TestQueryGet_Good_NotFound(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryGet{Name: "missing"})
|
r := c.QUERY(QueryGet{Name: "missing"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Nil(t, r.Value)
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryList_Good(t *testing.T) {
|
func TestQueryList_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}})
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}})
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}})
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
list := r.Value.(map[string]ContextMenuDef)
|
||||||
list := result.(map[string]ContextMenuDef)
|
|
||||||
assert.Len(t, list, 2)
|
assert.Len(t, list, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,10 +224,9 @@ func TestQueryList_Good_Empty(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
list := r.Value.(map[string]ContextMenuDef)
|
||||||
list := result.(map[string]ContextMenuDef)
|
|
||||||
assert.Len(t, list, 0)
|
assert.Len(t, list, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,16 +237,16 @@ func TestTaskAdd_Good_ClickBroadcast(t *testing.T) {
|
||||||
// Capture broadcast actions
|
// Capture broadcast actions
|
||||||
var clicked ActionItemClicked
|
var clicked ActionItemClicked
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionItemClicked); ok {
|
if a, ok := msg.(ActionItemClicked); ok {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
clicked = a
|
clicked = a
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "file-menu",
|
Name: "file-menu",
|
||||||
Menu: ContextMenuDef{
|
Menu: ContextMenuDef{
|
||||||
Name: "file-menu",
|
Name: "file-menu",
|
||||||
|
|
@ -269,7 +270,7 @@ func TestTaskAdd_Good_SubmenuItems(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestContextMenuService(t, mp)
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskAdd{
|
r := taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
Name: "nested",
|
Name: "nested",
|
||||||
Menu: ContextMenuDef{
|
Menu: ContextMenuDef{
|
||||||
Name: "nested",
|
Name: "nested",
|
||||||
|
|
@ -283,17 +284,197 @@ func TestTaskAdd_Good_SubmenuItems(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
result, _, _ := c.QUERY(QueryGet{Name: "nested"})
|
qr := c.QUERY(QueryGet{Name: "nested"})
|
||||||
def := result.(*ContextMenuDef)
|
def := qr.Value.(*ContextMenuDef)
|
||||||
assert.Len(t, def.Items, 3)
|
assert.Len(t, def.Items, 3)
|
||||||
assert.Len(t, def.Items[0].Items, 2) // submenu children
|
assert.Len(t, def.Items[0].Items, 2) // submenu children
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryList_Bad_NoService(t *testing.T) {
|
func TestQueryList_Bad_NoService(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskUpdate ---
|
||||||
|
|
||||||
|
func TestTaskUpdate_Good(t *testing.T) {
|
||||||
|
// Update replaces items on an existing menu
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
|
Name: "edit-menu",
|
||||||
|
Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{{Label: "Cut", ActionID: "cut"}}},
|
||||||
|
})
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.update", TaskUpdate{
|
||||||
|
Name: "edit-menu",
|
||||||
|
Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{
|
||||||
|
{Label: "Cut", ActionID: "cut"},
|
||||||
|
{Label: "Copy", ActionID: "copy"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
qr := c.QUERY(QueryGet{Name: "edit-menu"})
|
||||||
|
def := qr.Value.(*ContextMenuDef)
|
||||||
|
assert.Len(t, def.Items, 2)
|
||||||
|
assert.Equal(t, "Copy", def.Items[1].Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskUpdate_Bad_NotFound(t *testing.T) {
|
||||||
|
// Update on a non-existent menu returns ErrorMenuNotFound
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.update", TaskUpdate{
|
||||||
|
Name: "ghost",
|
||||||
|
Menu: ContextMenuDef{Name: "ghost"},
|
||||||
|
})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskUpdate_Ugly_PlatformRemoveError(t *testing.T) {
|
||||||
|
// Platform Remove fails mid-update — error is propagated
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{
|
||||||
|
Name: "tricky",
|
||||||
|
Menu: ContextMenuDef{Name: "tricky"},
|
||||||
|
})
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.removeErr = ErrorMenuNotFound // reuse sentinel as a platform-level error
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.update", TaskUpdate{
|
||||||
|
Name: "tricky",
|
||||||
|
Menu: ContextMenuDef{Name: "tricky", Items: []MenuItemDef{{Label: "X", ActionID: "x"}}},
|
||||||
|
})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskDestroy ---
|
||||||
|
|
||||||
|
func TestTaskDestroy_Good(t *testing.T) {
|
||||||
|
// Destroy removes the menu and releases platform resources
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "doomed", Menu: ContextMenuDef{Name: "doomed"}})
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "doomed"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
qr := c.QUERY(QueryGet{Name: "doomed"})
|
||||||
|
assert.Nil(t, qr.Value)
|
||||||
|
|
||||||
|
_, ok := mp.Get("doomed")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDestroy_Bad_NotFound(t *testing.T) {
|
||||||
|
// Destroy on a non-existent menu returns ErrorMenuNotFound
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDestroy_Ugly_PlatformError(t *testing.T) {
|
||||||
|
// Platform Remove fails — error is propagated but service remains consistent
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "frail", Menu: ContextMenuDef{Name: "frail"}})
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.removeErr = ErrorMenuNotFound
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "frail"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QueryGetAll ---
|
||||||
|
|
||||||
|
func TestQueryGetAll_Good(t *testing.T) {
|
||||||
|
// QueryGetAll returns all registered menus (equivalent to QueryList)
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "x", Menu: ContextMenuDef{Name: "x"}})
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "y", Menu: ContextMenuDef{Name: "y"}})
|
||||||
|
|
||||||
|
r := c.QUERY(QueryGetAll{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
all := r.Value.(map[string]ContextMenuDef)
|
||||||
|
assert.Len(t, all, 2)
|
||||||
|
assert.Contains(t, all, "x")
|
||||||
|
assert.Contains(t, all, "y")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryGetAll_Bad_Empty(t *testing.T) {
|
||||||
|
// QueryGetAll on an empty registry returns an empty map
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryGetAll{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
all := r.Value.(map[string]ContextMenuDef)
|
||||||
|
assert.Len(t, all, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryGetAll_Ugly_NoService(t *testing.T) {
|
||||||
|
// No contextmenu service — query is unhandled
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.QUERY(QueryGetAll{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OnShutdown ---
|
||||||
|
|
||||||
|
func TestOnShutdown_Good_CleansUpMenus(t *testing.T) {
|
||||||
|
// OnShutdown removes all registered menus from the platform
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "alpha", Menu: ContextMenuDef{Name: "alpha"}})
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "beta", Menu: ContextMenuDef{Name: "beta"}})
|
||||||
|
|
||||||
|
require.True(t, c.ServiceShutdown(t.Context()).OK)
|
||||||
|
|
||||||
|
assert.Len(t, mp.menus, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnShutdown_Bad_NothingRegistered(t *testing.T) {
|
||||||
|
// OnShutdown with no menus — no-op, no error
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
assert.True(t, c.ServiceShutdown(t.Context()).OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnShutdown_Ugly_PlatformRemoveErrors(t *testing.T) {
|
||||||
|
// Platform Remove errors during shutdown are silently swallowed
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestContextMenuService(t, mp)
|
||||||
|
|
||||||
|
_ = taskRun(c, "contextmenu.add", TaskAdd{Name: "stubborn", Menu: ContextMenuDef{Name: "stubborn"}})
|
||||||
|
|
||||||
|
mp.mu.Lock()
|
||||||
|
mp.removeErr = ErrorMenuNotFound
|
||||||
|
mp.mu.Unlock()
|
||||||
|
|
||||||
|
// Shutdown must not return an error even if platform Remove fails
|
||||||
|
assert.True(t, c.ServiceShutdown(t.Context()).OK)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,75 @@
|
||||||
// pkg/dialog/messages.go
|
|
||||||
package dialog
|
package dialog
|
||||||
|
|
||||||
// TaskOpenFile shows an open file dialog. Result: []string (paths)
|
// TaskOpenFile presents an open-file dialog with the given options.
|
||||||
type TaskOpenFile struct{ Opts OpenFileOptions }
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{Title: "Pick file"}})
|
||||||
|
// paths := result.([]string)
|
||||||
|
type TaskOpenFile struct{ Options OpenFileOptions }
|
||||||
|
|
||||||
// TaskSaveFile shows a save file dialog. Result: string (path)
|
// TaskOpenFileWithOptions presents an open-file dialog pre-configured from an options struct.
|
||||||
type TaskSaveFile struct{ Opts SaveFileOptions }
|
// Equivalent to TaskOpenFile but mirrors the stub DialogManager.OpenFileWithOptions API.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskOpenFileWithOptions{Options: &dialog.OpenFileOptions{Title: "Select log", AllowMultiple: true}})
|
||||||
|
type TaskOpenFileWithOptions struct{ Options *OpenFileOptions }
|
||||||
|
|
||||||
// TaskOpenDirectory shows a directory picker. Result: string (path)
|
// TaskSaveFile presents a save-file dialog with the given options.
|
||||||
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{Filename: "report.csv"}})
|
||||||
|
// path := result.(string)
|
||||||
|
type TaskSaveFile struct{ Options SaveFileOptions }
|
||||||
|
|
||||||
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
|
// TaskSaveFileWithOptions presents a save-file dialog pre-configured from an options struct.
|
||||||
type TaskMessageDialog struct{ Opts MessageDialogOptions }
|
// Equivalent to TaskSaveFile but mirrors the stub DialogManager.SaveFileWithOptions API.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskSaveFileWithOptions{Options: &dialog.SaveFileOptions{Title: "Export data"}})
|
||||||
|
type TaskSaveFileWithOptions struct{ Options *SaveFileOptions }
|
||||||
|
|
||||||
|
// TaskOpenDirectory presents a directory picker dialog.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{Title: "Choose folder"}})
|
||||||
|
// path := result.(string)
|
||||||
|
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
|
||||||
|
|
||||||
|
// TaskMessageDialog presents a message dialog of the given type.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{Type: dialog.DialogQuestion, Title: "Confirm", Message: "Delete?", Buttons: []string{"Yes", "No"}}})
|
||||||
|
// clicked := result.(string)
|
||||||
|
type TaskMessageDialog struct{ Options MessageDialogOptions }
|
||||||
|
|
||||||
|
// TaskInfo presents an information message dialog.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskInfo{Title: "Done", Message: "File saved successfully."})
|
||||||
|
// clicked := result.(string)
|
||||||
|
type TaskInfo struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Buttons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskQuestion presents a question message dialog.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskQuestion{Title: "Confirm", Message: "Delete file?", Buttons: []string{"Yes", "No"}})
|
||||||
|
// if result.(string) == "Yes" { deleteFile() }
|
||||||
|
type TaskQuestion struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Buttons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskWarning presents a warning message dialog.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskWarning{Title: "Low disk", Message: "Disk space is critically low."})
|
||||||
|
type TaskWarning struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Buttons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskError presents an error message dialog.
|
||||||
|
//
|
||||||
|
// result, _, err := c.PERFORM(dialog.TaskError{Title: "Operation failed", Message: err.Error()})
|
||||||
|
type TaskError struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Buttons []string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package dialog
|
||||||
|
|
||||||
// Platform abstracts the native dialog backend.
|
// Platform abstracts the native dialog backend.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
OpenFile(opts OpenFileOptions) ([]string, error)
|
OpenFile(options OpenFileOptions) ([]string, error)
|
||||||
SaveFile(opts SaveFileOptions) (string, error)
|
SaveFile(options SaveFileOptions) (string, error)
|
||||||
OpenDirectory(opts OpenDirectoryOptions) (string, error)
|
OpenDirectory(options OpenDirectoryOptions) (string, error)
|
||||||
MessageDialog(opts MessageDialogOptions) (string, error)
|
MessageDialog(options MessageDialogOptions) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DialogType represents the type of message dialog.
|
// DialogType represents the type of message dialog.
|
||||||
|
|
@ -20,27 +20,38 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenFileOptions contains options for the open file dialog.
|
// OpenFileOptions contains options for the open file dialog.
|
||||||
|
//
|
||||||
|
// opts := OpenFileOptions{Title: "Select image", Filters: []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}}}
|
||||||
type OpenFileOptions struct {
|
type OpenFileOptions struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Filters []FileFilter `json:"filters,omitempty"`
|
Filters []FileFilter `json:"filters,omitempty"`
|
||||||
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
||||||
|
CanChooseDirectories bool `json:"canChooseDirectories,omitempty"`
|
||||||
|
CanChooseFiles bool `json:"canChooseFiles,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveFileOptions contains options for the save file dialog.
|
// SaveFileOptions contains options for the save file dialog.
|
||||||
|
//
|
||||||
|
// opts := SaveFileOptions{Title: "Export", Filename: "report.pdf", ShowHiddenFiles: false}
|
||||||
type SaveFileOptions struct {
|
type SaveFileOptions struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Filters []FileFilter `json:"filters,omitempty"`
|
Filters []FileFilter `json:"filters,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenDirectoryOptions contains options for the directory picker.
|
// OpenDirectoryOptions contains options for the directory picker.
|
||||||
|
//
|
||||||
|
// opts := OpenDirectoryOptions{Title: "Choose folder", ShowHiddenFiles: true}
|
||||||
type OpenDirectoryOptions struct {
|
type OpenDirectoryOptions struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageDialogOptions contains options for a message dialog.
|
// MessageDialogOptions contains options for a message dialog.
|
||||||
|
|
|
||||||
|
|
@ -4,54 +4,96 @@ package dialog
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the dialog service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing native dialogs via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the dialog service to a Core instance.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
//
|
||||||
return func(c *core.Core) (any, error) {
|
// c.WithService(dialog.Register(wailsDialog))
|
||||||
return &Service{
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
s.Core().Action("dialog.openFile", func(_ context.Context, opts core.Options) core.Result {
|
||||||
s.Core().RegisterTask(s.handleTask)
|
var openOpts OpenFileOptions
|
||||||
return nil
|
switch v := opts.Get("task").Value.(type) {
|
||||||
|
case TaskOpenFile:
|
||||||
|
openOpts = v.Options
|
||||||
|
case TaskOpenFileWithOptions:
|
||||||
|
if v.Options != nil {
|
||||||
|
openOpts = *v.Options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths, err := s.platform.OpenFile(openOpts)
|
||||||
|
return core.Result{}.New(paths, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.saveFile", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
var saveOpts SaveFileOptions
|
||||||
|
switch v := opts.Get("task").Value.(type) {
|
||||||
|
case TaskSaveFile:
|
||||||
|
saveOpts = v.Options
|
||||||
|
case TaskSaveFileWithOptions:
|
||||||
|
if v.Options != nil {
|
||||||
|
saveOpts = *v.Options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path, err := s.platform.SaveFile(saveOpts)
|
||||||
|
return core.Result{}.New(path, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.openDirectory", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskOpenDirectory)
|
||||||
|
path, err := s.platform.OpenDirectory(t.Options)
|
||||||
|
return core.Result{}.New(path, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.message", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskMessageDialog)
|
||||||
|
button, err := s.platform.MessageDialog(t.Options)
|
||||||
|
return core.Result{}.New(button, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.info", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskInfo)
|
||||||
|
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||||
|
Type: DialogInfo, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
|
||||||
|
})
|
||||||
|
return core.Result{}.New(button, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.question", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskQuestion)
|
||||||
|
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||||
|
Type: DialogQuestion, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
|
||||||
|
})
|
||||||
|
return core.Result{}.New(button, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.warning", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskWarning)
|
||||||
|
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||||
|
Type: DialogWarning, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
|
||||||
|
})
|
||||||
|
return core.Result{}.New(button, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("dialog.error", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskError)
|
||||||
|
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||||
|
Type: DialogError, Title: t.Title, Message: t.Message, Buttons: t.Buttons,
|
||||||
|
})
|
||||||
|
return core.Result{}.New(button, err)
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
return paths, true, err
|
|
||||||
case TaskSaveFile:
|
|
||||||
path, err := s.platform.SaveFile(t.Opts)
|
|
||||||
return path, true, err
|
|
||||||
case TaskOpenDirectory:
|
|
||||||
path, err := s.platform.OpenDirectory(t.Opts)
|
|
||||||
return path, true, err
|
|
||||||
case TaskMessageDialog:
|
|
||||||
button, err := s.platform.MessageDialog(t.Opts)
|
|
||||||
return button, true, err
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,24 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
openFilePaths []string
|
openFilePaths []string
|
||||||
saveFilePath string
|
saveFilePath string
|
||||||
openDirPath string
|
openDirPath string
|
||||||
messageButton string
|
messageButton string
|
||||||
openFileErr error
|
openFileErr error
|
||||||
saveFileErr error
|
saveFileErr error
|
||||||
openDirErr error
|
openDirErr error
|
||||||
messageErr error
|
messageErr error
|
||||||
lastOpenOpts OpenFileOptions
|
lastOpenOpts OpenFileOptions
|
||||||
lastSaveOpts SaveFileOptions
|
lastSaveOpts SaveFileOptions
|
||||||
lastDirOpts OpenDirectoryOptions
|
lastDirOpts OpenDirectoryOptions
|
||||||
lastMsgOpts MessageDialogOptions
|
lastMsgOpts MessageDialogOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
|
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
|
||||||
|
|
@ -50,74 +50,341 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||||
openDirPath: "/tmp/dir",
|
openDirPath: "/tmp/dir",
|
||||||
messageButton: "OK",
|
messageButton: "OK",
|
||||||
}
|
}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
return mock, c
|
return mock, c
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Good path tests ---
|
||||||
|
|
||||||
|
func TestService_Register_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
svc := core.MustServiceFor[*Service](c, "dialog")
|
svc := core.MustServiceFor[*Service](c, "dialog")
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenFile_Good(t *testing.T) {
|
func TestService_TaskOpenFile_Good(t *testing.T) {
|
||||||
mock, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
||||||
|
|
||||||
result, handled, err := c.PERFORM(TaskOpenFile{
|
r := taskRun(c, "dialog.openFile", TaskOpenFile{
|
||||||
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
paths := r.Value.([]string)
|
||||||
paths := result.([]string)
|
|
||||||
assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths)
|
assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths)
|
||||||
assert.Equal(t, "Pick", mock.lastOpenOpts.Title)
|
assert.Equal(t, "Pick", mock.lastOpenOpts.Title)
|
||||||
assert.True(t, mock.lastOpenOpts.AllowMultiple)
|
assert.True(t, mock.lastOpenOpts.AllowMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSaveFile_Good(t *testing.T) {
|
func TestService_TaskOpenFile_FileFilters_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskSaveFile{
|
mock.openFilePaths = []string{"/img.png"}
|
||||||
Opts: SaveFileOptions{Filename: "out.txt"},
|
|
||||||
|
filters := []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}}
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFile{
|
||||||
|
Options: OpenFileOptions{
|
||||||
|
Title: "Select image",
|
||||||
|
Filters: filters,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, []string{"/img.png"}, r.Value.([]string))
|
||||||
assert.Equal(t, "/tmp/save.txt", result)
|
require.Len(t, mock.lastOpenOpts.Filters, 1)
|
||||||
|
assert.Equal(t, "Images", mock.lastOpenOpts.Filters[0].DisplayName)
|
||||||
|
assert.Equal(t, "*.png;*.jpg", mock.lastOpenOpts.Filters[0].Pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenDirectory_Good(t *testing.T) {
|
func TestService_TaskOpenFile_MultipleSelection_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskOpenDirectory{
|
mock.openFilePaths = []string{"/a.txt", "/b.txt", "/c.txt"}
|
||||||
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
|
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFile{
|
||||||
|
Options: OpenFileOptions{AllowMultiple: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, []string{"/a.txt", "/b.txt", "/c.txt"}, r.Value.([]string))
|
||||||
assert.Equal(t, "/tmp/dir", result)
|
assert.True(t, mock.lastOpenOpts.AllowMultiple)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskMessageDialog_Good(t *testing.T) {
|
func TestService_TaskOpenFile_CanChooseOptions_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFile{
|
||||||
|
Options: OpenFileOptions{
|
||||||
|
CanChooseFiles: true,
|
||||||
|
CanChooseDirectories: true,
|
||||||
|
ShowHiddenFiles: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.True(t, mock.lastOpenOpts.CanChooseFiles)
|
||||||
|
assert.True(t, mock.lastOpenOpts.CanChooseDirectories)
|
||||||
|
assert.True(t, mock.lastOpenOpts.ShowHiddenFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskOpenFileWithOptions_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.openFilePaths = []string{"/log.txt"}
|
||||||
|
|
||||||
|
opts := &OpenFileOptions{
|
||||||
|
Title: "Select log",
|
||||||
|
AllowMultiple: false,
|
||||||
|
ShowHiddenFiles: true,
|
||||||
|
}
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: opts})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, []string{"/log.txt"}, r.Value.([]string))
|
||||||
|
assert.Equal(t, "Select log", mock.lastOpenOpts.Title)
|
||||||
|
assert.True(t, mock.lastOpenOpts.ShowHiddenFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskOpenFileWithOptions_NilOptions_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: nil})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.NotNil(t, r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFile_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t)
|
||||||
|
r := taskRun(c, "dialog.saveFile", TaskSaveFile{
|
||||||
|
Options: SaveFileOptions{Filename: "out.txt"},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "/tmp/save.txt", r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFile_ShowHidden_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.saveFile", TaskSaveFile{
|
||||||
|
Options: SaveFileOptions{Filename: "out.txt", ShowHiddenFiles: true},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.True(t, mock.lastSaveOpts.ShowHiddenFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFileWithOptions_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.saveFilePath = "/exports/data.json"
|
||||||
|
|
||||||
|
opts := &SaveFileOptions{
|
||||||
|
Title: "Export data",
|
||||||
|
Filename: "data.json",
|
||||||
|
Filters: []FileFilter{{DisplayName: "JSON", Pattern: "*.json"}},
|
||||||
|
}
|
||||||
|
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: opts})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "/exports/data.json", r.Value.(string))
|
||||||
|
assert.Equal(t, "Export data", mock.lastSaveOpts.Title)
|
||||||
|
require.Len(t, mock.lastSaveOpts.Filters, 1)
|
||||||
|
assert.Equal(t, "JSON", mock.lastSaveOpts.Filters[0].DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFileWithOptions_NilOptions_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: nil})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "/tmp/save.txt", r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskOpenDirectory_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.openDirectory", TaskOpenDirectory{
|
||||||
|
Options: OpenDirectoryOptions{Title: "Pick Dir", ShowHiddenFiles: true},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "/tmp/dir", r.Value)
|
||||||
|
assert.Equal(t, "Pick Dir", mock.lastDirOpts.Title)
|
||||||
|
assert.True(t, mock.lastDirOpts.ShowHiddenFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskMessageDialog_Good(t *testing.T) {
|
||||||
mock, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
mock.messageButton = "Yes"
|
mock.messageButton = "Yes"
|
||||||
|
|
||||||
result, handled, err := c.PERFORM(TaskMessageDialog{
|
r := taskRun(c, "dialog.message", TaskMessageDialog{
|
||||||
Opts: MessageDialogOptions{
|
Options: MessageDialogOptions{
|
||||||
Type: DialogQuestion, Title: "Confirm",
|
Type: DialogQuestion, Title: "Confirm",
|
||||||
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, "Yes", r.Value)
|
||||||
assert.Equal(t, "Yes", result)
|
|
||||||
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
|
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenFile_Bad(t *testing.T) {
|
func TestService_TaskInfo_Good(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
mock, c := newTestService(t)
|
||||||
_, handled, _ := c.PERFORM(TaskOpenFile{})
|
mock.messageButton = "OK"
|
||||||
assert.False(t, handled)
|
|
||||||
|
r := taskRun(c, "dialog.info", TaskInfo{
|
||||||
|
Title: "Done", Message: "File saved successfully.",
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "OK", r.Value.(string))
|
||||||
|
assert.Equal(t, DialogInfo, mock.lastMsgOpts.Type)
|
||||||
|
assert.Equal(t, "Done", mock.lastMsgOpts.Title)
|
||||||
|
assert.Equal(t, "File saved successfully.", mock.lastMsgOpts.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskInfo_WithButtons_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.messageButton = "Close"
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.info", TaskInfo{
|
||||||
|
Title: "Notice", Message: "Update available.", Buttons: []string{"Close", "Later"},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "Close", r.Value.(string))
|
||||||
|
assert.Equal(t, []string{"Close", "Later"}, mock.lastMsgOpts.Buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskQuestion_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.messageButton = "Yes"
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.question", TaskQuestion{
|
||||||
|
Title: "Confirm deletion", Message: "Delete file?", Buttons: []string{"Yes", "No"},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "Yes", r.Value.(string))
|
||||||
|
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
|
||||||
|
assert.Equal(t, "Confirm deletion", mock.lastMsgOpts.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskWarning_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.messageButton = "OK"
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.warning", TaskWarning{
|
||||||
|
Title: "Disk full", Message: "Storage is critically low.", Buttons: []string{"OK"},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "OK", r.Value.(string))
|
||||||
|
assert.Equal(t, DialogWarning, mock.lastMsgOpts.Type)
|
||||||
|
assert.Equal(t, "Disk full", mock.lastMsgOpts.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskError_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.messageButton = "OK"
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.error", TaskError{
|
||||||
|
Title: "Operation failed", Message: "could not write file: permission denied",
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "OK", r.Value.(string))
|
||||||
|
assert.Equal(t, DialogError, mock.lastMsgOpts.Type)
|
||||||
|
assert.Equal(t, "Operation failed", mock.lastMsgOpts.Title)
|
||||||
|
assert.Equal(t, "could not write file: permission denied", mock.lastMsgOpts.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bad path tests ---
|
||||||
|
|
||||||
|
func TestService_TaskOpenFile_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.openFile").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskOpenFileWithOptions_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.openFile").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFileWithOptions_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.saveFile").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskInfo_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.info").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskQuestion_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.question").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskWarning_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.warning").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskError_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.error").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ugly path tests ---
|
||||||
|
|
||||||
|
func TestService_TaskOpenFile_Ugly(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.openFilePaths = nil
|
||||||
|
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFile{
|
||||||
|
Options: OpenFileOptions{Title: "Pick"},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Nil(t, r.Value.([]string))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskOpenFileWithOptions_MultipleFilters_Ugly(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.openFilePaths = []string{"/doc.pdf"}
|
||||||
|
|
||||||
|
opts := &OpenFileOptions{
|
||||||
|
Title: "Select document",
|
||||||
|
Filters: []FileFilter{
|
||||||
|
{DisplayName: "PDF", Pattern: "*.pdf"},
|
||||||
|
{DisplayName: "Word", Pattern: "*.docx"},
|
||||||
|
{DisplayName: "All files", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := taskRun(c, "dialog.openFile", TaskOpenFileWithOptions{Options: opts})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, []string{"/doc.pdf"}, r.Value.([]string))
|
||||||
|
assert.Len(t, mock.lastOpenOpts.Filters, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_TaskSaveFileWithOptions_FiltersAndHidden_Ugly(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
|
||||||
|
opts := &SaveFileOptions{
|
||||||
|
Title: "Save",
|
||||||
|
Filename: "output.csv",
|
||||||
|
ShowHiddenFiles: true,
|
||||||
|
Filters: []FileFilter{{DisplayName: "CSV", Pattern: "*.csv"}},
|
||||||
|
}
|
||||||
|
r := taskRun(c, "dialog.saveFile", TaskSaveFileWithOptions{Options: opts})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.True(t, mock.lastSaveOpts.ShowHiddenFiles)
|
||||||
|
assert.Equal(t, "output.csv", mock.lastSaveOpts.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_UnknownTask_Ugly(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dialog.nonexistent").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,10 +3,9 @@ package display
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/menu"
|
"forge.lthn.ai/core/gui/pkg/menu"
|
||||||
"forge.lthn.ai/core/gui/pkg/systray"
|
"forge.lthn.ai/core/gui/pkg/systray"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
|
|
@ -19,12 +18,11 @@ import (
|
||||||
// newTestDisplayService creates a display service registered with Core for IPC testing.
|
// newTestDisplayService creates a display service registered with Core for IPC testing.
|
||||||
func newTestDisplayService(t *testing.T) (*Service, *core.Core) {
|
func newTestDisplayService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(nil)),
|
core.WithService(Register(nil)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "display")
|
svc := core.MustServiceFor[*Service](c, "display")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
@ -32,46 +30,48 @@ func newTestDisplayService(t *testing.T) (*Service, *core.Core) {
|
||||||
// newTestConclave creates a full 4-service conclave for integration testing.
|
// newTestConclave creates a full 4-service conclave for integration testing.
|
||||||
func newTestConclave(t *testing.T) *core.Core {
|
func newTestConclave(t *testing.T) *core.Core {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(nil)),
|
core.WithService(Register(nil)),
|
||||||
core.WithService(window.Register(window.NewMockPlatform())),
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
core.WithService(systray.Register(systray.NewMockPlatform())),
|
core.WithService(systray.Register(systray.NewMockPlatform())),
|
||||||
core.WithService(menu.Register(menu.NewMockPlatform())),
|
core.WithService(menu.Register(menu.NewMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew_Good(t *testing.T) {
|
||||||
t.Run("creates service successfully", func(t *testing.T) {
|
service, err := New()
|
||||||
service, err := New()
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, err)
|
assert.NotNil(t, service)
|
||||||
assert.NotNil(t, service, "New() should return a non-nil service instance")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns independent instances", func(t *testing.T) {
|
|
||||||
service1, err1 := New()
|
|
||||||
service2, err2 := New()
|
|
||||||
assert.NoError(t, err1)
|
|
||||||
assert.NoError(t, err2)
|
|
||||||
assert.NotSame(t, service1, service2, "New() should return different instances")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterClosure_Good(t *testing.T) {
|
func TestNew_Good_IndependentInstances(t *testing.T) {
|
||||||
|
service1, err1 := New()
|
||||||
|
service2, err2 := New()
|
||||||
|
assert.NoError(t, err1)
|
||||||
|
assert.NoError(t, err2)
|
||||||
|
assert.NotSame(t, service1, service2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Good(t *testing.T) {
|
||||||
factory := Register(nil) // nil wailsApp for testing
|
factory := Register(nil) // nil wailsApp for testing
|
||||||
assert.NotNil(t, factory)
|
assert.NotNil(t, factory)
|
||||||
|
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(factory),
|
core.WithService(factory),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
|
|
||||||
svc := core.MustServiceFor[*Service](c, "display")
|
svc := core.MustServiceFor[*Service](c, "display")
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
|
|
@ -85,32 +85,29 @@ func TestConfigQuery_Good(t *testing.T) {
|
||||||
"default_width": 1024,
|
"default_width": 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, handled, err := c.QUERY(window.QueryConfig{})
|
r := c.QUERY(window.QueryConfig{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
cfg := r.Value.(map[string]any)
|
||||||
cfg := result.(map[string]any)
|
|
||||||
assert.Equal(t, 1024, cfg["default_width"])
|
assert.Equal(t, 1024, cfg["default_width"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigQuery_Bad(t *testing.T) {
|
func TestConfigQuery_Bad(t *testing.T) {
|
||||||
// No display service — window config query returns handled=false
|
// No display service — window config query returns handled=false
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
r := c.QUERY(window.QueryConfig{})
|
||||||
_, handled, _ := c.QUERY(window.QueryConfig{})
|
assert.False(t, r.OK)
|
||||||
assert.False(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigTask_Good(t *testing.T) {
|
func TestConfigTask_Good(t *testing.T) {
|
||||||
_, c := newTestDisplayService(t)
|
_, c := newTestDisplayService(t)
|
||||||
|
|
||||||
newCfg := map[string]any{"default_width": 800}
|
newCfg := map[string]any{"default_width": 800}
|
||||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
|
r := taskRun(c, "display.saveWindowConfig", window.TaskSaveConfig{Config: newCfg})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify config was saved
|
// Verify config was saved
|
||||||
result, _, _ := c.QUERY(window.QueryConfig{})
|
r2 := c.QUERY(window.QueryConfig{})
|
||||||
cfg := result.(map[string]any)
|
cfg := r2.Value.(map[string]any)
|
||||||
assert.Equal(t, 800, cfg["default_width"])
|
assert.Equal(t, 800, cfg["default_width"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,45 +117,41 @@ func TestServiceConclave_Good(t *testing.T) {
|
||||||
c := newTestConclave(t)
|
c := newTestConclave(t)
|
||||||
|
|
||||||
// Open a window via IPC
|
// Open a window via IPC
|
||||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
r := taskRun(c, "window.open", window.TaskOpenWindow{
|
||||||
Opts: []window.WindowOption{window.WithName("main")},
|
Window: &window.Window{Name: "main"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
info := r.Value.(window.WindowInfo)
|
||||||
info := result.(window.WindowInfo)
|
|
||||||
assert.Equal(t, "main", info.Name)
|
assert.Equal(t, "main", info.Name)
|
||||||
|
|
||||||
// Query window config from display
|
// Query window config from display
|
||||||
val, handled, err := c.QUERY(window.QueryConfig{})
|
r2 := c.QUERY(window.QueryConfig{})
|
||||||
require.NoError(t, err)
|
require.True(t, r2.OK)
|
||||||
assert.True(t, handled)
|
assert.NotNil(t, r2.Value)
|
||||||
assert.NotNil(t, val)
|
|
||||||
|
|
||||||
// Set app menu via IPC
|
// Set app menu via IPC
|
||||||
_, handled, err = c.PERFORM(menu.TaskSetAppMenu{Items: []menu.MenuItem{
|
r3 := taskRun(c, "menu.setAppMenu", menu.TaskSetAppMenu{Items: []menu.MenuItem{
|
||||||
{Label: "File"},
|
{Label: "File"},
|
||||||
}})
|
}})
|
||||||
require.NoError(t, err)
|
require.True(t, r3.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Query app menu via IPC
|
// Query app menu via IPC
|
||||||
menuResult, handled, _ := c.QUERY(menu.QueryGetAppMenu{})
|
r4 := c.QUERY(menu.QueryGetAppMenu{})
|
||||||
assert.True(t, handled)
|
assert.True(t, r4.OK)
|
||||||
items := menuResult.([]menu.MenuItem)
|
items := r4.Value.([]menu.MenuItem)
|
||||||
assert.Len(t, items, 1)
|
assert.Len(t, items, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceConclave_Bad(t *testing.T) {
|
func TestServiceConclave_Bad(t *testing.T) {
|
||||||
// Sub-service starts without display — config QUERY returns handled=false
|
// Sub-service starts without display — config QUERY returns handled=false
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(window.Register(window.NewMockPlatform())),
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
|
|
||||||
_, handled, _ := c.QUERY(window.QueryConfig{})
|
r := c.QUERY(window.QueryConfig{})
|
||||||
assert.False(t, handled, "no display service means no config handler")
|
assert.False(t, r.OK, "no display service means no config handler")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IPC delegation tests (full conclave) ---
|
// --- IPC delegation tests (full conclave) ---
|
||||||
|
|
@ -185,8 +178,8 @@ func TestOpenWindow_Good(t *testing.T) {
|
||||||
)
|
)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
result, _, _ := c.QUERY(window.QueryWindowByName{Name: "custom-window"})
|
r := c.QUERY(window.QueryWindowByName{Name: "custom-window"})
|
||||||
info := result.(*window.WindowInfo)
|
info := r.Value.(*window.WindowInfo)
|
||||||
assert.Equal(t, "custom-window", info.Name)
|
assert.Equal(t, "custom-window", info.Name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +194,7 @@ func TestGetWindowInfo_Good(t *testing.T) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Modify position via IPC
|
// Modify position via IPC
|
||||||
_, _, _ = c.PERFORM(window.TaskSetPosition{Name: "test-win", X: 100, Y: 200})
|
taskRun(c, "window.setPosition", window.TaskSetPosition{Name: "test-win", X: 100, Y: 200})
|
||||||
|
|
||||||
info, err := svc.GetWindowInfo("test-win")
|
info, err := svc.GetWindowInfo("test-win")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -358,7 +351,7 @@ func TestGetFocusedWindow_Good(t *testing.T) {
|
||||||
assert.Equal(t, "win-b", focused)
|
assert.Equal(t, "win-b", focused)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetFocusedWindow_NoneSelected(t *testing.T) {
|
func TestGetFocusedWindow_Good_NoneSelected(t *testing.T) {
|
||||||
c := newTestConclave(t)
|
c := newTestConclave(t)
|
||||||
svc := core.MustServiceFor[*Service](c, "display")
|
svc := core.MustServiceFor[*Service](c, "display")
|
||||||
_ = svc.OpenWindow(window.WithName("win-a"))
|
_ = svc.OpenWindow(window.WithName("win-a"))
|
||||||
|
|
@ -412,12 +405,11 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
|
||||||
|
|
||||||
// Open a window — this should trigger ActionWindowOpened
|
// Open a window — this should trigger ActionWindowOpened
|
||||||
// which HandleIPCEvents should convert to a WS event
|
// which HandleIPCEvents should convert to a WS event
|
||||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
r := taskRun(c, "window.open", window.TaskOpenWindow{
|
||||||
Opts: []window.WindowOption{window.WithName("test")},
|
Window: &window.Window{Name: "test"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
info := r.Value.(window.WindowInfo)
|
||||||
info := result.(window.WindowInfo)
|
|
||||||
assert.Equal(t, "test", info.Name)
|
assert.Equal(t, "test", info.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,8 +436,8 @@ func TestWSEventManager_Good(t *testing.T) {
|
||||||
func TestLoadConfig_Good(t *testing.T) {
|
func TestLoadConfig_Good(t *testing.T) {
|
||||||
// Create temp config file
|
// Create temp config file
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, ".core", "gui", "config.yaml")
|
cfgPath := core.JoinPath(dir, ".core", "gui", "config.yaml")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
|
require.NoError(t, os.MkdirAll(core.PathDir(cfgPath), 0o755))
|
||||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||||
window:
|
window:
|
||||||
default_width: 1280
|
default_width: 1280
|
||||||
|
|
@ -467,7 +459,7 @@ menu:
|
||||||
|
|
||||||
func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
||||||
s, _ := New()
|
s, _ := New()
|
||||||
s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
s.loadConfigFrom(core.JoinPath(t.TempDir(), "nonexistent.yaml"))
|
||||||
|
|
||||||
// Should not panic, configData stays at empty defaults
|
// Should not panic, configData stays at empty defaults
|
||||||
assert.Empty(t, s.configData["window"])
|
assert.Empty(t, s.configData["window"])
|
||||||
|
|
@ -477,26 +469,25 @@ func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, "config.yaml")
|
cfgPath := core.JoinPath(dir, "config.yaml")
|
||||||
|
|
||||||
s, _ := New()
|
s, _ := New()
|
||||||
s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet)
|
s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet)
|
||||||
|
|
||||||
// Simulate a TaskSaveConfig through the handler
|
// Simulate a TaskSaveConfig through the handler
|
||||||
c, _ := core.New(
|
c := core.New(
|
||||||
core.WithService(func(c *core.Core) (any, error) {
|
core.WithService(func(c *core.Core) core.Result {
|
||||||
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||||
return s, nil
|
return core.Result{Value: s, OK: true}
|
||||||
}),
|
}),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
c.ServiceStartup(context.Background(), nil)
|
c.ServiceStartup(context.Background(), nil)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{
|
r := taskRun(c, "display.saveWindowConfig", window.TaskSaveConfig{
|
||||||
Value: map[string]any{"default_width": 1920},
|
Config: map[string]any{"default_width": 1920},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify file was written
|
// Verify file was written
|
||||||
data, err := os.ReadFile(cfgPath)
|
data, err := os.ReadFile(cfgPath)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
@ -15,12 +15,12 @@ import (
|
||||||
type EventType string
|
type EventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EventWindowFocus EventType = "window.focus"
|
EventWindowFocus EventType = "window.focus"
|
||||||
EventWindowBlur EventType = "window.blur"
|
EventWindowBlur EventType = "window.blur"
|
||||||
EventWindowMove EventType = "window.move"
|
EventWindowMove EventType = "window.move"
|
||||||
EventWindowResize EventType = "window.resize"
|
EventWindowResize EventType = "window.resize"
|
||||||
EventWindowClose EventType = "window.close"
|
EventWindowClose EventType = "window.close"
|
||||||
EventWindowCreate EventType = "window.create"
|
EventWindowCreate EventType = "window.create"
|
||||||
EventThemeChange EventType = "theme.change"
|
EventThemeChange EventType = "theme.change"
|
||||||
EventScreenChange EventType = "screen.change"
|
EventScreenChange EventType = "screen.change"
|
||||||
EventNotificationClick EventType = "notification.click"
|
EventNotificationClick EventType = "notification.click"
|
||||||
|
|
@ -40,6 +40,11 @@ const (
|
||||||
EventContextMenuClick EventType = "contextmenu.item-clicked"
|
EventContextMenuClick EventType = "contextmenu.item-clicked"
|
||||||
EventWebviewConsole EventType = "webview.console"
|
EventWebviewConsole EventType = "webview.console"
|
||||||
EventWebviewException EventType = "webview.exception"
|
EventWebviewException EventType = "webview.exception"
|
||||||
|
EventCustomEvent EventType = "custom.event"
|
||||||
|
EventDockProgress EventType = "dock.progress"
|
||||||
|
EventDockBounce EventType = "dock.bounce"
|
||||||
|
EventNotificationAction EventType = "notification.action"
|
||||||
|
EventNotificationDismiss EventType = "notification.dismissed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a display event sent to subscribers.
|
// Event represents a display event sent to subscribers.
|
||||||
|
|
@ -129,10 +134,11 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(event)
|
marshalResult := core.JSONMarshal(event)
|
||||||
if err != nil {
|
if !marshalResult.OK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data, _ := marshalResult.Value.([]byte)
|
||||||
|
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
|
|
@ -173,7 +179,7 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
|
||||||
EventTypes []EventType `json:"eventTypes,omitempty"`
|
EventTypes []EventType `json:"eventTypes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(message, &msg); err != nil {
|
if unmarshalResult := core.JSONUnmarshal(message, &msg); !unmarshalResult.OK {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +208,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
|
||||||
if id == "" {
|
if id == "" {
|
||||||
em.mu.Lock()
|
em.mu.Lock()
|
||||||
em.nextSubID++
|
em.nextSubID++
|
||||||
id = fmt.Sprintf("sub-%d", em.nextSubID)
|
id = "sub-" + strconv.Itoa(em.nextSubID)
|
||||||
em.mu.Unlock()
|
em.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,8 +225,10 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
|
||||||
"id": id,
|
"id": id,
|
||||||
"eventTypes": eventTypes,
|
"eventTypes": eventTypes,
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(response)
|
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
|
||||||
conn.WriteMessage(websocket.TextMessage, data)
|
responseData, _ := marshalResult.Value.([]byte)
|
||||||
|
conn.WriteMessage(websocket.TextMessage, responseData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsubscribe removes a subscription for a client.
|
// unsubscribe removes a subscription for a client.
|
||||||
|
|
@ -242,8 +250,10 @@ func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
|
||||||
"type": "unsubscribed",
|
"type": "unsubscribed",
|
||||||
"id": id,
|
"id": id,
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(response)
|
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
|
||||||
conn.WriteMessage(websocket.TextMessage, data)
|
responseData, _ := marshalResult.Value.([]byte)
|
||||||
|
conn.WriteMessage(websocket.TextMessage, responseData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listSubscriptions sends a list of active subscriptions to a client.
|
// listSubscriptions sends a list of active subscriptions to a client.
|
||||||
|
|
@ -267,8 +277,10 @@ func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
|
||||||
"type": "subscriptions",
|
"type": "subscriptions",
|
||||||
"subscriptions": subs,
|
"subscriptions": subs,
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(response)
|
if marshalResult := core.JSONMarshal(response); marshalResult.OK {
|
||||||
conn.WriteMessage(websocket.TextMessage, data)
|
responseData, _ := marshalResult.Value.([]byte)
|
||||||
|
conn.WriteMessage(websocket.TextMessage, responseData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeClient removes a client and its subscriptions.
|
// removeClient removes a client and its subscriptions.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,33 @@ type TaskSetBadge struct{ Label string }
|
||||||
// TaskRemoveBadge removes the dock/taskbar badge. Result: nil
|
// TaskRemoveBadge removes the dock/taskbar badge. Result: nil
|
||||||
type TaskRemoveBadge struct{}
|
type TaskRemoveBadge struct{}
|
||||||
|
|
||||||
|
// TaskSetProgressBar updates the progress indicator on the dock/taskbar icon.
|
||||||
|
// Progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator.
|
||||||
|
// c.PERFORM(dock.TaskSetProgressBar{Progress: 0.75}) // 75% complete
|
||||||
|
// c.PERFORM(dock.TaskSetProgressBar{Progress: -1.0}) // hide indicator
|
||||||
|
// Result: nil
|
||||||
|
type TaskSetProgressBar struct{ Progress float64 }
|
||||||
|
|
||||||
|
// TaskBounce requests user attention by animating the dock icon.
|
||||||
|
// Result: int (requestID for use with TaskStopBounce)
|
||||||
|
// c.PERFORM(dock.TaskBounce{BounceType: dock.BounceInformational})
|
||||||
|
type TaskBounce struct{ BounceType BounceType }
|
||||||
|
|
||||||
|
// TaskStopBounce cancels a pending attention request.
|
||||||
|
// c.PERFORM(dock.TaskStopBounce{RequestID: id})
|
||||||
|
// Result: nil
|
||||||
|
type TaskStopBounce struct{ RequestID int }
|
||||||
|
|
||||||
// --- Actions (broadcasts) ---
|
// --- Actions (broadcasts) ---
|
||||||
|
|
||||||
// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon.
|
// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon.
|
||||||
type ActionVisibilityChanged struct{ Visible bool }
|
type ActionVisibilityChanged struct{ Visible bool }
|
||||||
|
|
||||||
|
// ActionProgressChanged is broadcast after a successful TaskSetProgressBar.
|
||||||
|
type ActionProgressChanged struct{ Progress float64 }
|
||||||
|
|
||||||
|
// ActionBounceStarted is broadcast after a successful TaskBounce.
|
||||||
|
type ActionBounceStarted struct {
|
||||||
|
RequestID int `json:"requestId"`
|
||||||
|
BounceType BounceType `json:"bounceType"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
// pkg/dock/platform.go
|
// pkg/dock/platform.go
|
||||||
package dock
|
package dock
|
||||||
|
|
||||||
|
// BounceType controls how the dock icon attracts attention.
|
||||||
|
// bounce := dock.BounceInformational — single bounce
|
||||||
|
// bounce := dock.BounceCritical — continuous bounce until focused
|
||||||
|
type BounceType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BounceInformational performs a single bounce to indicate a background event.
|
||||||
|
BounceInformational BounceType = iota
|
||||||
|
// BounceCritical bounces continuously until the application becomes active.
|
||||||
|
BounceCritical
|
||||||
|
)
|
||||||
|
|
||||||
// Platform abstracts the dock/taskbar backend (Wails v3).
|
// Platform abstracts the dock/taskbar backend (Wails v3).
|
||||||
// macOS: dock icon show/hide + badge.
|
// macOS: dock icon show/hide, badge, progress bar, bounce.
|
||||||
// Windows: taskbar badge only (show/hide not supported).
|
// Windows: taskbar badge + progress bar (show/hide and bounce not supported).
|
||||||
// Linux: not supported — adapter returns nil for all operations.
|
// Linux: not supported — adapter returns nil for all operations.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
ShowIcon() error
|
ShowIcon() error
|
||||||
|
|
@ -11,4 +23,12 @@ type Platform interface {
|
||||||
SetBadge(label string) error
|
SetBadge(label string) error
|
||||||
RemoveBadge() error
|
RemoveBadge() error
|
||||||
IsVisible() bool
|
IsVisible() bool
|
||||||
|
// SetProgressBar sets a progress indicator on the dock/taskbar icon.
|
||||||
|
// progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator.
|
||||||
|
SetProgressBar(progress float64) error
|
||||||
|
// Bounce requests user attention by animating the dock icon.
|
||||||
|
// Returns a request ID that can be passed to StopBounce.
|
||||||
|
Bounce(bounceType BounceType) (int, error)
|
||||||
|
// StopBounce cancels a pending attention request by its ID.
|
||||||
|
StopBounce(requestID int) error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
// pkg/dock/register.go
|
|
||||||
package dock
|
package dock
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the dock service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(dock.Register(wailsDock))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,82 @@
|
||||||
// pkg/dock/service.go
|
|
||||||
package dock
|
package dock
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the dock service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing dock/taskbar operations via IPC.
|
|
||||||
// It embeds ServiceRuntime for Core access and delegates to Platform.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("dock.showIcon", func(_ context.Context, _ core.Options) core.Result {
|
||||||
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:
|
|
||||||
return s.platform.IsVisible(), true, nil
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Task Handlers ---
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskShowIcon:
|
|
||||||
if err := s.platform.ShowIcon(); err != nil {
|
if err := s.platform.ShowIcon(); err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: true})
|
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: true})
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
case TaskHideIcon:
|
})
|
||||||
|
s.Core().Action("dock.hideIcon", func(_ context.Context, _ core.Options) core.Result {
|
||||||
if err := s.platform.HideIcon(); err != nil {
|
if err := s.platform.HideIcon(); err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: false})
|
_ = s.Core().ACTION(ActionVisibilityChanged{Visible: false})
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
case TaskSetBadge:
|
})
|
||||||
if err := s.platform.SetBadge(t.Label); err != nil {
|
s.Core().Action("dock.setBadge", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil, true, err
|
if err := s.platform.SetBadge(opts.String("label")); err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
case TaskRemoveBadge:
|
})
|
||||||
|
s.Core().Action("dock.removeBadge", func(_ context.Context, _ core.Options) core.Result {
|
||||||
if err := s.platform.RemoveBadge(); err != nil {
|
if err := s.platform.RemoveBadge(); err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("dock.setProgressBar", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetProgressBar)
|
||||||
|
if err := s.platform.SetProgressBar(t.Progress); err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
_ = s.Core().ACTION(ActionProgressChanged{Progress: t.Progress})
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("dock.bounce", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskBounce)
|
||||||
|
requestID, err := s.platform.Bounce(t.BounceType)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
_ = s.Core().ACTION(ActionBounceStarted{RequestID: requestID, BounceType: t.BounceType})
|
||||||
|
return core.Result{Value: requestID, OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("dock.stopBounce", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskStopBounce)
|
||||||
|
if err := s.platform.StopBounce(t.RequestID); err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
|
switch q.(type) {
|
||||||
|
case QueryVisible:
|
||||||
|
return core.Result{Value: s.platform.IsVisible(), OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -13,13 +13,21 @@ import (
|
||||||
// --- Mock Platform ---
|
// --- Mock Platform ---
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
visible bool
|
visible bool
|
||||||
badge string
|
badge string
|
||||||
hasBadge bool
|
hasBadge bool
|
||||||
showErr error
|
progress float64
|
||||||
hideErr error
|
bounceID int
|
||||||
badgeErr error
|
bounceType BounceType
|
||||||
removeErr error
|
bounceCalled bool
|
||||||
|
stopBounceCalled bool
|
||||||
|
showErr error
|
||||||
|
hideErr error
|
||||||
|
badgeErr error
|
||||||
|
removeErr error
|
||||||
|
progressErr error
|
||||||
|
bounceErr error
|
||||||
|
stopBounceErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) ShowIcon() error {
|
func (m *mockPlatform) ShowIcon() error {
|
||||||
|
|
@ -58,21 +66,58 @@ func (m *mockPlatform) RemoveBadge() error {
|
||||||
|
|
||||||
func (m *mockPlatform) IsVisible() bool { return m.visible }
|
func (m *mockPlatform) IsVisible() bool { return m.visible }
|
||||||
|
|
||||||
|
func (m *mockPlatform) SetProgressBar(progress float64) error {
|
||||||
|
if m.progressErr != nil {
|
||||||
|
return m.progressErr
|
||||||
|
}
|
||||||
|
m.progress = progress
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) Bounce(bounceType BounceType) (int, error) {
|
||||||
|
if m.bounceErr != nil {
|
||||||
|
return 0, m.bounceErr
|
||||||
|
}
|
||||||
|
m.bounceCalled = true
|
||||||
|
m.bounceType = bounceType
|
||||||
|
m.bounceID++
|
||||||
|
return m.bounceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) StopBounce(requestID int) error {
|
||||||
|
if m.stopBounceErr != nil {
|
||||||
|
return m.stopBounceErr
|
||||||
|
}
|
||||||
|
m.stopBounceCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Test helpers ---
|
// --- Test helpers ---
|
||||||
|
|
||||||
func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mock := &mockPlatform{visible: true}
|
mock := &mockPlatform{visible: true}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "dock")
|
svc := core.MustServiceFor[*Service](c, "dock")
|
||||||
return svc, c, mock
|
return svc, c, mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBadge(c *core.Core, label string) core.Result {
|
||||||
|
return c.Action("dock.setBadge").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "label", Value: label},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
|
|
@ -82,18 +127,16 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryVisible_Good(t *testing.T) {
|
func TestQueryVisible_Good(t *testing.T) {
|
||||||
_, c, _ := newTestDockService(t)
|
_, c, _ := newTestDockService(t)
|
||||||
result, handled, err := c.QUERY(QueryVisible{})
|
r := c.QUERY(QueryVisible{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, true, r.Value)
|
||||||
assert.Equal(t, true, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryVisible_Bad(t *testing.T) {
|
func TestQueryVisible_Bad(t *testing.T) {
|
||||||
// No dock service registered — QUERY returns handled=false
|
// No dock service registered — QUERY returns handled=false
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
r := c.QUERY(QueryVisible{})
|
||||||
_, handled, _ := c.QUERY(QueryVisible{})
|
assert.False(t, r.OK)
|
||||||
assert.False(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskShowIcon_Good(t *testing.T) {
|
func TestTaskShowIcon_Good(t *testing.T) {
|
||||||
|
|
@ -101,16 +144,15 @@ func TestTaskShowIcon_Good(t *testing.T) {
|
||||||
mock.visible = false // Start hidden
|
mock.visible = false // Start hidden
|
||||||
|
|
||||||
var received *ActionVisibilityChanged
|
var received *ActionVisibilityChanged
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionVisibilityChanged); ok {
|
if a, ok := msg.(ActionVisibilityChanged); ok {
|
||||||
received = &a
|
received = &a
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskShowIcon{})
|
r := taskRun(c, "dock.showIcon", TaskShowIcon{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.True(t, mock.visible)
|
assert.True(t, mock.visible)
|
||||||
require.NotNil(t, received)
|
require.NotNil(t, received)
|
||||||
assert.True(t, received.Visible)
|
assert.True(t, received.Visible)
|
||||||
|
|
@ -121,16 +163,15 @@ func TestTaskHideIcon_Good(t *testing.T) {
|
||||||
mock.visible = true // Start visible
|
mock.visible = true // Start visible
|
||||||
|
|
||||||
var received *ActionVisibilityChanged
|
var received *ActionVisibilityChanged
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionVisibilityChanged); ok {
|
if a, ok := msg.(ActionVisibilityChanged); ok {
|
||||||
received = &a
|
received = &a
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskHideIcon{})
|
r := taskRun(c, "dock.hideIcon", TaskHideIcon{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.False(t, mock.visible)
|
assert.False(t, mock.visible)
|
||||||
require.NotNil(t, received)
|
require.NotNil(t, received)
|
||||||
assert.False(t, received.Visible)
|
assert.False(t, received.Visible)
|
||||||
|
|
@ -138,18 +179,16 @@ func TestTaskHideIcon_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestTaskSetBadge_Good(t *testing.T) {
|
func TestTaskSetBadge_Good(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
_, handled, err := c.PERFORM(TaskSetBadge{Label: "3"})
|
r := setBadge(c, "3")
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.Equal(t, "3", mock.badge)
|
assert.Equal(t, "3", mock.badge)
|
||||||
assert.True(t, mock.hasBadge)
|
assert.True(t, mock.hasBadge)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) {
|
func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
_, handled, err := c.PERFORM(TaskSetBadge{Label: ""})
|
r := setBadge(c, "")
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.Equal(t, "", mock.badge)
|
assert.Equal(t, "", mock.badge)
|
||||||
assert.True(t, mock.hasBadge) // Empty string = default system badge indicator
|
assert.True(t, mock.hasBadge) // Empty string = default system badge indicator
|
||||||
}
|
}
|
||||||
|
|
@ -157,11 +196,10 @@ func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) {
|
||||||
func TestTaskRemoveBadge_Good(t *testing.T) {
|
func TestTaskRemoveBadge_Good(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
// Set a badge first
|
// Set a badge first
|
||||||
_, _, _ = c.PERFORM(TaskSetBadge{Label: "5"})
|
_ = setBadge(c, "5")
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskRemoveBadge{})
|
r := taskRun(c, "dock.removeBadge", TaskRemoveBadge{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.Equal(t, "", mock.badge)
|
assert.Equal(t, "", mock.badge)
|
||||||
assert.False(t, mock.hasBadge)
|
assert.False(t, mock.hasBadge)
|
||||||
}
|
}
|
||||||
|
|
@ -170,25 +208,166 @@ func TestTaskShowIcon_Bad(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
mock.showErr = assert.AnError
|
mock.showErr = assert.AnError
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskShowIcon{})
|
r := taskRun(c, "dock.showIcon", TaskShowIcon{})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskHideIcon_Bad(t *testing.T) {
|
func TestTaskHideIcon_Bad(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
mock.hideErr = assert.AnError
|
mock.hideErr = assert.AnError
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskHideIcon{})
|
r := taskRun(c, "dock.hideIcon", TaskHideIcon{})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetBadge_Bad(t *testing.T) {
|
func TestTaskSetBadge_Bad(t *testing.T) {
|
||||||
_, c, mock := newTestDockService(t)
|
_, c, mock := newTestDockService(t)
|
||||||
mock.badgeErr = assert.AnError
|
mock.badgeErr = assert.AnError
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSetBadge{Label: "3"})
|
r := setBadge(c, "3")
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.Error(t, err)
|
}
|
||||||
|
|
||||||
|
// --- TaskSetProgressBar ---
|
||||||
|
|
||||||
|
func TestTaskSetProgressBar_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
|
||||||
|
var received *ActionProgressChanged
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if a, ok := msg.(ActionProgressChanged); ok {
|
||||||
|
received = &a
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: 0.5})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, 0.5, mock.progress)
|
||||||
|
require.NotNil(t, received)
|
||||||
|
assert.Equal(t, 0.5, received.Progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetProgressBar_Hide_Good(t *testing.T) {
|
||||||
|
// Progress -1.0 hides the indicator
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: -1.0})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, -1.0, mock.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetProgressBar_Bad(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
mock.progressErr = assert.AnError
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.setProgressBar", TaskSetProgressBar{Progress: 0.5})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetProgressBar_Ugly(t *testing.T) {
|
||||||
|
// No dock service — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dock.setProgressBar").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskBounce ---
|
||||||
|
|
||||||
|
func TestTaskBounce_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
|
||||||
|
var received *ActionBounceStarted
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if a, ok := msg.(ActionBounceStarted); ok {
|
||||||
|
received = &a
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.True(t, mock.bounceCalled)
|
||||||
|
assert.Equal(t, BounceInformational, mock.bounceType)
|
||||||
|
requestID, ok := r.Value.(int)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, requestID)
|
||||||
|
require.NotNil(t, received)
|
||||||
|
assert.Equal(t, BounceInformational, received.BounceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskBounce_Critical_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceCritical})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, BounceCritical, mock.bounceType)
|
||||||
|
requestID, ok := r.Value.(int)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskBounce_Bad(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
mock.bounceErr = assert.AnError
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskBounce_Ugly(t *testing.T) {
|
||||||
|
// No dock service — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dock.bounce").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskStopBounce ---
|
||||||
|
|
||||||
|
func TestTaskStopBounce_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
|
||||||
|
// Start a bounce to get a requestID
|
||||||
|
r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
requestID := r.Value.(int)
|
||||||
|
|
||||||
|
r2 := taskRun(c, "dock.stopBounce", TaskStopBounce{RequestID: requestID})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
assert.True(t, mock.stopBounceCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStopBounce_Bad(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
mock.stopBounceErr = assert.AnError
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.stopBounce", TaskStopBounce{RequestID: 1})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStopBounce_Ugly(t *testing.T) {
|
||||||
|
// No dock service — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("dock.stopBounce").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRemoveBadge_Bad(t *testing.T) {
|
||||||
|
_, c, mock := newTestDockService(t)
|
||||||
|
mock.removeErr = assert.AnError
|
||||||
|
|
||||||
|
r := taskRun(c, "dock.removeBadge", TaskRemoveBadge{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryVisible_Ugly(t *testing.T) {
|
||||||
|
// Dock icon initially hidden
|
||||||
|
mock := &mockPlatform{visible: false}
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryVisible{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, false, r.Value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ type TaskOpenFileManager struct {
|
||||||
Select bool `json:"select"`
|
Select bool `json:"select"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryFocusFollowsMouse returns whether the platform uses focus-follows-mouse. Result: bool
|
||||||
|
type QueryFocusFollowsMouse struct{}
|
||||||
|
|
||||||
// ActionThemeChanged is broadcast when the system theme changes.
|
// ActionThemeChanged is broadcast when the system theme changes.
|
||||||
type ActionThemeChanged struct {
|
type ActionThemeChanged struct {
|
||||||
IsDark bool `json:"isDark"`
|
IsDark bool `json:"isDark"`
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ type Platform interface {
|
||||||
Info() EnvironmentInfo
|
Info() EnvironmentInfo
|
||||||
AccentColour() string
|
AccentColour() string
|
||||||
OpenFileManager(path string, selectFile bool) error
|
OpenFileManager(path string, selectFile bool) error
|
||||||
|
HasFocusFollowsMouse() bool
|
||||||
OnThemeChange(handler func(isDark bool)) func() // returns cancel func
|
OnThemeChange(handler func(isDark bool)) func() // returns cancel func
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,55 +4,57 @@ package environment
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the environment service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service providing environment queries and theme change events via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
cancelTheme func() // cancel function for theme change listener
|
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.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
// core.WithService(environment.Register(wailsEnvironment))
|
||||||
return func(c *core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return &Service{
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers and the theme change listener.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("environment.openFileManager", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskOpenFileManager)
|
||||||
|
if err := s.platform.OpenFileManager(t.Path, t.Select); err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
// Register theme change callback — broadcasts ActionThemeChanged via IPC
|
// Register theme change callback — broadcasts ActionThemeChanged via IPC
|
||||||
s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) {
|
s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) {
|
||||||
_ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark})
|
_ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark})
|
||||||
})
|
})
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnShutdown cancels the theme change listener.
|
func (s *Service) OnShutdown(_ context.Context) core.Result {
|
||||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
|
||||||
if s.cancelTheme != nil {
|
if s.cancelTheme != nil {
|
||||||
s.cancelTheme()
|
s.cancelTheme()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryTheme:
|
case QueryTheme:
|
||||||
isDark := s.platform.IsDarkMode()
|
isDark := s.platform.IsDarkMode()
|
||||||
|
|
@ -60,21 +62,14 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
if isDark {
|
if isDark {
|
||||||
theme = "dark"
|
theme = "dark"
|
||||||
}
|
}
|
||||||
return ThemeInfo{IsDark: isDark, Theme: theme}, true, nil
|
return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: theme}, OK: true}
|
||||||
case QueryInfo:
|
case QueryInfo:
|
||||||
return s.platform.Info(), true, nil
|
return core.Result{Value: s.platform.Info(), OK: true}
|
||||||
case QueryAccentColour:
|
case QueryAccentColour:
|
||||||
return s.platform.AccentColour(), true, nil
|
return core.Result{Value: s.platform.AccentColour(), OK: true}
|
||||||
|
case QueryFocusFollowsMouse:
|
||||||
|
return core.Result{Value: s.platform.HasFocusFollowsMouse(), OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,26 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
isDark bool
|
isDark bool
|
||||||
info EnvironmentInfo
|
info EnvironmentInfo
|
||||||
accentColour string
|
accentColour string
|
||||||
openFMErr error
|
openFMErr error
|
||||||
themeHandler func(isDark bool)
|
focusFollowsMouse bool
|
||||||
mu sync.Mutex
|
themeHandler func(isDark bool)
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
|
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
|
||||||
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
|
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
|
||||||
func (m *mockPlatform) AccentColour() string { return m.accentColour }
|
func (m *mockPlatform) AccentColour() string { return m.accentColour }
|
||||||
|
func (m *mockPlatform) HasFocusFollowsMouse() bool { return m.focusFollowsMouse }
|
||||||
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
|
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
|
||||||
return m.openFMErr
|
return m.openFMErr
|
||||||
}
|
}
|
||||||
|
|
@ -57,12 +60,11 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||||
Platform: PlatformInfo{Name: "macOS", Version: "14.0"},
|
Platform: PlatformInfo{Name: "macOS", Version: "14.0"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
return mock, c
|
return mock, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,37 +76,35 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryTheme_Good(t *testing.T) {
|
func TestQueryTheme_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryTheme{})
|
r := c.QUERY(QueryTheme{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
theme := r.Value.(ThemeInfo)
|
||||||
theme := result.(ThemeInfo)
|
|
||||||
assert.True(t, theme.IsDark)
|
assert.True(t, theme.IsDark)
|
||||||
assert.Equal(t, "dark", theme.Theme)
|
assert.Equal(t, "dark", theme.Theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryInfo_Good(t *testing.T) {
|
func TestQueryInfo_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryInfo{})
|
r := c.QUERY(QueryInfo{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
info := r.Value.(EnvironmentInfo)
|
||||||
info := result.(EnvironmentInfo)
|
|
||||||
assert.Equal(t, "darwin", info.OS)
|
assert.Equal(t, "darwin", info.OS)
|
||||||
assert.Equal(t, "arm64", info.Arch)
|
assert.Equal(t, "arm64", info.Arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryAccentColour_Good(t *testing.T) {
|
func TestQueryAccentColour_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryAccentColour{})
|
r := c.QUERY(QueryAccentColour{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, "rgb(0,122,255)", r.Value)
|
||||||
assert.Equal(t, "rgb(0,122,255)", result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenFileManager_Good(t *testing.T) {
|
func TestTaskOpenFileManager_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
_, handled, err := c.PERFORM(TaskOpenFileManager{Path: "/tmp", Select: true})
|
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions(
|
||||||
require.NoError(t, err)
|
core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/tmp", Select: true}},
|
||||||
assert.True(t, handled)
|
))
|
||||||
|
require.True(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
||||||
|
|
@ -113,13 +113,13 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
||||||
// Register a listener that captures the action
|
// Register a listener that captures the action
|
||||||
var received *ActionThemeChanged
|
var received *ActionThemeChanged
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionThemeChanged); ok {
|
if a, ok := msg.(ActionThemeChanged); ok {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
received = &a
|
received = &a
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Simulate theme change
|
// Simulate theme change
|
||||||
|
|
@ -131,3 +131,82 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
||||||
require.NotNil(t, r)
|
require.NotNil(t, r)
|
||||||
assert.False(t, r.IsDark)
|
assert.False(t, r.IsDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GetAccentColor ---
|
||||||
|
|
||||||
|
func TestQueryAccentColour_Bad_Empty(t *testing.T) {
|
||||||
|
// accent colour := "" — still returns handled with empty string
|
||||||
|
mock := &mockPlatform{
|
||||||
|
isDark: false,
|
||||||
|
accentColour: "",
|
||||||
|
info: EnvironmentInfo{OS: "linux", Arch: "amd64"},
|
||||||
|
}
|
||||||
|
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
|
||||||
|
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryAccentColour{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "", r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAccentColour_Ugly_NoService(t *testing.T) {
|
||||||
|
// No environment service — query is unhandled
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.QUERY(QueryAccentColour{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenFileManager ---
|
||||||
|
|
||||||
|
func TestTaskOpenFileManager_Bad_Error(t *testing.T) {
|
||||||
|
// platform returns an error on open
|
||||||
|
openErr := coreerr.E("test", "file manager unavailable", nil)
|
||||||
|
mock := &mockPlatform{openFMErr: openErr}
|
||||||
|
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
|
||||||
|
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
|
||||||
|
|
||||||
|
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/missing", Select: false}},
|
||||||
|
))
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, openErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOpenFileManager_Ugly_NoService(t *testing.T) {
|
||||||
|
// No environment service — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("environment.openFileManager").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HasFocusFollowsMouse ---
|
||||||
|
|
||||||
|
func TestQueryFocusFollowsMouse_Good_True(t *testing.T) {
|
||||||
|
// platform reports focus-follows-mouse enabled (Linux/X11 sloppy focus)
|
||||||
|
mock := &mockPlatform{focusFollowsMouse: true}
|
||||||
|
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
|
||||||
|
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryFocusFollowsMouse{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, true, r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFocusFollowsMouse_Bad_False(t *testing.T) {
|
||||||
|
// platform reports focus-follows-mouse disabled (Windows/macOS default)
|
||||||
|
mock := &mockPlatform{focusFollowsMouse: false}
|
||||||
|
c := core.New(core.WithService(Register(mock)), core.WithServiceLock())
|
||||||
|
require.True(t, c.ServiceStartup(t.Context(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryFocusFollowsMouse{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, false, r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFocusFollowsMouse_Ugly_NoService(t *testing.T) {
|
||||||
|
// No environment service — query is unhandled
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.QUERY(QueryFocusFollowsMouse{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
|
||||||
51
pkg/events/messages.go
Normal file
51
pkg/events/messages.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// pkg/events/messages.go
|
||||||
|
package events
|
||||||
|
|
||||||
|
// All IPC message types for the events service.
|
||||||
|
// Tasks mutate event state; Queries read it; Actions broadcast fired events.
|
||||||
|
|
||||||
|
// TaskEmit fires a named custom event with optional data to all registered listeners.
|
||||||
|
// Result: bool (true if the event was cancelled by a listener)
|
||||||
|
//
|
||||||
|
// c.PERFORM(events.TaskEmit{Name: "user:login", Data: userPayload})
|
||||||
|
type TaskEmit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskOn registers a persistent listener for the named custom event via IPC.
|
||||||
|
// The listener fires an ActionEventFired action for each matching event.
|
||||||
|
// Result: nil (side-effect only; use Off/Reset to remove)
|
||||||
|
//
|
||||||
|
// c.PERFORM(events.TaskOn{Name: "theme:changed"})
|
||||||
|
type TaskOn struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskOff removes all listeners for the named custom event.
|
||||||
|
// Result: nil
|
||||||
|
//
|
||||||
|
// c.PERFORM(events.TaskOff{Name: "theme:changed"})
|
||||||
|
type TaskOff struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryListeners returns a snapshot of all registered listener counts per event name.
|
||||||
|
// Result: []ListenerInfo
|
||||||
|
//
|
||||||
|
// result, _, _ := c.QUERY(events.QueryListeners{})
|
||||||
|
// for _, info := range result.([]events.ListenerInfo) { ... }
|
||||||
|
type QueryListeners struct{}
|
||||||
|
|
||||||
|
// ActionEventFired is broadcast when a registered IPC listener receives an event.
|
||||||
|
// Consumers subscribe via c.RegisterAction to react to platform events.
|
||||||
|
//
|
||||||
|
// c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||||
|
// if fired, ok := msg.(events.ActionEventFired); ok {
|
||||||
|
// handleEvent(fired.Event)
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
// })
|
||||||
|
type ActionEventFired struct {
|
||||||
|
Event CustomEvent `json:"event"`
|
||||||
|
}
|
||||||
34
pkg/events/platform.go
Normal file
34
pkg/events/platform.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// pkg/events/platform.go
|
||||||
|
package events
|
||||||
|
|
||||||
|
// Platform abstracts the Wails EventManager for custom events.
|
||||||
|
//
|
||||||
|
// platform.Emit("user:login", userPayload)
|
||||||
|
// cancel := platform.On("theme:changed", func(e *CustomEvent) { applyTheme(e) })
|
||||||
|
// defer cancel()
|
||||||
|
type Platform interface {
|
||||||
|
Emit(name string, data ...any) bool
|
||||||
|
On(name string, callback func(*CustomEvent)) func()
|
||||||
|
Off(name string)
|
||||||
|
OnMultiple(name string, callback func(*CustomEvent), counter int)
|
||||||
|
Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomEvent is a named event carrying arbitrary data, mirroring the Wails type.
|
||||||
|
//
|
||||||
|
// platform.On("file:saved", func(e *CustomEvent) {
|
||||||
|
// path := e.Data.(string)
|
||||||
|
// })
|
||||||
|
type CustomEvent struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
Sender string `json:"sender,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenerInfo describes a registered listener for QueryListeners results.
|
||||||
|
//
|
||||||
|
// info := ListenerInfo{EventName: "user:login", Count: 3}
|
||||||
|
type ListenerInfo struct {
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
18
pkg/events/register.go
Normal file
18
pkg/events/register.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// pkg/events/register.go
|
||||||
|
package events
|
||||||
|
|
||||||
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
|
// Register binds the events service to a Core instance.
|
||||||
|
//
|
||||||
|
// core.WithService(events.Register(wailsEventPlatform))
|
||||||
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
|
platform: p,
|
||||||
|
listeners: make(map[string][]func()),
|
||||||
|
counts: make(map[string]int),
|
||||||
|
}, OK: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
pkg/events/service.go
Normal file
103
pkg/events/service.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// pkg/events/service.go
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the events service (currently empty).
|
||||||
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service bridges Wails custom events into Core IPC.
|
||||||
|
// Emit/On/Off/OnMultiple/Reset are available as Tasks; QueryListeners reads state.
|
||||||
|
type Service struct {
|
||||||
|
*core.ServiceRuntime[Options]
|
||||||
|
platform Platform
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
listeners map[string][]func() // IPC-registered cancels per event name
|
||||||
|
counts map[string]int // listener counts per event name
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStartup registers query and action handlers.
|
||||||
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
|
s.Core().Action("events.emit", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskEmit)
|
||||||
|
cancelled := s.platform.Emit(t.Name, t.Data)
|
||||||
|
return core.Result{Value: cancelled, OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("events.on", func(ctx context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskOn)
|
||||||
|
if t.Name == "" {
|
||||||
|
return core.Result{Value: coreerr.E("events.on", "event name must not be empty", nil), OK: false}
|
||||||
|
}
|
||||||
|
cancel := s.platform.On(t.Name, func(event *CustomEvent) {
|
||||||
|
_ = s.Core().ACTION(ActionEventFired{Event: *event})
|
||||||
|
})
|
||||||
|
s.mu.Lock()
|
||||||
|
s.listeners[t.Name] = append(s.listeners[t.Name], cancel)
|
||||||
|
s.counts[t.Name]++
|
||||||
|
s.mu.Unlock()
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("events.off", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskOff)
|
||||||
|
s.platform.Off(t.Name)
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, cancel := range s.listeners[t.Name] {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
delete(s.listeners, t.Name)
|
||||||
|
delete(s.counts, t.Name)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnShutdown cancels all IPC-registered platform listeners.
|
||||||
|
func (s *Service) OnShutdown(_ context.Context) core.Result {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, cancels := range s.listeners {
|
||||||
|
for _, cancel := range cancels {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.listeners = make(map[string][]func())
|
||||||
|
s.counts = make(map[string]int)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents satisfies the core.Service interface (no-op for now).
|
||||||
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
|
switch q.(type) {
|
||||||
|
case QueryListeners:
|
||||||
|
return core.Result{Value: s.listenerSnapshot(), OK: true}
|
||||||
|
default:
|
||||||
|
return core.Result{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenerSnapshot returns a sorted slice of ListenerInfo for all known event names.
|
||||||
|
//
|
||||||
|
// snapshot := s.listenerSnapshot()
|
||||||
|
// for _, info := range snapshot { log(info.EventName, info.Count) }
|
||||||
|
func (s *Service) listenerSnapshot() []ListenerInfo {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
snapshot := make([]ListenerInfo, 0, len(s.counts))
|
||||||
|
for name, count := range s.counts {
|
||||||
|
snapshot = append(snapshot, ListenerInfo{EventName: name, Count: count})
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
358
pkg/events/service_test.go
Normal file
358
pkg/events/service_test.go
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
// pkg/events/service_test.go
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mock Platform ---
|
||||||
|
|
||||||
|
type mockPlatform struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
listeners map[string][]*mockListener
|
||||||
|
emitted []CustomEvent
|
||||||
|
resetCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockListener struct {
|
||||||
|
callback func(*CustomEvent)
|
||||||
|
counter int // -1 = persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockPlatform() *mockPlatform {
|
||||||
|
return &mockPlatform{
|
||||||
|
listeners: make(map[string][]*mockListener),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) Emit(name string, data ...any) bool {
|
||||||
|
event := &CustomEvent{Name: name}
|
||||||
|
if len(data) == 1 {
|
||||||
|
event.Data = data[0]
|
||||||
|
} else if len(data) > 1 {
|
||||||
|
event.Data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.emitted = append(m.emitted, *event)
|
||||||
|
active := make([]*mockListener, len(m.listeners[name]))
|
||||||
|
copy(active, m.listeners[name])
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, listener := range active {
|
||||||
|
listener.callback(event)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() {
|
||||||
|
listener := &mockListener{callback: callback, counter: -1}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.listeners[name] = append(m.listeners[name], listener)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
updated := m.listeners[name][:0]
|
||||||
|
for _, existing := range m.listeners[name] {
|
||||||
|
if existing != listener {
|
||||||
|
updated = append(updated, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.listeners[name] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) Off(name string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.listeners, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.listeners[name] = append(m.listeners[name], &mockListener{callback: callback, counter: counter})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) Reset() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.listeners = make(map[string][]*mockListener)
|
||||||
|
m.resetCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// simulateEvent fires all registered listeners for the given event name with optional data.
|
||||||
|
func (m *mockPlatform) simulateEvent(name string, data any) {
|
||||||
|
event := &CustomEvent{Name: name, Data: data}
|
||||||
|
m.mu.Lock()
|
||||||
|
active := make([]*mockListener, len(m.listeners[name]))
|
||||||
|
copy(active, m.listeners[name])
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, listener := range active {
|
||||||
|
listener.callback(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenerCount returns the total number of registered listeners across all event names.
|
||||||
|
func (m *mockPlatform) listenerCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
total := 0
|
||||||
|
for _, listeners := range m.listeners {
|
||||||
|
total += len(listeners)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test helpers ---
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
||||||
|
t.Helper()
|
||||||
|
mock := newMockPlatform()
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
svc := core.MustServiceFor[*Service](c, "events")
|
||||||
|
return svc, c, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Good path tests ---
|
||||||
|
|
||||||
|
func TestRegister_Good(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
assert.NotNil(t, svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskEmit_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "events.emit", TaskEmit{Name: "user:login", Data: "alice"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, false, r.Value) // not cancelled
|
||||||
|
|
||||||
|
assert.Len(t, mock.emitted, 1)
|
||||||
|
assert.Equal(t, "user:login", mock.emitted[0].Name)
|
||||||
|
assert.Equal(t, "alice", mock.emitted[0].Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskEmit_NoData_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "events.emit", TaskEmit{Name: "ping"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Len(t, mock.emitted, 1)
|
||||||
|
assert.Nil(t, mock.emitted[0].Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOn_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
var received []ActionEventFired
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if fired, ok := msg.(ActionEventFired); ok {
|
||||||
|
received = append(received, fired)
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
mock.simulateEvent("theme:changed", "dark")
|
||||||
|
|
||||||
|
assert.Len(t, received, 1)
|
||||||
|
assert.Equal(t, "theme:changed", received[0].Event.Name)
|
||||||
|
assert.Equal(t, "dark", received[0].Event.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOff_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
// Register via IPC then remove
|
||||||
|
r := taskRun(c, "events.on", TaskOn{Name: "file:saved"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, 1, mock.listenerCount())
|
||||||
|
|
||||||
|
r2 := taskRun(c, "events.off", TaskOff{Name: "file:saved"})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
assert.Equal(t, 0, mock.listenerCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListeners_Good(t *testing.T) {
|
||||||
|
_, c, _ := newTestService(t)
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
|
||||||
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
|
||||||
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "theme:changed"}).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryListeners{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
infos := r.Value.([]ListenerInfo)
|
||||||
|
counts := make(map[string]int)
|
||||||
|
for _, info := range infos {
|
||||||
|
counts[info.EventName] = info.Count
|
||||||
|
}
|
||||||
|
assert.Equal(t, 2, counts["user:login"])
|
||||||
|
assert.Equal(t, 1, counts["theme:changed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListeners_Empty_Good(t *testing.T) {
|
||||||
|
_, c, _ := newTestService(t)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryListeners{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
infos := r.Value.([]ListenerInfo)
|
||||||
|
assert.Empty(t, infos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnShutdown_CancelsAll_Good(t *testing.T) {
|
||||||
|
svc, _, mock := newTestService(t)
|
||||||
|
|
||||||
|
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "a:b"}).OK)
|
||||||
|
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "c:d"}).OK)
|
||||||
|
assert.Equal(t, 2, mock.listenerCount())
|
||||||
|
|
||||||
|
require.True(t, svc.OnShutdown(context.Background()).OK)
|
||||||
|
assert.Equal(t, 0, mock.listenerCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionEventFired_BroadcastOnSimulate_Good(t *testing.T) {
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
var receivedEvents []CustomEvent
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if fired, ok := msg.(ActionEventFired); ok {
|
||||||
|
receivedEvents = append(receivedEvents, fired.Event)
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "events.on", TaskOn{Name: "data:ready"}).OK)
|
||||||
|
|
||||||
|
mock.simulateEvent("data:ready", map[string]any{"rows": 42})
|
||||||
|
|
||||||
|
require.Len(t, receivedEvents, 1)
|
||||||
|
assert.Equal(t, "data:ready", receivedEvents[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bad path tests ---
|
||||||
|
|
||||||
|
func TestTaskOn_EmptyName_Bad(t *testing.T) {
|
||||||
|
_, c, _ := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "events.on", TaskOn{Name: ""})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskEmit_UnknownEvent_Bad(t *testing.T) {
|
||||||
|
// Emitting an event with no listeners is valid — returns not-cancelled.
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "events.emit", TaskEmit{Name: "no:listeners"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, false, r.Value)
|
||||||
|
assert.Len(t, mock.emitted, 1) // still recorded as emitted
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListeners_NoService_Bad(t *testing.T) {
|
||||||
|
// No events service registered — query is not handled.
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
|
||||||
|
r := c.QUERY(QueryListeners{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskEmit_NoService_Bad(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
|
||||||
|
r := c.Action("events.emit").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ugly path tests ---
|
||||||
|
|
||||||
|
func TestTaskOff_NeverRegistered_Ugly(t *testing.T) {
|
||||||
|
// Off on a name that was never registered is a no-op — must not panic.
|
||||||
|
_, c, _ := newTestService(t)
|
||||||
|
|
||||||
|
r := taskRun(c, "events.off", TaskOff{Name: "nonexistent:event"})
|
||||||
|
assert.True(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOn_MultipleListeners_Ugly(t *testing.T) {
|
||||||
|
// Multiple IPC listeners for the same event each receive ActionEventFired.
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var fireCount int
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if _, ok := msg.(ActionEventFired); ok {
|
||||||
|
mu.Lock()
|
||||||
|
fireCount++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
||||||
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
||||||
|
taskRun(c, "events.on", TaskOn{Name: "flood"})
|
||||||
|
|
||||||
|
mock.simulateEvent("flood", nil)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
count := fireCount
|
||||||
|
mu.Unlock()
|
||||||
|
assert.Equal(t, 3, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskOff_ThenEmit_Ugly(t *testing.T) {
|
||||||
|
// After Off, simulating the event must not trigger any IPC actions.
|
||||||
|
_, c, mock := newTestService(t)
|
||||||
|
|
||||||
|
var received bool
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if _, ok := msg.(ActionEventFired); ok {
|
||||||
|
received = true
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
taskRun(c, "events.on", TaskOn{Name: "transient"})
|
||||||
|
taskRun(c, "events.off", TaskOff{Name: "transient"})
|
||||||
|
|
||||||
|
mock.simulateEvent("transient", "late-data")
|
||||||
|
assert.False(t, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListeners_AfterOff_Ugly(t *testing.T) {
|
||||||
|
// After Off, the event name must not appear in QueryListeners results.
|
||||||
|
_, c, _ := newTestService(t)
|
||||||
|
|
||||||
|
taskRun(c, "events.on", TaskOn{Name: "ephemeral"})
|
||||||
|
taskRun(c, "events.off", TaskOff{Name: "ephemeral"})
|
||||||
|
|
||||||
|
r := c.QUERY(QueryListeners{})
|
||||||
|
infos := r.Value.([]ListenerInfo)
|
||||||
|
|
||||||
|
for _, info := range infos {
|
||||||
|
assert.NotEqual(t, "ephemeral", info.EventName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,39 @@
|
||||||
// pkg/keybinding/messages.go
|
|
||||||
package keybinding
|
package keybinding
|
||||||
|
|
||||||
import "errors"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// ErrAlreadyRegistered is returned when attempting to add a binding
|
var ErrorAlreadyRegistered = core.E("keybinding", "accelerator already registered", nil)
|
||||||
// that already exists. Callers must TaskRemove first to rebind.
|
var ErrorNotRegistered = core.E("keybinding", "accelerator not registered", nil)
|
||||||
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
|
|
||||||
|
|
||||||
// BindingInfo describes a registered keyboard shortcut.
|
// BindingInfo describes a registered global key binding.
|
||||||
type BindingInfo struct {
|
type BindingInfo struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Queries ---
|
// QueryList returns all registered key bindings. Result: []BindingInfo
|
||||||
|
|
||||||
// QueryList returns all registered bindings. Result: []BindingInfo
|
|
||||||
type QueryList struct{}
|
type QueryList struct{}
|
||||||
|
|
||||||
// --- Tasks ---
|
// TaskAdd registers a global key binding. Error: ErrorAlreadyRegistered if accelerator taken.
|
||||||
|
|
||||||
// TaskAdd registers a new keyboard shortcut. Result: nil
|
|
||||||
// Returns ErrAlreadyRegistered if the accelerator is already bound.
|
|
||||||
type TaskAdd struct {
|
type TaskAdd struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskRemove unregisters a keyboard shortcut. Result: nil
|
// TaskRemove unregisters a global key binding by accelerator. Error: ErrorNotRegistered if not found.
|
||||||
type TaskRemove struct {
|
type TaskRemove struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// TaskProcess triggers a registered key binding programmatically.
|
||||||
|
// Returns ActionTriggered if the accelerator was handled, ErrorNotRegistered if not found.
|
||||||
|
//
|
||||||
|
// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"})
|
||||||
|
type TaskProcess struct {
|
||||||
|
Accelerator string `json:"accelerator"`
|
||||||
|
}
|
||||||
|
|
||||||
// ActionTriggered is broadcast when a registered shortcut is activated.
|
// ActionTriggered is broadcast when a registered key binding fires.
|
||||||
type ActionTriggered struct {
|
type ActionTriggered struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ type Platform interface {
|
||||||
// Remove unregisters a previously registered keyboard shortcut.
|
// Remove unregisters a previously registered keyboard shortcut.
|
||||||
Remove(accelerator string) error
|
Remove(accelerator string) error
|
||||||
|
|
||||||
|
// Process triggers the registered handler for the given accelerator programmatically.
|
||||||
|
// Returns true if a handler was found and invoked, false if not registered.
|
||||||
|
//
|
||||||
|
// handled := platform.Process("Ctrl+S")
|
||||||
|
Process(accelerator string) bool
|
||||||
|
|
||||||
// GetAll returns all currently registered accelerator strings.
|
// GetAll returns all currently registered accelerator strings.
|
||||||
// Used for adapter-level reconciliation only — not read by QueryList.
|
// Used for adapter-level reconciliation only — not read by QueryList.
|
||||||
GetAll() []string
|
GetAll() []string
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
// pkg/keybinding/register.go
|
|
||||||
package keybinding
|
package keybinding
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the keybinding service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(keybinding.Register(wailsKeybinding))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
bindings: make(map[string]BindingInfo),
|
registeredBindings: make(map[string]BindingInfo),
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,71 +3,62 @@ package keybinding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the keybinding service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing keyboard shortcuts via IPC.
|
|
||||||
// It maintains an in-memory registry of bindings and delegates
|
|
||||||
// platform-level registration to the Platform interface.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
bindings map[string]BindingInfo
|
registeredBindings map[string]BindingInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("keybinding.add", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
t, _ := opts.Get("task").Value.(TaskAdd)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskAdd(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("keybinding.remove", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskRemove)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskRemove(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("keybinding.process", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskProcess)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskProcess(t))
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Query Handlers ---
|
// --- Query Handlers ---
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryList:
|
case QueryList:
|
||||||
return s.queryList(), true, nil
|
return core.Result{Value: s.queryList(), OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryList reads from the in-memory registry (not platform.GetAll()).
|
|
||||||
func (s *Service) queryList() []BindingInfo {
|
func (s *Service) queryList() []BindingInfo {
|
||||||
result := make([]BindingInfo, 0, len(s.bindings))
|
result := make([]BindingInfo, 0, len(s.registeredBindings))
|
||||||
for _, info := range s.bindings {
|
for _, info := range s.registeredBindings {
|
||||||
result = append(result, info)
|
result = append(result, info)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Task Handlers ---
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskAdd:
|
|
||||||
return nil, true, s.taskAdd(t)
|
|
||||||
case TaskRemove:
|
|
||||||
return nil, true, s.taskRemove(t)
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) taskAdd(t TaskAdd) error {
|
func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
if _, exists := s.bindings[t.Accelerator]; exists {
|
if _, exists := s.registeredBindings[t.Accelerator]; exists {
|
||||||
return ErrAlreadyRegistered
|
return ErrorAlreadyRegistered
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register on platform with a callback that broadcasts ActionTriggered
|
// Register on platform with a callback that broadcasts ActionTriggered
|
||||||
|
|
@ -75,10 +66,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 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,
|
Accelerator: t.Accelerator,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
}
|
}
|
||||||
|
|
@ -86,15 +77,32 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskRemove(t TaskRemove) error {
|
func (s *Service) taskRemove(t TaskRemove) error {
|
||||||
if _, exists := s.bindings[t.Accelerator]; !exists {
|
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
||||||
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
|
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.platform.Remove(t.Accelerator)
|
err := s.platform.Remove(t.Accelerator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("keybinding: platform remove failed: %w", err)
|
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.registeredBindings, t.Accelerator)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskProcess triggers the registered handler for the given accelerator programmatically.
|
||||||
|
// Broadcasts ActionTriggered if handled; returns ErrorNotRegistered if the accelerator is unknown.
|
||||||
|
//
|
||||||
|
// c.Action("keybinding.process").Run(ctx, core.NewOptions(core.Option{Key:"task", Value:keybinding.TaskProcess{Accelerator:"Ctrl+S"}}))
|
||||||
|
func (s *Service) taskProcess(t TaskProcess) error {
|
||||||
|
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
||||||
|
return coreerr.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered)
|
||||||
|
}
|
||||||
|
|
||||||
|
handled := s.platform.Process(t.Accelerator)
|
||||||
|
if !handled {
|
||||||
|
return coreerr.E("keybinding.taskProcess", "platform did not handle: "+t.Accelerator, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(s.bindings, t.Accelerator)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -37,6 +37,17 @@ func (m *mockPlatform) Remove(accelerator string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockPlatform) Process(accelerator string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
h, ok := m.handlers[accelerator]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ok && h != nil {
|
||||||
|
h()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) GetAll() []string {
|
func (m *mockPlatform) GetAll() []string {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
@ -59,16 +70,21 @@ func (m *mockPlatform) trigger(accelerator string) {
|
||||||
|
|
||||||
func newTestKeybindingService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
func newTestKeybindingService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mp)),
|
core.WithService(Register(mp)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "keybinding")
|
svc := core.MustServiceFor[*Service](c, "keybinding")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
svc, _ := newTestKeybindingService(t, mp)
|
svc, _ := newTestKeybindingService(t, mp)
|
||||||
|
|
@ -80,11 +96,10 @@ func TestTaskAdd_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskAdd{
|
r := taskRun(c, "keybinding.add", TaskAdd{
|
||||||
Accelerator: "Ctrl+S", Description: "Save",
|
Accelerator: "Ctrl+S", Description: "Save",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify binding registered on platform
|
// Verify binding registered on platform
|
||||||
assert.Contains(t, mp.GetAll(), "Ctrl+S")
|
assert.Contains(t, mp.GetAll(), "Ctrl+S")
|
||||||
|
|
@ -94,22 +109,22 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||||
|
|
||||||
// Second add with same accelerator should fail
|
// Second add with same accelerator should fail
|
||||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.ErrorIs(t, err, ErrAlreadyRegistered)
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskRemove_Good(t *testing.T) {
|
func TestTaskRemove_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||||
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
|
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify removed from platform
|
// Verify removed from platform
|
||||||
assert.NotContains(t, mp.GetAll(), "Ctrl+S")
|
assert.NotContains(t, mp.GetAll(), "Ctrl+S")
|
||||||
|
|
@ -119,22 +134,20 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"})
|
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryList_Good(t *testing.T) {
|
func TestQueryList_Good(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
list := r.Value.([]BindingInfo)
|
||||||
list := result.([]BindingInfo)
|
|
||||||
assert.Len(t, list, 2)
|
assert.Len(t, list, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,10 +155,9 @@ func TestQueryList_Good_Empty(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
list := r.Value.([]BindingInfo)
|
||||||
list := result.([]BindingInfo)
|
|
||||||
assert.Len(t, list, 0)
|
assert.Len(t, list, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,16 +168,16 @@ func TestTaskAdd_Good_TriggerBroadcast(t *testing.T) {
|
||||||
// Capture broadcast actions
|
// Capture broadcast actions
|
||||||
var triggered ActionTriggered
|
var triggered ActionTriggered
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionTriggered); ok {
|
if a, ok := msg.(ActionTriggered); ok {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
triggered = a
|
triggered = a
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||||
|
|
||||||
// Simulate shortcut trigger via mock
|
// Simulate shortcut trigger via mock
|
||||||
mp.trigger("Ctrl+S")
|
mp.trigger("Ctrl+S")
|
||||||
|
|
@ -179,23 +191,108 @@ func TestTaskAdd_Good_RebindAfterRemove(t *testing.T) {
|
||||||
mp := newMockPlatform()
|
mp := newMockPlatform()
|
||||||
_, c := newTestKeybindingService(t, mp)
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"})
|
||||||
_, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+S"})
|
taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"})
|
||||||
|
|
||||||
// Should succeed after remove
|
// Should succeed after remove
|
||||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"})
|
r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify new description
|
// Verify new description
|
||||||
result, _, _ := c.QUERY(QueryList{})
|
r2 := c.QUERY(QueryList{})
|
||||||
list := result.([]BindingInfo)
|
list := r2.Value.([]BindingInfo)
|
||||||
assert.Len(t, list, 1)
|
assert.Len(t, list, 1)
|
||||||
assert.Equal(t, "Save v2", list[0].Description)
|
assert.Equal(t, "Save v2", list[0].Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryList_Bad_NoService(t *testing.T) {
|
func TestQueryList_Bad_NoService(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.QUERY(QueryList{})
|
r := c.QUERY(QueryList{})
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskProcess tests ---
|
||||||
|
|
||||||
|
func TestTaskProcess_Good(t *testing.T) {
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
|
||||||
|
|
||||||
|
var triggered ActionTriggered
|
||||||
|
var mu sync.Mutex
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if a, ok := msg.(ActionTriggered); ok {
|
||||||
|
mu.Lock()
|
||||||
|
triggered = a
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(t, "Ctrl+P", triggered.Accelerator)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskProcess_Bad_NotRegistered(t *testing.T) {
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
|
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorNotRegistered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskProcess_Ugly_RemovedBinding(t *testing.T) {
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
|
||||||
|
taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+P"})
|
||||||
|
|
||||||
|
// After remove, process should fail with ErrorNotRegistered
|
||||||
|
r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorNotRegistered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskRemove ErrorNotRegistered sentinel tests ---
|
||||||
|
|
||||||
|
func TestTaskRemove_Bad_ErrorSentinel(t *testing.T) {
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
|
r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
err, _ := r.Value.(error)
|
||||||
|
assert.ErrorIs(t, err, ErrorNotRegistered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QueryList Ugly: concurrent adds ---
|
||||||
|
|
||||||
|
func TestQueryList_Ugly_ConcurrentAdds(t *testing.T) {
|
||||||
|
mp := newMockPlatform()
|
||||||
|
_, c := newTestKeybindingService(t, mp)
|
||||||
|
|
||||||
|
accelerators := []string{"Ctrl+1", "Ctrl+2", "Ctrl+3", "Ctrl+4", "Ctrl+5"}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, accelerator := range accelerators {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(acc string) {
|
||||||
|
defer wg.Done()
|
||||||
|
taskRun(c, "keybinding.add", TaskAdd{Accelerator: acc, Description: acc})
|
||||||
|
}(accelerator)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
r := c.QUERY(QueryList{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
list := r.Value.([]BindingInfo)
|
||||||
|
assert.Len(t, list, len(accelerators))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
// pkg/lifecycle/register.go
|
|
||||||
package lifecycle
|
package lifecycle
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the lifecycle service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(lifecycle.Register(wailsLifecycle))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,20 @@
|
||||||
// pkg/lifecycle/service.go
|
|
||||||
package lifecycle
|
package lifecycle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the lifecycle service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service that registers platform lifecycle callbacks
|
|
||||||
// and broadcasts corresponding IPC Actions. It implements both Startable
|
|
||||||
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
cancels []func()
|
cancels []func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers a platform callback for each EventType and for file-open.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
|
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
// Register fire-and-forget event callbacks
|
|
||||||
eventActions := map[EventType]func(){
|
eventActions := map[EventType]func(){
|
||||||
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
||||||
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
||||||
|
|
@ -38,26 +30,22 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.cancels = append(s.cancels, cancel)
|
s.cancels = append(s.cancels, cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register file-open callback (carries data)
|
|
||||||
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
||||||
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
||||||
})
|
})
|
||||||
s.cancels = append(s.cancels, cancel)
|
s.cancels = append(s.cancels, cancel)
|
||||||
|
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnShutdown cancels all registered platform callbacks.
|
func (s *Service) OnShutdown(_ context.Context) core.Result {
|
||||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
|
||||||
for _, cancel := range s.cancels {
|
for _, cancel := range s.cancels {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
s.cancels = nil
|
s.cancels = nil
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
|
return core.Result{OK: true}
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -91,12 +91,11 @@ func (m *mockPlatform) handlerCount() int {
|
||||||
func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
func newTestLifecycleService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mock := newMockPlatform()
|
mock := newMockPlatform()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "lifecycle")
|
svc := core.MustServiceFor[*Service](c, "lifecycle")
|
||||||
return svc, c, mock
|
return svc, c, mock
|
||||||
}
|
}
|
||||||
|
|
@ -112,11 +111,11 @@ func TestApplicationStarted_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionApplicationStarted); ok {
|
if _, ok := msg.(ActionApplicationStarted); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventApplicationStarted)
|
mock.simulateEvent(EventApplicationStarted)
|
||||||
|
|
@ -127,11 +126,11 @@ func TestDidBecomeActive_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionDidBecomeActive); ok {
|
if _, ok := msg.(ActionDidBecomeActive); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventDidBecomeActive)
|
mock.simulateEvent(EventDidBecomeActive)
|
||||||
|
|
@ -142,11 +141,11 @@ func TestDidResignActive_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionDidResignActive); ok {
|
if _, ok := msg.(ActionDidResignActive); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventDidResignActive)
|
mock.simulateEvent(EventDidResignActive)
|
||||||
|
|
@ -157,11 +156,11 @@ func TestWillTerminate_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionWillTerminate); ok {
|
if _, ok := msg.(ActionWillTerminate); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventWillTerminate)
|
mock.simulateEvent(EventWillTerminate)
|
||||||
|
|
@ -172,11 +171,11 @@ func TestPowerStatusChanged_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionPowerStatusChanged); ok {
|
if _, ok := msg.(ActionPowerStatusChanged); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventPowerStatusChanged)
|
mock.simulateEvent(EventPowerStatusChanged)
|
||||||
|
|
@ -187,11 +186,11 @@ func TestSystemSuspend_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionSystemSuspend); ok {
|
if _, ok := msg.(ActionSystemSuspend); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventSystemSuspend)
|
mock.simulateEvent(EventSystemSuspend)
|
||||||
|
|
@ -202,11 +201,11 @@ func TestSystemResume_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionSystemResume); ok {
|
if _, ok := msg.(ActionSystemResume); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateEvent(EventSystemResume)
|
mock.simulateEvent(EventSystemResume)
|
||||||
|
|
@ -217,11 +216,11 @@ func TestOpenedWithFile_Good(t *testing.T) {
|
||||||
_, c, mock := newTestLifecycleService(t)
|
_, c, mock := newTestLifecycleService(t)
|
||||||
|
|
||||||
var receivedPath string
|
var receivedPath string
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionOpenedWithFile); ok {
|
if a, ok := msg.(ActionOpenedWithFile); ok {
|
||||||
receivedPath = a.Path
|
receivedPath = a.Path
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.simulateFileOpen("/Users/snider/Documents/test.txt")
|
mock.simulateFileOpen("/Users/snider/Documents/test.txt")
|
||||||
|
|
@ -235,23 +234,21 @@ func TestOnShutdown_CancelsAll_Good(t *testing.T) {
|
||||||
assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup")
|
assert.Greater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup")
|
||||||
|
|
||||||
// Shutdown should cancel all registrations
|
// Shutdown should cancel all registrations
|
||||||
err := svc.OnShutdown(context.Background())
|
require.True(t, svc.OnShutdown(context.Background()).OK)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown")
|
assert.Equal(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegister_Bad(t *testing.T) {
|
func TestRegister_Bad(t *testing.T) {
|
||||||
// No lifecycle service registered — actions are not received
|
// No lifecycle service registered — actions are not received
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var received bool
|
var received bool
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if _, ok := msg.(ActionApplicationStarted); ok {
|
if _, ok := msg.(ActionApplicationStarted); ok {
|
||||||
received = true
|
received = true
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
// No way to trigger events without the service
|
// No way to trigger events without the service
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -13,13 +13,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubsystem_Good_Name(t *testing.T) {
|
func TestSubsystem_Good_Name(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
sub := New(c)
|
sub := New(c)
|
||||||
assert.Equal(t, "display", sub.Name())
|
assert.Equal(t, "display", sub.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
sub := New(c)
|
sub := New(c)
|
||||||
// RegisterTools should not panic with a real mcp.Server
|
// RegisterTools should not panic with a real mcp.Server
|
||||||
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
|
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
|
||||||
|
|
@ -37,25 +37,23 @@ func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
|
||||||
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
|
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
|
||||||
|
|
||||||
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
|
|
||||||
// Verify the IPC path that clipboard_read tool handler uses
|
// Verify the IPC path that clipboard_read tool handler uses
|
||||||
result, handled, err := c.QUERY(clipboard.QueryText{})
|
r := c.QUERY(clipboard.QueryText{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
content, ok := r.Value.(clipboard.ClipboardContent)
|
||||||
content, ok := result.(clipboard.ClipboardContent)
|
|
||||||
require.True(t, ok, "expected ClipboardContent type")
|
require.True(t, ok, "expected ClipboardContent type")
|
||||||
assert.Equal(t, "hello", content.Text)
|
assert.Equal(t, "hello", content.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMCP_Bad_NoServices(t *testing.T) {
|
func TestMCP_Bad_NoServices(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
// Without any services, QUERY should return handled=false
|
// Without any services, QUERY should return OK=false
|
||||||
_, handled, _ := c.QUERY(clipboard.QueryText{})
|
r := c.QUERY(clipboard.QueryText{})
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,23 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subsystem implements the MCP Subsystem interface via structural typing.
|
// Subsystem translates MCP tool calls to Core IPC messages for GUI operations.
|
||||||
// It registers GUI tools that translate MCP tool calls to IPC messages.
|
|
||||||
type Subsystem struct {
|
type Subsystem struct {
|
||||||
core *core.Core
|
core *core.Core
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a display MCP subsystem backed by the given Core instance.
|
// New(c) creates a display MCP subsystem backed by a Core instance.
|
||||||
|
// sub := mcp.New(c); sub.RegisterTools(server)
|
||||||
func New(c *core.Core) *Subsystem {
|
func New(c *core.Core) *Subsystem {
|
||||||
return &Subsystem{core: c}
|
return &Subsystem{core: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the subsystem identifier.
|
|
||||||
func (s *Subsystem) Name() string { return "display" }
|
func (s *Subsystem) Name() string { return "display" }
|
||||||
|
|
||||||
// RegisterTools registers all GUI tools with the MCP server.
|
|
||||||
func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
||||||
s.registerWebviewTools(server)
|
s.registerWebviewTools(server)
|
||||||
s.registerWindowTools(server)
|
s.registerWindowTools(server)
|
||||||
|
|
@ -36,4 +34,5 @@ func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
||||||
s.registerKeybindingTools(server)
|
s.registerKeybindingTools(server)
|
||||||
s.registerDockTools(server)
|
s.registerDockTools(server)
|
||||||
s.registerLifecycleTools(server)
|
s.registerLifecycleTools(server)
|
||||||
|
s.registerEventsTools(server)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/gui/pkg/browser"
|
core "dappco.re/go/core"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,9 +18,14 @@ type BrowserOpenURLOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) browserOpenURL(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenURLInput) (*mcp.CallToolResult, BrowserOpenURLOutput, error) {
|
func (s *Subsystem) browserOpenURL(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenURLInput) (*mcp.CallToolResult, BrowserOpenURLOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(browser.TaskOpenURL{URL: input.URL})
|
r := s.core.Action("browser.openURL").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "url", Value: input.URL},
|
||||||
return nil, BrowserOpenURLOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, BrowserOpenURLOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, BrowserOpenURLOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, BrowserOpenURLOutput{Success: true}, nil
|
return nil, BrowserOpenURLOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -17,13 +18,16 @@ type ClipboardReadOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, error) {
|
func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, error) {
|
||||||
result, _, err := s.core.QUERY(clipboard.QueryText{})
|
r := s.core.QUERY(clipboard.QueryText{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ClipboardReadOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ClipboardReadOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ClipboardReadOutput{}, nil
|
||||||
}
|
}
|
||||||
content, ok := result.(clipboard.ClipboardContent)
|
content, ok := r.Value.(clipboard.ClipboardContent)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ClipboardReadOutput{}, 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
|
return nil, ClipboardReadOutput{Content: content.Text}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -38,15 +42,16 @@ type ClipboardWriteOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteInput) (*mcp.CallToolResult, ClipboardWriteOutput, error) {
|
func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteInput) (*mcp.CallToolResult, ClipboardWriteOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(clipboard.TaskSetText{Text: input.Text})
|
r := s.core.Action("clipboard.setText").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: clipboard.TaskSetText{Text: input.Text}},
|
||||||
return nil, ClipboardWriteOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ClipboardWriteOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ClipboardWriteOutput{}, nil
|
||||||
}
|
}
|
||||||
success, ok := result.(bool)
|
return nil, ClipboardWriteOutput{Success: true}, nil
|
||||||
if !ok {
|
|
||||||
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
|
|
||||||
}
|
|
||||||
return nil, ClipboardWriteOutput{Success: success}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- clipboard_has ---
|
// --- clipboard_has ---
|
||||||
|
|
@ -57,13 +62,13 @@ type ClipboardHasOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardHasInput) (*mcp.CallToolResult, ClipboardHasOutput, error) {
|
func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardHasInput) (*mcp.CallToolResult, ClipboardHasOutput, error) {
|
||||||
result, _, err := s.core.QUERY(clipboard.QueryText{})
|
r := s.core.QUERY(clipboard.QueryText{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ClipboardHasOutput{}, err
|
return nil, ClipboardHasOutput{}, nil
|
||||||
}
|
}
|
||||||
content, ok := result.(clipboard.ClipboardContent)
|
content, ok := r.Value.(clipboard.ClipboardContent)
|
||||||
if !ok {
|
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
|
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -76,15 +81,14 @@ type ClipboardClearOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardClearInput) (*mcp.CallToolResult, ClipboardClearOutput, error) {
|
func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardClearInput) (*mcp.CallToolResult, ClipboardClearOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(clipboard.TaskClear{})
|
r := s.core.Action("clipboard.clear").Run(context.Background(), core.NewOptions())
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ClipboardClearOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ClipboardClearOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ClipboardClearOutput{}, nil
|
||||||
}
|
}
|
||||||
success, ok := result.(bool)
|
return nil, ClipboardClearOutput{Success: true}, nil
|
||||||
if !ok {
|
|
||||||
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
|
|
||||||
}
|
|
||||||
return nil, ClipboardClearOutput{Success: success}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -25,17 +25,24 @@ type ContextMenuAddOutput struct {
|
||||||
|
|
||||||
func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) {
|
func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) {
|
||||||
// Convert map[string]any to ContextMenuDef via JSON round-trip
|
// Convert map[string]any to ContextMenuDef via JSON round-trip
|
||||||
menuJSON, err := json.Marshal(input.Menu)
|
marshalResult := core.JSONMarshal(input.Menu)
|
||||||
if err != nil {
|
if !marshalResult.OK {
|
||||||
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
|
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", marshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
|
menuJSON := marshalResult.Value.([]byte)
|
||||||
var menuDef contextmenu.ContextMenuDef
|
var menuDef contextmenu.ContextMenuDef
|
||||||
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
|
unmarshalResult := core.JSONUnmarshal(menuJSON, &menuDef)
|
||||||
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
|
if !unmarshalResult.OK {
|
||||||
|
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", unmarshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
|
r := s.core.Action("contextmenu.add").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: contextmenu.TaskAdd{Name: input.Name, Menu: menuDef}},
|
||||||
return nil, ContextMenuAddOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ContextMenuAddOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ContextMenuAddOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, ContextMenuAddOutput{Success: true}, nil
|
return nil, ContextMenuAddOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -50,9 +57,14 @@ type ContextMenuRemoveOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) contextMenuRemove(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuRemoveInput) (*mcp.CallToolResult, ContextMenuRemoveOutput, error) {
|
func (s *Subsystem) contextMenuRemove(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuRemoveInput) (*mcp.CallToolResult, ContextMenuRemoveOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(contextmenu.TaskRemove{Name: input.Name})
|
r := s.core.Action("contextmenu.remove").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: contextmenu.TaskRemove{Name: input.Name}},
|
||||||
return nil, ContextMenuRemoveOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ContextMenuRemoveOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ContextMenuRemoveOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, ContextMenuRemoveOutput{Success: true}, nil
|
return nil, ContextMenuRemoveOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -67,25 +79,30 @@ type ContextMenuGetOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, error) {
|
func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, error) {
|
||||||
result, _, err := s.core.QUERY(contextmenu.QueryGet{Name: input.Name})
|
r := s.core.QUERY(contextmenu.QueryGet{Name: input.Name})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ContextMenuGetOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ContextMenuGetOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ContextMenuGetOutput{}, nil
|
||||||
}
|
}
|
||||||
menu, ok := result.(*contextmenu.ContextMenuDef)
|
menu, ok := r.Value.(*contextmenu.ContextMenuDef)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
|
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
|
||||||
}
|
}
|
||||||
if menu == nil {
|
if menu == nil {
|
||||||
return nil, ContextMenuGetOutput{}, nil
|
return nil, ContextMenuGetOutput{}, nil
|
||||||
}
|
}
|
||||||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||||
menuJSON, err := json.Marshal(menu)
|
marshalResult := core.JSONMarshal(menu)
|
||||||
if err != nil {
|
if !marshalResult.OK {
|
||||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
|
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", marshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
|
menuJSON := marshalResult.Value.([]byte)
|
||||||
var menuMap map[string]any
|
var menuMap map[string]any
|
||||||
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
|
unmarshalResult := core.JSONUnmarshal(menuJSON, &menuMap)
|
||||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
|
if !unmarshalResult.OK {
|
||||||
|
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", unmarshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
|
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -98,22 +115,27 @@ type ContextMenuListOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, error) {
|
func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, error) {
|
||||||
result, _, err := s.core.QUERY(contextmenu.QueryList{})
|
r := s.core.QUERY(contextmenu.QueryList{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ContextMenuListOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ContextMenuListOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ContextMenuListOutput{}, nil
|
||||||
}
|
}
|
||||||
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
|
menus, ok := r.Value.(map[string]contextmenu.ContextMenuDef)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ContextMenuListOutput{}, 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
|
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||||
menusJSON, err := json.Marshal(menus)
|
marshalResult := core.JSONMarshal(menus)
|
||||||
if err != nil {
|
if !marshalResult.OK {
|
||||||
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
|
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", marshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
|
menusJSON := marshalResult.Value.([]byte)
|
||||||
var menusMap map[string]any
|
var menusMap map[string]any
|
||||||
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
|
unmarshalResult := core.JSONUnmarshal(menusJSON, &menusMap)
|
||||||
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
|
if !unmarshalResult.OK {
|
||||||
|
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", unmarshalResult.Value.(error))
|
||||||
}
|
}
|
||||||
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -12,28 +13,39 @@ import (
|
||||||
// --- dialog_open_file ---
|
// --- dialog_open_file ---
|
||||||
|
|
||||||
type DialogOpenFileInput struct {
|
type DialogOpenFileInput struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
Filters []dialog.FileFilter `json:"filters,omitempty"`
|
Filters []dialog.FileFilter `json:"filters,omitempty"`
|
||||||
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
||||||
|
CanChooseDirectories bool `json:"canChooseDirectories,omitempty"`
|
||||||
|
CanChooseFiles bool `json:"canChooseFiles,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
type DialogOpenFileOutput struct {
|
type DialogOpenFileOutput struct {
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
|
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
|
r := s.core.Action("dialog.openFile").Run(context.Background(), core.NewOptions(
|
||||||
Title: input.Title,
|
core.Option{Key: "task", Value: dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
|
||||||
Directory: input.Directory,
|
Title: input.Title,
|
||||||
Filters: input.Filters,
|
Directory: input.Directory,
|
||||||
AllowMultiple: input.AllowMultiple,
|
Filters: input.Filters,
|
||||||
}})
|
AllowMultiple: input.AllowMultiple,
|
||||||
if err != nil {
|
CanChooseDirectories: input.CanChooseDirectories,
|
||||||
return nil, DialogOpenFileOutput{}, err
|
CanChooseFiles: input.CanChooseFiles,
|
||||||
|
ShowHiddenFiles: input.ShowHiddenFiles,
|
||||||
|
}}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogOpenFileOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogOpenFileOutput{}, nil
|
||||||
}
|
}
|
||||||
paths, ok := result.([]string)
|
paths, ok := r.Value.([]string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogOpenFileOutput{}, 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
|
return nil, DialogOpenFileOutput{Paths: paths}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -41,28 +53,35 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
// --- dialog_save_file ---
|
// --- dialog_save_file ---
|
||||||
|
|
||||||
type DialogSaveFileInput struct {
|
type DialogSaveFileInput struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Filters []dialog.FileFilter `json:"filters,omitempty"`
|
Filters []dialog.FileFilter `json:"filters,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
type DialogSaveFileOutput struct {
|
type DialogSaveFileOutput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
|
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
|
r := s.core.Action("dialog.saveFile").Run(context.Background(), core.NewOptions(
|
||||||
Title: input.Title,
|
core.Option{Key: "task", Value: dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
|
||||||
Directory: input.Directory,
|
Title: input.Title,
|
||||||
Filename: input.Filename,
|
Directory: input.Directory,
|
||||||
Filters: input.Filters,
|
Filename: input.Filename,
|
||||||
}})
|
Filters: input.Filters,
|
||||||
if err != nil {
|
ShowHiddenFiles: input.ShowHiddenFiles,
|
||||||
return nil, DialogSaveFileOutput{}, err
|
}}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogSaveFileOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogSaveFileOutput{}, nil
|
||||||
}
|
}
|
||||||
path, ok := result.(string)
|
path, ok := r.Value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogSaveFileOutput{}, 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
|
return nil, DialogSaveFileOutput{Path: path}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -70,24 +89,31 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
// --- dialog_open_directory ---
|
// --- dialog_open_directory ---
|
||||||
|
|
||||||
type DialogOpenDirectoryInput struct {
|
type DialogOpenDirectoryInput struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Directory string `json:"directory,omitempty"`
|
Directory string `json:"directory,omitempty"`
|
||||||
|
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||||
}
|
}
|
||||||
type DialogOpenDirectoryOutput struct {
|
type DialogOpenDirectoryOutput struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
|
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
|
r := s.core.Action("dialog.openDirectory").Run(context.Background(), core.NewOptions(
|
||||||
Title: input.Title,
|
core.Option{Key: "task", Value: dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
|
||||||
Directory: input.Directory,
|
Title: input.Title,
|
||||||
}})
|
Directory: input.Directory,
|
||||||
if err != nil {
|
ShowHiddenFiles: input.ShowHiddenFiles,
|
||||||
return nil, DialogOpenDirectoryOutput{}, err
|
}}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogOpenDirectoryOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogOpenDirectoryOutput{}, nil
|
||||||
}
|
}
|
||||||
path, ok := result.(string)
|
path, ok := r.Value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogOpenDirectoryOutput{}, 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
|
return nil, DialogOpenDirectoryOutput{Path: path}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,18 +130,22 @@ type DialogConfirmOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
|
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
r := s.core.Action("dialog.question").Run(context.Background(), core.NewOptions(
|
||||||
Type: dialog.DialogQuestion,
|
core.Option{Key: "task", Value: dialog.TaskQuestion{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
Buttons: input.Buttons,
|
Buttons: input.Buttons,
|
||||||
}})
|
}},
|
||||||
if err != nil {
|
))
|
||||||
return nil, DialogConfirmOutput{}, err
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogConfirmOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogConfirmOutput{}, nil
|
||||||
}
|
}
|
||||||
button, ok := result.(string)
|
button, ok := r.Value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogConfirmOutput{}, 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
|
return nil, DialogConfirmOutput{Button: button}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -131,28 +161,131 @@ type DialogPromptOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
|
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
r := s.core.Action("dialog.info").Run(context.Background(), core.NewOptions(
|
||||||
Type: dialog.DialogInfo,
|
core.Option{Key: "task", Value: dialog.TaskInfo{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
Buttons: []string{"OK", "Cancel"},
|
Buttons: []string{"OK", "Cancel"},
|
||||||
}})
|
}},
|
||||||
if err != nil {
|
))
|
||||||
return nil, DialogPromptOutput{}, err
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogPromptOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogPromptOutput{}, nil
|
||||||
}
|
}
|
||||||
button, ok := result.(string)
|
button, ok := r.Value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogPromptOutput{}, 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
|
return nil, DialogPromptOutput{Button: button}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dialog_info ---
|
||||||
|
|
||||||
|
type DialogInfoInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Buttons []string `json:"buttons,omitempty"`
|
||||||
|
}
|
||||||
|
type DialogInfoOutput struct {
|
||||||
|
Button string `json:"button"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) dialogInfo(_ context.Context, _ *mcp.CallToolRequest, input DialogInfoInput) (*mcp.CallToolResult, DialogInfoOutput, error) {
|
||||||
|
r := s.core.Action("dialog.info").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: dialog.TaskInfo{
|
||||||
|
Title: input.Title,
|
||||||
|
Message: input.Message,
|
||||||
|
Buttons: input.Buttons,
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogInfoOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogInfoOutput{}, nil
|
||||||
|
}
|
||||||
|
button, ok := r.Value.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, DialogInfoOutput{}, coreerr.E("mcp.dialogInfo", "unexpected result type", nil)
|
||||||
|
}
|
||||||
|
return nil, DialogInfoOutput{Button: button}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dialog_warning ---
|
||||||
|
|
||||||
|
type DialogWarningInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Buttons []string `json:"buttons,omitempty"`
|
||||||
|
}
|
||||||
|
type DialogWarningOutput struct {
|
||||||
|
Button string `json:"button"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) dialogWarning(_ context.Context, _ *mcp.CallToolRequest, input DialogWarningInput) (*mcp.CallToolResult, DialogWarningOutput, error) {
|
||||||
|
r := s.core.Action("dialog.warning").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: dialog.TaskWarning{
|
||||||
|
Title: input.Title,
|
||||||
|
Message: input.Message,
|
||||||
|
Buttons: input.Buttons,
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogWarningOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogWarningOutput{}, nil
|
||||||
|
}
|
||||||
|
button, ok := r.Value.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, DialogWarningOutput{}, coreerr.E("mcp.dialogWarning", "unexpected result type", nil)
|
||||||
|
}
|
||||||
|
return nil, DialogWarningOutput{Button: button}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dialog_error ---
|
||||||
|
|
||||||
|
type DialogErrorInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Buttons []string `json:"buttons,omitempty"`
|
||||||
|
}
|
||||||
|
type DialogErrorOutput struct {
|
||||||
|
Button string `json:"button"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) dialogError(_ context.Context, _ *mcp.CallToolRequest, input DialogErrorInput) (*mcp.CallToolResult, DialogErrorOutput, error) {
|
||||||
|
r := s.core.Action("dialog.error").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: dialog.TaskError{
|
||||||
|
Title: input.Title,
|
||||||
|
Message: input.Message,
|
||||||
|
Buttons: input.Buttons,
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DialogErrorOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DialogErrorOutput{}, nil
|
||||||
|
}
|
||||||
|
button, ok := r.Value.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, DialogErrorOutput{}, coreerr.E("mcp.dialogError", "unexpected result type", nil)
|
||||||
|
}
|
||||||
|
return nil, DialogErrorOutput{Button: button}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerDialogTools(server *mcp.Server) {
|
func (s *Subsystem) registerDialogTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile)
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile)
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory)
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a confirmation dialog"}, s.dialogConfirm)
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a question/confirmation dialog"}, s.dialogConfirm)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show a prompt dialog"}, s.dialogPrompt)
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show an info prompt dialog with OK/Cancel"}, s.dialogPrompt)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_info", Description: "Show an information message dialog"}, s.dialogInfo)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_warning", Description: "Show a warning message dialog"}, s.dialogWarning)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_error", Description: "Show an error message dialog"}, s.dialogError)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/dock"
|
"forge.lthn.ai/core/gui/pkg/dock"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -16,9 +17,12 @@ type DockShowOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dockShow(_ context.Context, _ *mcp.CallToolRequest, _ DockShowInput) (*mcp.CallToolResult, DockShowOutput, error) {
|
func (s *Subsystem) dockShow(_ context.Context, _ *mcp.CallToolRequest, _ DockShowInput) (*mcp.CallToolResult, DockShowOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(dock.TaskShowIcon{})
|
r := s.core.Action("dock.showIcon").Run(context.Background(), core.NewOptions())
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, DockShowOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DockShowOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DockShowOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, DockShowOutput{Success: true}, nil
|
return nil, DockShowOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -31,9 +35,12 @@ type DockHideOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dockHide(_ context.Context, _ *mcp.CallToolRequest, _ DockHideInput) (*mcp.CallToolResult, DockHideOutput, error) {
|
func (s *Subsystem) dockHide(_ context.Context, _ *mcp.CallToolRequest, _ DockHideInput) (*mcp.CallToolResult, DockHideOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(dock.TaskHideIcon{})
|
r := s.core.Action("dock.hideIcon").Run(context.Background(), core.NewOptions())
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, DockHideOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DockHideOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DockHideOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, DockHideOutput{Success: true}, nil
|
return nil, DockHideOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -48,9 +55,14 @@ type DockBadgeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) dockBadge(_ context.Context, _ *mcp.CallToolRequest, input DockBadgeInput) (*mcp.CallToolResult, DockBadgeOutput, error) {
|
func (s *Subsystem) dockBadge(_ context.Context, _ *mcp.CallToolRequest, input DockBadgeInput) (*mcp.CallToolResult, DockBadgeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(dock.TaskSetBadge{Label: input.Label})
|
r := s.core.Action("dock.setBadge").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: dock.TaskSetBadge{Label: input.Label}},
|
||||||
return nil, DockBadgeOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, DockBadgeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, DockBadgeOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, DockBadgeOutput{Success: true}, nil
|
return nil, DockBadgeOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/environment"
|
"forge.lthn.ai/core/gui/pkg/environment"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -17,13 +17,16 @@ type ThemeGetOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeGetInput) (*mcp.CallToolResult, ThemeGetOutput, error) {
|
func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeGetInput) (*mcp.CallToolResult, ThemeGetOutput, error) {
|
||||||
result, _, err := s.core.QUERY(environment.QueryTheme{})
|
r := s.core.QUERY(environment.QueryTheme{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ThemeGetOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ThemeGetOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ThemeGetOutput{}, nil
|
||||||
}
|
}
|
||||||
theme, ok := result.(environment.ThemeInfo)
|
theme, ok := r.Value.(environment.ThemeInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ThemeGetOutput{}, 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
|
return nil, ThemeGetOutput{Theme: theme}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -36,13 +39,16 @@ type ThemeSystemOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ ThemeSystemInput) (*mcp.CallToolResult, ThemeSystemOutput, error) {
|
func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ ThemeSystemInput) (*mcp.CallToolResult, ThemeSystemOutput, error) {
|
||||||
result, _, err := s.core.QUERY(environment.QueryInfo{})
|
r := s.core.QUERY(environment.QueryInfo{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ThemeSystemOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ThemeSystemOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ThemeSystemOutput{}, nil
|
||||||
}
|
}
|
||||||
info, ok := result.(environment.EnvironmentInfo)
|
info, ok := r.Value.(environment.EnvironmentInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ThemeSystemOutput{}, 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
|
return nil, ThemeSystemOutput{Info: info}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
pkg/mcp/tools_events.go
Normal file
113
pkg/mcp/tools_events.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// pkg/mcp/tools_events.go
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/events"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- event_emit ---
|
||||||
|
|
||||||
|
type EventEmitInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
type EventEmitOutput struct {
|
||||||
|
Cancelled bool `json:"cancelled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) eventEmit(_ context.Context, _ *mcp.CallToolRequest, input EventEmitInput) (*mcp.CallToolResult, EventEmitOutput, error) {
|
||||||
|
r := s.core.Action("events.emit").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: events.TaskEmit{Name: input.Name, Data: input.Data}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, EventEmitOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, EventEmitOutput{}, nil
|
||||||
|
}
|
||||||
|
cancelled, ok := r.Value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return nil, EventEmitOutput{}, coreerr.E("mcp.eventEmit", "unexpected result type", nil)
|
||||||
|
}
|
||||||
|
return nil, EventEmitOutput{Cancelled: cancelled}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- event_on ---
|
||||||
|
|
||||||
|
type EventOnInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type EventOnOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) eventOn(_ context.Context, _ *mcp.CallToolRequest, input EventOnInput) (*mcp.CallToolResult, EventOnOutput, error) {
|
||||||
|
r := s.core.Action("events.on").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: events.TaskOn{Name: input.Name}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, EventOnOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, EventOnOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, EventOnOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- event_off ---
|
||||||
|
|
||||||
|
type EventOffInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type EventOffOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) eventOff(_ context.Context, _ *mcp.CallToolRequest, input EventOffInput) (*mcp.CallToolResult, EventOffOutput, error) {
|
||||||
|
r := s.core.Action("events.off").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: events.TaskOff{Name: input.Name}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, EventOffOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, EventOffOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, EventOffOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- event_list ---
|
||||||
|
|
||||||
|
type EventListInput struct{}
|
||||||
|
type EventListOutput struct {
|
||||||
|
Listeners []events.ListenerInfo `json:"listeners"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) eventList(_ context.Context, _ *mcp.CallToolRequest, _ EventListInput) (*mcp.CallToolResult, EventListOutput, error) {
|
||||||
|
r := s.core.QUERY(events.QueryListeners{})
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, EventListOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, EventListOutput{}, nil
|
||||||
|
}
|
||||||
|
listenerInfos, ok := r.Value.([]events.ListenerInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, EventListOutput{}, coreerr.E("mcp.eventList", "unexpected result type", nil)
|
||||||
|
}
|
||||||
|
return nil, EventListOutput{Listeners: listenerInfos}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registration ---
|
||||||
|
|
||||||
|
func (s *Subsystem) registerEventsTools(server *mcp.Server) {
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "event_emit", Description: "Fire a named custom event with optional data"}, s.eventEmit)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "event_on", Description: "Register a listener for a named custom event"}, s.eventOn)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "event_off", Description: "Remove all listeners for a named custom event"}, s.eventOff)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "event_list", Description: "Query all registered event listeners"}, s.eventList)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/keybinding"
|
"forge.lthn.ai/core/gui/pkg/keybinding"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -19,9 +20,14 @@ type KeybindingAddOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) keybindingAdd(_ context.Context, _ *mcp.CallToolRequest, input KeybindingAddInput) (*mcp.CallToolResult, KeybindingAddOutput, error) {
|
func (s *Subsystem) keybindingAdd(_ context.Context, _ *mcp.CallToolRequest, input KeybindingAddInput) (*mcp.CallToolResult, KeybindingAddOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(keybinding.TaskAdd{Accelerator: input.Accelerator, Description: input.Description})
|
r := s.core.Action("keybinding.add").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: keybinding.TaskAdd{Accelerator: input.Accelerator, Description: input.Description}},
|
||||||
return nil, KeybindingAddOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, KeybindingAddOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, KeybindingAddOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, KeybindingAddOutput{Success: true}, nil
|
return nil, KeybindingAddOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -36,9 +42,14 @@ type KeybindingRemoveOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) keybindingRemove(_ context.Context, _ *mcp.CallToolRequest, input KeybindingRemoveInput) (*mcp.CallToolResult, KeybindingRemoveOutput, error) {
|
func (s *Subsystem) keybindingRemove(_ context.Context, _ *mcp.CallToolRequest, input KeybindingRemoveInput) (*mcp.CallToolResult, KeybindingRemoveOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(keybinding.TaskRemove{Accelerator: input.Accelerator})
|
r := s.core.Action("keybinding.remove").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: keybinding.TaskRemove{Accelerator: input.Accelerator}},
|
||||||
return nil, KeybindingRemoveOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, KeybindingRemoveOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, KeybindingRemoveOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, KeybindingRemoveOutput{Success: true}, nil
|
return nil, KeybindingRemoveOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -19,9 +20,14 @@ type LayoutSaveOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutSave(_ context.Context, _ *mcp.CallToolRequest, input LayoutSaveInput) (*mcp.CallToolResult, LayoutSaveOutput, error) {
|
func (s *Subsystem) layoutSave(_ context.Context, _ *mcp.CallToolRequest, input LayoutSaveInput) (*mcp.CallToolResult, LayoutSaveOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSaveLayout{Name: input.Name})
|
r := s.core.Action("window.saveLayout").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSaveLayout{Name: input.Name}},
|
||||||
return nil, LayoutSaveOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutSaveOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutSaveOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, LayoutSaveOutput{Success: true}, nil
|
return nil, LayoutSaveOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -36,9 +42,14 @@ type LayoutRestoreOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutRestore(_ context.Context, _ *mcp.CallToolRequest, input LayoutRestoreInput) (*mcp.CallToolResult, LayoutRestoreOutput, error) {
|
func (s *Subsystem) layoutRestore(_ context.Context, _ *mcp.CallToolRequest, input LayoutRestoreInput) (*mcp.CallToolResult, LayoutRestoreOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskRestoreLayout{Name: input.Name})
|
r := s.core.Action("window.restoreLayout").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskRestoreLayout{Name: input.Name}},
|
||||||
return nil, LayoutRestoreOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutRestoreOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutRestoreOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, LayoutRestoreOutput{Success: true}, nil
|
return nil, LayoutRestoreOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -51,13 +62,16 @@ type LayoutListOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ LayoutListInput) (*mcp.CallToolResult, LayoutListOutput, error) {
|
func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ LayoutListInput) (*mcp.CallToolResult, LayoutListOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryLayoutList{})
|
r := s.core.QUERY(window.QueryLayoutList{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, LayoutListOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutListOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutListOutput{}, nil
|
||||||
}
|
}
|
||||||
layouts, ok := result.([]window.LayoutInfo)
|
layouts, ok := r.Value.([]window.LayoutInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, LayoutListOutput{}, 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
|
return nil, LayoutListOutput{Layouts: layouts}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -72,9 +86,14 @@ type LayoutDeleteOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutDelete(_ context.Context, _ *mcp.CallToolRequest, input LayoutDeleteInput) (*mcp.CallToolResult, LayoutDeleteOutput, error) {
|
func (s *Subsystem) layoutDelete(_ context.Context, _ *mcp.CallToolRequest, input LayoutDeleteInput) (*mcp.CallToolResult, LayoutDeleteOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskDeleteLayout{Name: input.Name})
|
r := s.core.Action("window.deleteLayout").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskDeleteLayout{Name: input.Name}},
|
||||||
return nil, LayoutDeleteOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutDeleteOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutDeleteOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, LayoutDeleteOutput{Success: true}, nil
|
return nil, LayoutDeleteOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -89,13 +108,16 @@ type LayoutGetOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input LayoutGetInput) (*mcp.CallToolResult, LayoutGetOutput, error) {
|
func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input LayoutGetInput) (*mcp.CallToolResult, LayoutGetOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryLayoutGet{Name: input.Name})
|
r := s.core.QUERY(window.QueryLayoutGet{Name: input.Name})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, LayoutGetOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutGetOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutGetOutput{}, nil
|
||||||
}
|
}
|
||||||
layout, ok := result.(*window.Layout)
|
layout, ok := r.Value.(*window.Layout)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, LayoutGetOutput{}, 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
|
return nil, LayoutGetOutput{Layout: layout}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -111,9 +133,14 @@ type LayoutTileOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutTile(_ context.Context, _ *mcp.CallToolRequest, input LayoutTileInput) (*mcp.CallToolResult, LayoutTileOutput, error) {
|
func (s *Subsystem) layoutTile(_ context.Context, _ *mcp.CallToolRequest, input LayoutTileInput) (*mcp.CallToolResult, LayoutTileOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskTileWindows{Mode: input.Mode, Windows: input.Windows})
|
r := s.core.Action("window.tileWindows").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskTileWindows{Mode: input.Mode, Windows: input.Windows}},
|
||||||
return nil, LayoutTileOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutTileOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutTileOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, LayoutTileOutput{Success: true}, nil
|
return nil, LayoutTileOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -129,13 +156,65 @@ type LayoutSnapOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input LayoutSnapInput) (*mcp.CallToolResult, LayoutSnapOutput, error) {
|
func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input LayoutSnapInput) (*mcp.CallToolResult, LayoutSnapOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSnapWindow{Name: input.Name, Position: input.Position})
|
r := s.core.Action("window.snapWindow").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSnapWindow{Name: input.Name, Position: input.Position}},
|
||||||
return nil, LayoutSnapOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutSnapOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutSnapOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, LayoutSnapOutput{Success: true}, nil
|
return nil, LayoutSnapOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- layout_stack ---
|
||||||
|
|
||||||
|
type LayoutStackInput struct {
|
||||||
|
Windows []string `json:"windows,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) {
|
||||||
|
r := s.core.Action("window.stackWindows").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutStackOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutStackOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, LayoutStackOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- layout_workflow ---
|
||||||
|
|
||||||
|
type LayoutWorkflowInput struct {
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
Windows []string `json:"windows,omitempty"`
|
||||||
|
}
|
||||||
|
type LayoutWorkflowOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
|
||||||
|
r := s.core.Action("window.applyWorkflow").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, LayoutWorkflowOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, LayoutWorkflowOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, LayoutWorkflowOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
|
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
|
||||||
|
|
@ -146,4 +225,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_get", Description: "Get a specific layout by name"}, s.layoutGet)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,7 @@ type AppQuitOutput struct {
|
||||||
|
|
||||||
func (s *Subsystem) appQuit(_ context.Context, _ *mcp.CallToolRequest, _ AppQuitInput) (*mcp.CallToolResult, AppQuitOutput, error) {
|
func (s *Subsystem) appQuit(_ context.Context, _ *mcp.CallToolRequest, _ AppQuitInput) (*mcp.CallToolResult, AppQuitOutput, error) {
|
||||||
// Broadcast the will-terminate action which triggers application shutdown
|
// Broadcast the will-terminate action which triggers application shutdown
|
||||||
err := s.core.ACTION(lifecycle.ActionWillTerminate{})
|
_ = s.core.ACTION(lifecycle.ActionWillTerminate{})
|
||||||
if err != nil {
|
|
||||||
return nil, AppQuitOutput{}, err
|
|
||||||
}
|
|
||||||
return nil, AppQuitOutput{Success: true}, nil
|
return nil, AppQuitOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/notification"
|
"forge.lthn.ai/core/gui/pkg/notification"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -21,13 +22,18 @@ type NotificationShowOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
|
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
r := s.core.Action("notification.send").Run(context.Background(), core.NewOptions(
|
||||||
Title: input.Title,
|
core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{
|
||||||
Message: input.Message,
|
Title: input.Title,
|
||||||
Subtitle: input.Subtitle,
|
Message: input.Message,
|
||||||
}})
|
Subtitle: input.Subtitle,
|
||||||
if err != nil {
|
}}},
|
||||||
return nil, NotificationShowOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, NotificationShowOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, NotificationShowOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, NotificationShowOutput{Success: true}, nil
|
return nil, NotificationShowOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -40,13 +46,16 @@ type NotificationPermissionRequestOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionRequestInput) (*mcp.CallToolResult, NotificationPermissionRequestOutput, error) {
|
func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionRequestInput) (*mcp.CallToolResult, NotificationPermissionRequestOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(notification.TaskRequestPermission{})
|
r := s.core.Action("notification.requestPermission").Run(context.Background(), core.NewOptions())
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, NotificationPermissionRequestOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, NotificationPermissionRequestOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, NotificationPermissionRequestOutput{}, nil
|
||||||
}
|
}
|
||||||
granted, ok := result.(bool)
|
granted, ok := r.Value.(bool)
|
||||||
if !ok {
|
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
|
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -59,13 +68,16 @@ type NotificationPermissionCheckOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionCheckInput) (*mcp.CallToolResult, NotificationPermissionCheckOutput, error) {
|
func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionCheckInput) (*mcp.CallToolResult, NotificationPermissionCheckOutput, error) {
|
||||||
result, _, err := s.core.QUERY(notification.QueryPermission{})
|
r := s.core.QUERY(notification.QueryPermission{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, NotificationPermissionCheckOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, NotificationPermissionCheckOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, NotificationPermissionCheckOutput{}, nil
|
||||||
}
|
}
|
||||||
status, ok := result.(notification.PermissionStatus)
|
status, ok := r.Value.(notification.PermissionStatus)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, NotificationPermissionCheckOutput{}, 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
|
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/screen"
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,13 +18,16 @@ type ScreenListOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ ScreenListInput) (*mcp.CallToolResult, ScreenListOutput, error) {
|
func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ ScreenListInput) (*mcp.CallToolResult, ScreenListOutput, error) {
|
||||||
result, _, err := s.core.QUERY(screen.QueryAll{})
|
r := s.core.QUERY(screen.QueryAll{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ScreenListOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ScreenListOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ScreenListOutput{}, nil
|
||||||
}
|
}
|
||||||
screens, ok := result.([]screen.Screen)
|
screens, ok := r.Value.([]screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenListOutput{}, 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
|
return nil, ScreenListOutput{Screens: screens}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -38,13 +42,16 @@ type ScreenGetOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input ScreenGetInput) (*mcp.CallToolResult, ScreenGetOutput, error) {
|
func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input ScreenGetInput) (*mcp.CallToolResult, ScreenGetOutput, error) {
|
||||||
result, _, err := s.core.QUERY(screen.QueryByID{ID: input.ID})
|
r := s.core.QUERY(screen.QueryByID{ID: input.ID})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ScreenGetOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ScreenGetOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ScreenGetOutput{}, nil
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := r.Value.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenGetOutput{}, 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
|
return nil, ScreenGetOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -57,13 +64,16 @@ type ScreenPrimaryOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ ScreenPrimaryInput) (*mcp.CallToolResult, ScreenPrimaryOutput, error) {
|
func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ ScreenPrimaryInput) (*mcp.CallToolResult, ScreenPrimaryOutput, error) {
|
||||||
result, _, err := s.core.QUERY(screen.QueryPrimary{})
|
r := s.core.QUERY(screen.QueryPrimary{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ScreenPrimaryOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ScreenPrimaryOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ScreenPrimaryOutput{}, nil
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := r.Value.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenPrimaryOutput{}, 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
|
return nil, ScreenPrimaryOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,13 +89,16 @@ type ScreenAtPointOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, input ScreenAtPointInput) (*mcp.CallToolResult, ScreenAtPointOutput, error) {
|
func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, input ScreenAtPointInput) (*mcp.CallToolResult, ScreenAtPointOutput, error) {
|
||||||
result, _, err := s.core.QUERY(screen.QueryAtPoint{X: input.X, Y: input.Y})
|
r := s.core.QUERY(screen.QueryAtPoint{X: input.X, Y: input.Y})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ScreenAtPointOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ScreenAtPointOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ScreenAtPointOutput{}, nil
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := r.Value.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenAtPointOutput{}, 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
|
return nil, ScreenAtPointOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -98,17 +111,48 @@ type ScreenWorkAreasOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
|
func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
|
||||||
result, _, err := s.core.QUERY(screen.QueryWorkAreas{})
|
r := s.core.QUERY(screen.QueryWorkAreas{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, ScreenWorkAreasOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, ScreenWorkAreasOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, ScreenWorkAreasOutput{}, nil
|
||||||
}
|
}
|
||||||
areas, ok := result.([]screen.Rect)
|
areas, ok := r.Value.([]screen.Rect)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenWorkAreasOutput{}, 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
|
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- screen_for_window ---
|
||||||
|
|
||||||
|
type ScreenForWindowInput struct {
|
||||||
|
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) {
|
||||||
|
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
||||||
|
if !r.OK {
|
||||||
|
return nil, ScreenForWindowOutput{}, nil
|
||||||
|
}
|
||||||
|
info, _ := r.Value.(*window.WindowInfo)
|
||||||
|
if info == nil {
|
||||||
|
return nil, ScreenForWindowOutput{}, nil
|
||||||
|
}
|
||||||
|
centerX := info.X + info.Width/2
|
||||||
|
centerY := info.Y + info.Height/2
|
||||||
|
r2 := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY})
|
||||||
|
if !r2.OK {
|
||||||
|
return nil, ScreenForWindowOutput{}, nil
|
||||||
|
}
|
||||||
|
scr, _ := r2.Value.(*screen.Screen)
|
||||||
|
return nil, ScreenForWindowOutput{Screen: scr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
||||||
|
|
@ -117,4 +161,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_primary", Description: "Get the primary screen"}, s.screenPrimary)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/systray"
|
"forge.lthn.ai/core/gui/pkg/systray"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -19,9 +20,14 @@ type TraySetIconOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) traySetIcon(_ context.Context, _ *mcp.CallToolRequest, input TraySetIconInput) (*mcp.CallToolResult, TraySetIconOutput, error) {
|
func (s *Subsystem) traySetIcon(_ context.Context, _ *mcp.CallToolRequest, input TraySetIconInput) (*mcp.CallToolResult, TraySetIconOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(systray.TaskSetTrayIcon{Data: input.Data})
|
r := s.core.Action("systray.setIcon").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: systray.TaskSetTrayIcon{Data: input.Data}},
|
||||||
return nil, TraySetIconOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, TraySetIconOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, TraySetIconOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, TraySetIconOutput{Success: true}, nil
|
return nil, TraySetIconOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -64,13 +70,16 @@ type TrayInfoOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayInfoInput) (*mcp.CallToolResult, TrayInfoOutput, error) {
|
func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayInfoInput) (*mcp.CallToolResult, TrayInfoOutput, error) {
|
||||||
result, _, err := s.core.QUERY(systray.QueryConfig{})
|
r := s.core.QUERY(systray.QueryConfig{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, TrayInfoOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, TrayInfoOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, TrayInfoOutput{}, nil
|
||||||
}
|
}
|
||||||
config, ok := result.(map[string]any)
|
config, ok := r.Value.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, TrayInfoOutput{}, 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
|
return nil, TrayInfoOutput{Config: config}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/webview"
|
"forge.lthn.ai/core/gui/pkg/webview"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -22,11 +23,16 @@ type WebviewEvalOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewEval(_ context.Context, _ *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) {
|
func (s *Subsystem) webviewEval(_ context.Context, _ *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(webview.TaskEvaluate{Window: input.Window, Script: input.Script})
|
r := s.core.Action("webview.evaluate").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskEvaluate{Window: input.Window, Script: input.Script}},
|
||||||
return nil, WebviewEvalOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewEvalOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewEvalOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewEvalOutput{Result: result, Window: input.Window}, nil
|
return nil, WebviewEvalOutput{Result: r.Value, Window: input.Window}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- webview_click ---
|
// --- webview_click ---
|
||||||
|
|
@ -41,9 +47,14 @@ type WebviewClickOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewClick(_ context.Context, _ *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, error) {
|
func (s *Subsystem) webviewClick(_ context.Context, _ *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskClick{Window: input.Window, Selector: input.Selector})
|
r := s.core.Action("webview.click").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskClick{Window: input.Window, Selector: input.Selector}},
|
||||||
return nil, WebviewClickOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewClickOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewClickOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewClickOutput{Success: true}, nil
|
return nil, WebviewClickOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -61,9 +72,14 @@ type WebviewTypeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewType(_ context.Context, _ *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, error) {
|
func (s *Subsystem) webviewType(_ context.Context, _ *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskType{Window: input.Window, Selector: input.Selector, Text: input.Text})
|
r := s.core.Action("webview.type").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskType{Window: input.Window, Selector: input.Selector, Text: input.Text}},
|
||||||
return nil, WebviewTypeOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewTypeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewTypeOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewTypeOutput{Success: true}, nil
|
return nil, WebviewTypeOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +96,14 @@ type WebviewNavigateOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewNavigate(_ context.Context, _ *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, error) {
|
func (s *Subsystem) webviewNavigate(_ context.Context, _ *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskNavigate{Window: input.Window, URL: input.URL})
|
r := s.core.Action("webview.navigate").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskNavigate{Window: input.Window, URL: input.URL}},
|
||||||
return nil, WebviewNavigateOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewNavigateOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewNavigateOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewNavigateOutput{Success: true}, nil
|
return nil, WebviewNavigateOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -99,13 +120,18 @@ type WebviewScreenshotOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, error) {
|
func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(webview.TaskScreenshot{Window: input.Window})
|
r := s.core.Action("webview.screenshot").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskScreenshot{Window: input.Window}},
|
||||||
return nil, WebviewScreenshotOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewScreenshotOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewScreenshotOutput{}, nil
|
||||||
}
|
}
|
||||||
sr, ok := result.(webview.ScreenshotResult)
|
sr, ok := r.Value.(webview.ScreenshotResult)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewScreenshotOutput{}, 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
|
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -123,9 +149,14 @@ type WebviewScrollOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewScroll(_ context.Context, _ *mcp.CallToolRequest, input WebviewScrollInput) (*mcp.CallToolResult, WebviewScrollOutput, error) {
|
func (s *Subsystem) webviewScroll(_ context.Context, _ *mcp.CallToolRequest, input WebviewScrollInput) (*mcp.CallToolResult, WebviewScrollOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskScroll{Window: input.Window, X: input.X, Y: input.Y})
|
r := s.core.Action("webview.scroll").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskScroll{Window: input.Window, X: input.X, Y: input.Y}},
|
||||||
return nil, WebviewScrollOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewScrollOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewScrollOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewScrollOutput{Success: true}, nil
|
return nil, WebviewScrollOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -142,9 +173,14 @@ type WebviewHoverOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewHover(_ context.Context, _ *mcp.CallToolRequest, input WebviewHoverInput) (*mcp.CallToolResult, WebviewHoverOutput, error) {
|
func (s *Subsystem) webviewHover(_ context.Context, _ *mcp.CallToolRequest, input WebviewHoverInput) (*mcp.CallToolResult, WebviewHoverOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskHover{Window: input.Window, Selector: input.Selector})
|
r := s.core.Action("webview.hover").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskHover{Window: input.Window, Selector: input.Selector}},
|
||||||
return nil, WebviewHoverOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewHoverOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewHoverOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewHoverOutput{Success: true}, nil
|
return nil, WebviewHoverOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -162,9 +198,14 @@ type WebviewSelectOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewSelect(_ context.Context, _ *mcp.CallToolRequest, input WebviewSelectInput) (*mcp.CallToolResult, WebviewSelectOutput, error) {
|
func (s *Subsystem) webviewSelect(_ context.Context, _ *mcp.CallToolRequest, input WebviewSelectInput) (*mcp.CallToolResult, WebviewSelectOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskSelect{Window: input.Window, Selector: input.Selector, Value: input.Value})
|
r := s.core.Action("webview.select").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskSelect{Window: input.Window, Selector: input.Selector, Value: input.Value}},
|
||||||
return nil, WebviewSelectOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewSelectOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewSelectOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewSelectOutput{Success: true}, nil
|
return nil, WebviewSelectOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +223,14 @@ type WebviewCheckOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewCheck(_ context.Context, _ *mcp.CallToolRequest, input WebviewCheckInput) (*mcp.CallToolResult, WebviewCheckOutput, error) {
|
func (s *Subsystem) webviewCheck(_ context.Context, _ *mcp.CallToolRequest, input WebviewCheckInput) (*mcp.CallToolResult, WebviewCheckOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskCheck{Window: input.Window, Selector: input.Selector, Checked: input.Checked})
|
r := s.core.Action("webview.check").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskCheck{Window: input.Window, Selector: input.Selector, Checked: input.Checked}},
|
||||||
return nil, WebviewCheckOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewCheckOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewCheckOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewCheckOutput{Success: true}, nil
|
return nil, WebviewCheckOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -202,9 +248,14 @@ type WebviewUploadOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewUpload(_ context.Context, _ *mcp.CallToolRequest, input WebviewUploadInput) (*mcp.CallToolResult, WebviewUploadOutput, error) {
|
func (s *Subsystem) webviewUpload(_ context.Context, _ *mcp.CallToolRequest, input WebviewUploadInput) (*mcp.CallToolResult, WebviewUploadOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskUploadFile{Window: input.Window, Selector: input.Selector, Paths: input.Paths})
|
r := s.core.Action("webview.uploadFile").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskUploadFile{Window: input.Window, Selector: input.Selector, Paths: input.Paths}},
|
||||||
return nil, WebviewUploadOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewUploadOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewUploadOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewUploadOutput{Success: true}, nil
|
return nil, WebviewUploadOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -222,9 +273,14 @@ type WebviewViewportOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewViewport(_ context.Context, _ *mcp.CallToolRequest, input WebviewViewportInput) (*mcp.CallToolResult, WebviewViewportOutput, error) {
|
func (s *Subsystem) webviewViewport(_ context.Context, _ *mcp.CallToolRequest, input WebviewViewportInput) (*mcp.CallToolResult, WebviewViewportOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskSetViewport{Window: input.Window, Width: input.Width, Height: input.Height})
|
r := s.core.Action("webview.setViewport").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskSetViewport{Window: input.Window, Width: input.Width, Height: input.Height}},
|
||||||
return nil, WebviewViewportOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewViewportOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewViewportOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewViewportOutput{Success: true}, nil
|
return nil, WebviewViewportOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -242,13 +298,16 @@ type WebviewConsoleOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, error) {
|
func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QueryConsole{Window: input.Window, Level: input.Level, Limit: input.Limit})
|
r := s.core.QUERY(webview.QueryConsole{Window: input.Window, Level: input.Level, Limit: input.Limit})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewConsoleOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewConsoleOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewConsoleOutput{}, nil
|
||||||
}
|
}
|
||||||
msgs, ok := result.([]webview.ConsoleMessage)
|
msgs, ok := r.Value.([]webview.ConsoleMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewConsoleOutput{}, 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
|
return nil, WebviewConsoleOutput{Messages: msgs}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -264,9 +323,14 @@ type WebviewConsoleClearOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleClearInput) (*mcp.CallToolResult, WebviewConsoleClearOutput, error) {
|
func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleClearInput) (*mcp.CallToolResult, WebviewConsoleClearOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(webview.TaskClearConsole{Window: input.Window})
|
r := s.core.Action("webview.clearConsole").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: webview.TaskClearConsole{Window: input.Window}},
|
||||||
return nil, WebviewConsoleClearOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewConsoleClearOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewConsoleClearOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -283,13 +347,16 @@ type WebviewQueryOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
|
func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector})
|
r := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewQueryOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewQueryOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewQueryOutput{}, nil
|
||||||
}
|
}
|
||||||
el, ok := result.(*webview.ElementInfo)
|
el, ok := r.Value.(*webview.ElementInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewQueryOutput{}, 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
|
return nil, WebviewQueryOutput{Element: el}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -306,13 +373,16 @@ type WebviewQueryAllOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryAllInput) (*mcp.CallToolResult, WebviewQueryAllOutput, error) {
|
func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryAllInput) (*mcp.CallToolResult, WebviewQueryAllOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QuerySelectorAll{Window: input.Window, Selector: input.Selector})
|
r := s.core.QUERY(webview.QuerySelectorAll{Window: input.Window, Selector: input.Selector})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewQueryAllOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewQueryAllOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewQueryAllOutput{}, nil
|
||||||
}
|
}
|
||||||
els, ok := result.([]*webview.ElementInfo)
|
els, ok := r.Value.([]*webview.ElementInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewQueryAllOutput{}, 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
|
return nil, WebviewQueryAllOutput{Elements: els}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -329,13 +399,16 @@ type WebviewDOMTreeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) {
|
func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QueryDOMTree{Window: input.Window, Selector: input.Selector})
|
r := s.core.QUERY(webview.QueryDOMTree{Window: input.Window, Selector: input.Selector})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewDOMTreeOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewDOMTreeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewDOMTreeOutput{}, nil
|
||||||
}
|
}
|
||||||
html, ok := result.(string)
|
html, ok := r.Value.(string)
|
||||||
if !ok {
|
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
|
return nil, WebviewDOMTreeOutput{HTML: html}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -351,13 +424,16 @@ type WebviewURLOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input WebviewURLInput) (*mcp.CallToolResult, WebviewURLOutput, error) {
|
func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input WebviewURLInput) (*mcp.CallToolResult, WebviewURLOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QueryURL{Window: input.Window})
|
r := s.core.QUERY(webview.QueryURL{Window: input.Window})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewURLOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewURLOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewURLOutput{}, nil
|
||||||
}
|
}
|
||||||
url, ok := result.(string)
|
url, ok := r.Value.(string)
|
||||||
if !ok {
|
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
|
return nil, WebviewURLOutput{URL: url}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -373,13 +449,16 @@ type WebviewTitleOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, input WebviewTitleInput) (*mcp.CallToolResult, WebviewTitleOutput, error) {
|
func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, input WebviewTitleInput) (*mcp.CallToolResult, WebviewTitleOutput, error) {
|
||||||
result, _, err := s.core.QUERY(webview.QueryTitle{Window: input.Window})
|
r := s.core.QUERY(webview.QueryTitle{Window: input.Window})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WebviewTitleOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WebviewTitleOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WebviewTitleOutput{}, nil
|
||||||
}
|
}
|
||||||
title, ok := result.(string)
|
title, ok := r.Value.(string)
|
||||||
if !ok {
|
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
|
return nil, WebviewTitleOutput{Title: title}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -17,13 +18,16 @@ type WindowListOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ WindowListInput) (*mcp.CallToolResult, WindowListOutput, error) {
|
func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ WindowListInput) (*mcp.CallToolResult, WindowListOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryWindowList{})
|
r := s.core.QUERY(window.QueryWindowList{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WindowListOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowListOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowListOutput{}, nil
|
||||||
}
|
}
|
||||||
windows, ok := result.([]window.WindowInfo)
|
windows, ok := r.Value.([]window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowListOutput{}, 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
|
return nil, WindowListOutput{Windows: windows}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -38,13 +42,16 @@ type WindowGetOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input WindowGetInput) (*mcp.CallToolResult, WindowGetOutput, error) {
|
func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input WindowGetInput) (*mcp.CallToolResult, WindowGetOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WindowGetOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowGetOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowGetOutput{}, nil
|
||||||
}
|
}
|
||||||
info, ok := result.(*window.WindowInfo)
|
info, ok := r.Value.(*window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowGetOutput{}, 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
|
return nil, WindowGetOutput{Window: info}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -57,13 +64,16 @@ type WindowFocusedOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ WindowFocusedInput) (*mcp.CallToolResult, WindowFocusedOutput, error) {
|
func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ WindowFocusedInput) (*mcp.CallToolResult, WindowFocusedOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryWindowList{})
|
r := s.core.QUERY(window.QueryWindowList{})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, WindowFocusedOutput{}, err
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowFocusedOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowFocusedOutput{}, nil
|
||||||
}
|
}
|
||||||
windows, ok := result.([]window.WindowInfo)
|
windows, ok := r.Value.([]window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
|
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type", nil)
|
||||||
}
|
}
|
||||||
for _, w := range windows {
|
for _, w := range windows {
|
||||||
if w.Focused {
|
if w.Focused {
|
||||||
|
|
@ -89,28 +99,28 @@ type WindowCreateOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
|
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
|
||||||
opts := []window.WindowOption{
|
r := s.core.Action("window.open").Run(context.Background(), core.NewOptions(
|
||||||
window.WithName(input.Name),
|
core.Option{Key: "task", Value: window.TaskOpenWindow{
|
||||||
|
Window: &window.Window{
|
||||||
|
Name: input.Name,
|
||||||
|
Title: input.Title,
|
||||||
|
URL: input.URL,
|
||||||
|
Width: input.Width,
|
||||||
|
Height: input.Height,
|
||||||
|
X: input.X,
|
||||||
|
Y: input.Y,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowCreateOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "window.open failed", nil)
|
||||||
}
|
}
|
||||||
if input.Title != "" {
|
info, ok := r.Value.(window.WindowInfo)
|
||||||
opts = append(opts, window.WithTitle(input.Title))
|
|
||||||
}
|
|
||||||
if input.URL != "" {
|
|
||||||
opts = append(opts, window.WithURL(input.URL))
|
|
||||||
}
|
|
||||||
if input.Width > 0 || input.Height > 0 {
|
|
||||||
opts = append(opts, window.WithSize(input.Width, input.Height))
|
|
||||||
}
|
|
||||||
if input.X != 0 || input.Y != 0 {
|
|
||||||
opts = append(opts, window.WithPosition(input.X, input.Y))
|
|
||||||
}
|
|
||||||
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
|
|
||||||
if err != nil {
|
|
||||||
return nil, WindowCreateOutput{}, err
|
|
||||||
}
|
|
||||||
info, ok := result.(window.WindowInfo)
|
|
||||||
if !ok {
|
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
|
return nil, WindowCreateOutput{Window: info}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -125,9 +135,14 @@ type WindowCloseOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowClose(_ context.Context, _ *mcp.CallToolRequest, input WindowCloseInput) (*mcp.CallToolResult, WindowCloseOutput, error) {
|
func (s *Subsystem) windowClose(_ context.Context, _ *mcp.CallToolRequest, input WindowCloseInput) (*mcp.CallToolResult, WindowCloseOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskCloseWindow{Name: input.Name})
|
r := s.core.Action("window.close").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskCloseWindow{Name: input.Name}},
|
||||||
return nil, WindowCloseOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowCloseOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowCloseOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowCloseOutput{Success: true}, nil
|
return nil, WindowCloseOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -144,9 +159,14 @@ type WindowPositionOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowPosition(_ context.Context, _ *mcp.CallToolRequest, input WindowPositionInput) (*mcp.CallToolResult, WindowPositionOutput, error) {
|
func (s *Subsystem) windowPosition(_ context.Context, _ *mcp.CallToolRequest, input WindowPositionInput) (*mcp.CallToolResult, WindowPositionOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
|
r := s.core.Action("window.setPosition").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y}},
|
||||||
return nil, WindowPositionOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowPositionOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowPositionOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowPositionOutput{Success: true}, nil
|
return nil, WindowPositionOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -163,9 +183,14 @@ type WindowSizeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
|
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
|
r := s.core.Action("window.setSize").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height}},
|
||||||
return nil, WindowSizeOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowSizeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowSizeOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowSizeOutput{Success: true}, nil
|
return nil, WindowSizeOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -184,13 +209,23 @@ type WindowBoundsOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
|
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
|
r := s.core.Action("window.setPosition").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y}},
|
||||||
return nil, WindowBoundsOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowBoundsOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowBoundsOutput{}, nil
|
||||||
}
|
}
|
||||||
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
|
r = s.core.Action("window.setSize").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height}},
|
||||||
return nil, WindowBoundsOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowBoundsOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowBoundsOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowBoundsOutput{Success: true}, nil
|
return nil, WindowBoundsOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -205,9 +240,14 @@ type WindowMaximizeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
|
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskMaximise{Name: input.Name})
|
r := s.core.Action("window.maximise").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskMaximise{Name: input.Name}},
|
||||||
return nil, WindowMaximizeOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowMaximizeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowMaximizeOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowMaximizeOutput{Success: true}, nil
|
return nil, WindowMaximizeOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -222,9 +262,14 @@ type WindowMinimizeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
|
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskMinimise{Name: input.Name})
|
r := s.core.Action("window.minimise").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskMinimise{Name: input.Name}},
|
||||||
return nil, WindowMinimizeOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowMinimizeOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowMinimizeOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowMinimizeOutput{Success: true}, nil
|
return nil, WindowMinimizeOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -239,9 +284,14 @@ type WindowRestoreOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowRestore(_ context.Context, _ *mcp.CallToolRequest, input WindowRestoreInput) (*mcp.CallToolResult, WindowRestoreOutput, error) {
|
func (s *Subsystem) windowRestore(_ context.Context, _ *mcp.CallToolRequest, input WindowRestoreInput) (*mcp.CallToolResult, WindowRestoreOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskRestore{Name: input.Name})
|
r := s.core.Action("window.restore").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskRestore{Name: input.Name}},
|
||||||
return nil, WindowRestoreOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowRestoreOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowRestoreOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowRestoreOutput{Success: true}, nil
|
return nil, WindowRestoreOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -256,9 +306,14 @@ type WindowFocusOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input WindowFocusInput) (*mcp.CallToolResult, WindowFocusOutput, error) {
|
func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input WindowFocusInput) (*mcp.CallToolResult, WindowFocusOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskFocus{Name: input.Name})
|
r := s.core.Action("window.focus").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskFocus{Name: input.Name}},
|
||||||
return nil, WindowFocusOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowFocusOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowFocusOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowFocusOutput{Success: true}, nil
|
return nil, WindowFocusOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -274,13 +329,39 @@ type WindowTitleOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
|
func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetTitle{Name: input.Name, Title: input.Title})
|
r := s.core.Action("window.setTitle").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetTitle{Name: input.Name, Title: input.Title}},
|
||||||
return nil, WindowTitleOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowTitleOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowTitleOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowTitleOutput{Success: true}, nil
|
return nil, WindowTitleOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- window_title_get ---
|
||||||
|
|
||||||
|
type WindowTitleGetInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type WindowTitleGetOutput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) {
|
||||||
|
r := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
||||||
|
if !r.OK {
|
||||||
|
return nil, WindowTitleGetOutput{}, nil
|
||||||
|
}
|
||||||
|
info, _ := r.Value.(*window.WindowInfo)
|
||||||
|
if info == nil {
|
||||||
|
return nil, WindowTitleGetOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, WindowTitleGetOutput{Title: info.Title}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- window_visibility ---
|
// --- window_visibility ---
|
||||||
|
|
||||||
type WindowVisibilityInput struct {
|
type WindowVisibilityInput struct {
|
||||||
|
|
@ -292,13 +373,69 @@ type WindowVisibilityOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, input WindowVisibilityInput) (*mcp.CallToolResult, WindowVisibilityOutput, error) {
|
func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, input WindowVisibilityInput) (*mcp.CallToolResult, WindowVisibilityOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetVisibility{Name: input.Name, Visible: input.Visible})
|
r := s.core.Action("window.setVisibility").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskSetVisibility{Name: input.Name, Visible: input.Visible}},
|
||||||
return nil, WindowVisibilityOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowVisibilityOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowVisibilityOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowVisibilityOutput{Success: true}, nil
|
return nil, WindowVisibilityOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- window_always_on_top ---
|
||||||
|
|
||||||
|
type WindowAlwaysOnTopInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlwaysOnTop bool `json:"alwaysOnTop"`
|
||||||
|
}
|
||||||
|
type WindowAlwaysOnTopOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) {
|
||||||
|
r := s.core.Action("window.setAlwaysOnTop").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowAlwaysOnTopOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowAlwaysOnTopOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, WindowAlwaysOnTopOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- window_background_colour ---
|
||||||
|
|
||||||
|
type WindowBackgroundColourInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Red uint8 `json:"red"`
|
||||||
|
Green uint8 `json:"green"`
|
||||||
|
Blue uint8 `json:"blue"`
|
||||||
|
Alpha uint8 `json:"alpha"`
|
||||||
|
}
|
||||||
|
type WindowBackgroundColourOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) {
|
||||||
|
r := s.core.Action("window.setBackgroundColour").Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: window.TaskSetBackgroundColour{
|
||||||
|
Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha,
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowBackgroundColourOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowBackgroundColourOutput{}, nil
|
||||||
|
}
|
||||||
|
return nil, WindowBackgroundColourOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- window_fullscreen ---
|
// --- window_fullscreen ---
|
||||||
|
|
||||||
type WindowFullscreenInput struct {
|
type WindowFullscreenInput struct {
|
||||||
|
|
@ -310,9 +447,14 @@ type WindowFullscreenOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowFullscreenInput) (*mcp.CallToolResult, WindowFullscreenOutput, error) {
|
func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowFullscreenInput) (*mcp.CallToolResult, WindowFullscreenOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskFullscreen{Name: input.Name, Fullscreen: input.Fullscreen})
|
r := s.core.Action("window.fullscreen").Run(context.Background(), core.NewOptions(
|
||||||
if err != nil {
|
core.Option{Key: "task", Value: window.TaskFullscreen{Name: input.Name, Fullscreen: input.Fullscreen}},
|
||||||
return nil, WindowFullscreenOutput{}, err
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if e, ok := r.Value.(error); ok {
|
||||||
|
return nil, WindowFullscreenOutput{}, e
|
||||||
|
}
|
||||||
|
return nil, WindowFullscreenOutput{}, nil
|
||||||
}
|
}
|
||||||
return nil, WindowFullscreenOutput{Success: true}, nil
|
return nil, WindowFullscreenOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -333,6 +475,9 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
// QueryConfig requests this service's config section from the display orchestrator.
|
|
||||||
// Result: map[string]any
|
|
||||||
type QueryConfig struct{}
|
type QueryConfig struct{}
|
||||||
|
|
||||||
// QueryGetAppMenu returns the current app menu item descriptors.
|
|
||||||
// Result: []MenuItem
|
|
||||||
type QueryGetAppMenu struct{}
|
type QueryGetAppMenu struct{}
|
||||||
|
|
||||||
// TaskSetAppMenu sets the application menu. OnClick closures work because
|
|
||||||
// core/go IPC is in-process (no serialisation boundary).
|
|
||||||
type TaskSetAppMenu struct{ Items []MenuItem }
|
type TaskSetAppMenu struct{ Items []MenuItem }
|
||||||
|
|
||||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
type TaskSaveConfig struct{ Config map[string]any }
|
||||||
type TaskSaveConfig struct{ Value map[string]any }
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the menu service to a Core instance.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
// core.WithService(menu.Register(wailsMenu))
|
||||||
return func(c *core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return &Service{
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
manager: NewManager(p),
|
manager: NewManager(p),
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,73 +3,61 @@ package menu
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the menu service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing application menus via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
platform Platform
|
platform Platform
|
||||||
items []MenuItem // last-set menu items for QueryGetAppMenu
|
menuItems []MenuItem
|
||||||
showDevTools bool
|
showDevTools bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup queries config and registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
r := s.Core().QUERY(QueryConfig{})
|
||||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
if r.OK {
|
||||||
if handled {
|
if menuConfig, ok := r.Value.(map[string]any); ok {
|
||||||
if mCfg, ok := cfg.(map[string]any); ok {
|
s.applyConfig(menuConfig)
|
||||||
s.applyConfig(mCfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("menu.setAppMenu", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
t, _ := opts.Get("task").Value.(TaskSetAppMenu)
|
||||||
|
s.menuItems = t.Items
|
||||||
|
s.manager.SetApplicationMenu(t.Items)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(cfg map[string]any) {
|
func (s *Service) applyConfig(configData map[string]any) {
|
||||||
if v, ok := cfg["show_dev_tools"]; ok {
|
if v, ok := configData["show_dev_tools"]; ok {
|
||||||
if show, ok := v.(bool); ok {
|
if show, ok := v.(bool); ok {
|
||||||
s.showDevTools = show
|
s.showDevTools = show
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowDevTools returns whether developer tools menu items should be shown.
|
|
||||||
func (s *Service) ShowDevTools() bool {
|
func (s *Service) ShowDevTools() bool {
|
||||||
return s.showDevTools
|
return s.showDevTools
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryGetAppMenu:
|
case QueryGetAppMenu:
|
||||||
return s.items, true, nil
|
return core.Result{Value: s.menuItems, OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskSetAppMenu:
|
|
||||||
s.items = t.Items
|
|
||||||
s.manager.SetApplicationMenu(t.Items)
|
|
||||||
return nil, true, nil
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager returns the underlying menu Manager.
|
|
||||||
func (s *Service) Manager() *Manager {
|
func (s *Service) Manager() *Manager {
|
||||||
return s.manager
|
return s.manager
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,28 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestMenuService(t *testing.T) (*Service, *core.Core) {
|
func newTestMenuService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(newMockPlatform())),
|
core.WithService(Register(newMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "menu")
|
svc := core.MustServiceFor[*Service](c, "menu")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
svc, _ := newTestMenuService(t)
|
svc, _ := newTestMenuService(t)
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
|
|
@ -37,28 +42,25 @@ func TestTaskSetAppMenu_Good(t *testing.T) {
|
||||||
{Label: "Quit"},
|
{Label: "Quit"},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
_, handled, err := c.PERFORM(TaskSetAppMenu{Items: items})
|
r := taskRun(c, "menu.setAppMenu", TaskSetAppMenu{Items: items})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryGetAppMenu_Good(t *testing.T) {
|
func TestQueryGetAppMenu_Good(t *testing.T) {
|
||||||
_, c := newTestMenuService(t)
|
_, c := newTestMenuService(t)
|
||||||
|
|
||||||
items := []MenuItem{{Label: "File"}, {Label: "Edit"}}
|
items := []MenuItem{{Label: "File"}, {Label: "Edit"}}
|
||||||
_, _, _ = c.PERFORM(TaskSetAppMenu{Items: items})
|
taskRun(c, "menu.setAppMenu", TaskSetAppMenu{Items: items})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryGetAppMenu{})
|
r := c.QUERY(QueryGetAppMenu{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
menuItems := r.Value.([]MenuItem)
|
||||||
menuItems := result.([]MenuItem)
|
|
||||||
assert.Len(t, menuItems, 2)
|
assert.Len(t, menuItems, 2)
|
||||||
assert.Equal(t, "File", menuItems[0].Label)
|
assert.Equal(t, "File", menuItems[0].Label)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetAppMenu_Bad(t *testing.T) {
|
func TestTaskSetAppMenu_Bad(t *testing.T) {
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
r := c.Action("menu.setAppMenu").Run(context.Background(), core.NewOptions())
|
||||||
_, handled, _ := c.PERFORM(TaskSetAppMenu{})
|
assert.False(t, r.OK)
|
||||||
assert.False(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
// pkg/notification/messages.go
|
|
||||||
package notification
|
package notification
|
||||||
|
|
||||||
// QueryPermission checks notification authorisation. Result: PermissionStatus
|
// QueryPermission returns current notification permission status. Result: PermissionStatus
|
||||||
type QueryPermission struct{}
|
type QueryPermission struct{}
|
||||||
|
|
||||||
// TaskSend sends a notification. Falls back to dialog if platform fails.
|
// TaskSend sends a native notification, falling back to dialog on failure.
|
||||||
type TaskSend struct{ Opts NotificationOptions }
|
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{}
|
type TaskRequestPermission struct{}
|
||||||
|
|
||||||
// ActionNotificationClicked is broadcast when a notification is clicked (future).
|
// TaskRevokePermission revokes previously granted notification permission. Result: nil
|
||||||
|
type TaskRevokePermission struct{}
|
||||||
|
|
||||||
|
// TaskRegisterCategory registers a notification category with its actions.
|
||||||
|
// c.PERFORM(notification.TaskRegisterCategory{Category: notification.NotificationCategory{ID: "message", Actions: actions}})
|
||||||
|
type TaskRegisterCategory struct{ Category NotificationCategory }
|
||||||
|
|
||||||
|
// ActionNotificationClicked is broadcast when the user clicks a notification body.
|
||||||
type ActionNotificationClicked struct{ ID string }
|
type ActionNotificationClicked struct{ ID string }
|
||||||
|
|
||||||
|
// ActionNotificationActionTriggered is broadcast when the user activates a notification action button.
|
||||||
|
// c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||||
|
// if a, ok := msg.(notification.ActionNotificationActionTriggered); ok { ... }
|
||||||
|
// return nil
|
||||||
|
// })
|
||||||
|
type ActionNotificationActionTriggered struct {
|
||||||
|
NotificationID string `json:"notificationId"`
|
||||||
|
ActionID string `json:"actionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionNotificationDismissed is broadcast when the user dismisses a notification.
|
||||||
|
type ActionNotificationDismissed struct{ ID string }
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ package notification
|
||||||
|
|
||||||
// Platform abstracts the native notification backend.
|
// Platform abstracts the native notification backend.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
Send(opts NotificationOptions) error
|
Send(options NotificationOptions) error
|
||||||
RequestPermission() (bool, error)
|
RequestPermission() (bool, error)
|
||||||
CheckPermission() (bool, error)
|
CheckPermission() (bool, error)
|
||||||
|
RevokePermission() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationSeverity indicates the severity for dialog fallback.
|
// NotificationSeverity indicates the severity for dialog fallback.
|
||||||
|
|
@ -17,13 +18,30 @@ const (
|
||||||
SeverityError
|
SeverityError
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NotificationAction is a button that can be attached to a notification.
|
||||||
|
// id := "reply"; action := NotificationAction{ID: id, Title: "Reply", Destructive: false}
|
||||||
|
type NotificationAction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Destructive bool `json:"destructive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationCategory groups actions under a named category/channel.
|
||||||
|
// category := NotificationCategory{ID: "message", Actions: []NotificationAction{{ID: "reply", Title: "Reply"}}}
|
||||||
|
type NotificationCategory struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Actions []NotificationAction `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// NotificationOptions contains options for sending a notification.
|
// NotificationOptions contains options for sending a notification.
|
||||||
type NotificationOptions struct {
|
type NotificationOptions struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Subtitle string `json:"subtitle,omitempty"`
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
Severity NotificationSeverity `json:"severity,omitempty"`
|
Severity NotificationSeverity `json:"severity,omitempty"`
|
||||||
|
CategoryID string `json:"categoryId,omitempty"`
|
||||||
|
Actions []NotificationAction `json:"actions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionStatus indicates whether notifications are authorised.
|
// PermissionStatus indicates whether notifications are authorised.
|
||||||
|
|
|
||||||
|
|
@ -3,85 +3,88 @@ package notification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the notification service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing notifications via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
|
categories map[string]NotificationCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return core.Result{Value: &Service{
|
||||||
return &Service{
|
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
categories: make(map[string]NotificationCategory),
|
||||||
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("notification.send", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
t, _ := opts.Get("task").Value.(TaskSend)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.send(t.Options))
|
||||||
|
})
|
||||||
|
s.Core().Action("notification.requestPermission", func(_ context.Context, _ core.Options) core.Result {
|
||||||
|
granted, err := s.platform.RequestPermission()
|
||||||
|
return core.Result{}.New(granted, err)
|
||||||
|
})
|
||||||
|
s.Core().Action("notification.revokePermission", func(_ context.Context, _ core.Options) core.Result {
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission())
|
||||||
|
})
|
||||||
|
s.Core().Action("notification.registerCategory", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskRegisterCategory)
|
||||||
|
s.categories[t.Category.ID] = t.Category
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryPermission:
|
case QueryPermission:
|
||||||
granted, err := s.platform.CheckPermission()
|
granted, err := s.platform.CheckPermission()
|
||||||
return PermissionStatus{Granted: granted}, true, err
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{Value: PermissionStatus{Granted: granted}, OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskSend:
|
|
||||||
return nil, true, s.send(t.Opts)
|
|
||||||
case TaskRequestPermission:
|
|
||||||
granted, err := s.platform.RequestPermission()
|
|
||||||
return granted, true, err
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// send attempts native notification, falls back to dialog via IPC.
|
// send attempts native notification, falls back to dialog via IPC.
|
||||||
func (s *Service) send(opts NotificationOptions) error {
|
func (s *Service) send(options NotificationOptions) error {
|
||||||
// Generate ID if not provided
|
// Generate ID if not provided
|
||||||
if opts.ID == "" {
|
if options.ID == "" {
|
||||||
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
|
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.platform.Send(opts); err != nil {
|
if err := s.platform.Send(options); err != nil {
|
||||||
// Fallback: show as dialog via IPC
|
// Fallback: show as dialog via IPC
|
||||||
return s.fallbackDialog(opts)
|
return s.fallbackDialog(options)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallbackDialog shows a dialog via IPC when native notifications fail.
|
// fallbackDialog shows a dialog via IPC when native notifications fail.
|
||||||
func (s *Service) fallbackDialog(opts NotificationOptions) error {
|
func (s *Service) fallbackDialog(options NotificationOptions) error {
|
||||||
// Map severity to dialog type
|
// Map severity to dialog type
|
||||||
var dt dialog.DialogType
|
var dt dialog.DialogType
|
||||||
switch opts.Severity {
|
switch options.Severity {
|
||||||
case SeverityWarning:
|
case SeverityWarning:
|
||||||
dt = dialog.DialogWarning
|
dt = dialog.DialogWarning
|
||||||
case SeverityError:
|
case SeverityError:
|
||||||
|
|
@ -90,18 +93,25 @@ func (s *Service) fallbackDialog(opts NotificationOptions) error {
|
||||||
dt = dialog.DialogInfo
|
dt = dialog.DialogInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := opts.Message
|
message := options.Message
|
||||||
if opts.Subtitle != "" {
|
if options.Subtitle != "" {
|
||||||
msg = opts.Subtitle + "\n\n" + msg
|
message = options.Subtitle + "\n\n" + message
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
|
r := s.Core().Action("dialog.message").Run(context.Background(), core.NewOptions(
|
||||||
Opts: dialog.MessageDialogOptions{
|
core.Option{Key: "task", Value: dialog.TaskMessageDialog{
|
||||||
Type: dt,
|
Options: dialog.MessageDialogOptions{
|
||||||
Title: opts.Title,
|
Type: dt,
|
||||||
Message: msg,
|
Title: options.Title,
|
||||||
Buttons: []string{"OK"},
|
Message: message,
|
||||||
},
|
Buttons: []string{"OK"},
|
||||||
})
|
},
|
||||||
return err
|
}},
|
||||||
|
))
|
||||||
|
if !r.OK {
|
||||||
|
if err, ok := r.Value.(error); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,22 @@ package notification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
core "dappco.re/go/core"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
sendErr error
|
sendErr error
|
||||||
permGranted bool
|
permGranted bool
|
||||||
permErr error
|
permErr error
|
||||||
lastOpts NotificationOptions
|
revokeErr error
|
||||||
sendCalled bool
|
revokeCalled bool
|
||||||
|
lastOpts NotificationOptions
|
||||||
|
sendCalled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) Send(opts NotificationOptions) error {
|
func (m *mockPlatform) Send(opts NotificationOptions) error {
|
||||||
|
|
@ -27,6 +28,10 @@ func (m *mockPlatform) Send(opts NotificationOptions) error {
|
||||||
}
|
}
|
||||||
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
|
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||||
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
|
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||||
|
func (m *mockPlatform) RevokePermission() error {
|
||||||
|
m.revokeCalled = true
|
||||||
|
return m.revokeErr
|
||||||
|
}
|
||||||
|
|
||||||
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
|
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
|
||||||
type mockDialogPlatform struct {
|
type mockDialogPlatform struct {
|
||||||
|
|
@ -48,15 +53,20 @@ func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (st
|
||||||
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mock := &mockPlatform{permGranted: true}
|
mock := &mockPlatform{permGranted: true}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
return mock, c
|
return mock, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
svc := core.MustServiceFor[*Service](c, "notification")
|
svc := core.MustServiceFor[*Service](c, "notification")
|
||||||
|
|
@ -65,55 +75,186 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestTaskSend_Good(t *testing.T) {
|
func TestTaskSend_Good(t *testing.T) {
|
||||||
mock, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
_, handled, err := c.PERFORM(TaskSend{
|
r := taskRun(c, "notification.send", TaskSend{
|
||||||
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
|
Options: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.True(t, mock.sendCalled)
|
assert.True(t, mock.sendCalled)
|
||||||
assert.Equal(t, "Test", mock.lastOpts.Title)
|
assert.Equal(t, "Test", mock.lastOpts.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSend_Fallback_Good(t *testing.T) {
|
func TestTaskSend_Fallback_Good(t *testing.T) {
|
||||||
// Platform fails -> falls back to dialog via IPC
|
// Platform fails -> falls back to dialog via IPC
|
||||||
mockNotify := &mockPlatform{sendErr: errors.New("no permission")}
|
mockNotify := &mockPlatform{sendErr: core.NewError("no permission")}
|
||||||
mockDlg := &mockDialogPlatform{}
|
mockDlg := &mockDialogPlatform{}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(dialog.Register(mockDlg)),
|
core.WithService(dialog.Register(mockDlg)),
|
||||||
core.WithService(Register(mockNotify)),
|
core.WithService(Register(mockNotify)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSend{
|
r := taskRun(c, "notification.send", TaskSend{
|
||||||
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||||
})
|
})
|
||||||
assert.True(t, handled)
|
assert.True(t, r.OK) // fallback succeeds even though platform failed
|
||||||
assert.NoError(t, err) // fallback succeeds even though platform failed
|
|
||||||
assert.True(t, mockDlg.messageCalled)
|
assert.True(t, mockDlg.messageCalled)
|
||||||
assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type)
|
assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryPermission_Good(t *testing.T) {
|
func TestQueryPermission_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryPermission{})
|
r := c.QUERY(QueryPermission{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
status := r.Value.(PermissionStatus)
|
||||||
status := result.(PermissionStatus)
|
|
||||||
assert.True(t, status.Granted)
|
assert.True(t, status.Granted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskRequestPermission_Good(t *testing.T) {
|
func TestTaskRequestPermission_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskRequestPermission{})
|
r := c.Action("notification.requestPermission").Run(context.Background(), core.NewOptions())
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, true, r.Value)
|
||||||
assert.Equal(t, true, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSend_Bad(t *testing.T) {
|
func TestTaskSend_Bad(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.PERFORM(TaskSend{})
|
r := c.Action("notification.send").Run(context.Background(), core.NewOptions())
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskRevokePermission ---
|
||||||
|
|
||||||
|
func TestTaskRevokePermission_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.True(t, mock.revokeCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRevokePermission_Bad(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
mock.revokeErr = core.NewError("cannot revoke")
|
||||||
|
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRevokePermission_Ugly(t *testing.T) {
|
||||||
|
// No service registered — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("notification.revokePermission").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskRegisterCategory ---
|
||||||
|
|
||||||
|
func TestTaskRegisterCategory_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t)
|
||||||
|
category := NotificationCategory{
|
||||||
|
ID: "message",
|
||||||
|
Actions: []NotificationAction{
|
||||||
|
{ID: "reply", Title: "Reply"},
|
||||||
|
{ID: "delete", Title: "Delete", Destructive: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: category})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "notification")
|
||||||
|
stored, ok := svc.categories["message"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 2, len(stored.Actions))
|
||||||
|
assert.Equal(t, "reply", stored.Actions[0].ID)
|
||||||
|
assert.True(t, stored.Actions[1].Destructive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRegisterCategory_Bad(t *testing.T) {
|
||||||
|
// No service registered — action is not registered
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: NotificationCategory{ID: "x"}})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRegisterCategory_Ugly(t *testing.T) {
|
||||||
|
// Re-registering a category replaces the previous one
|
||||||
|
_, c := newTestService(t)
|
||||||
|
first := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "a", Title: "A"}}}
|
||||||
|
second := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "b", Title: "B"}, {ID: "c", Title: "C"}}}
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: first}).OK)
|
||||||
|
require.True(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: second}).OK)
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "notification")
|
||||||
|
assert.Equal(t, 2, len(svc.categories["chat"].Actions))
|
||||||
|
assert.Equal(t, "b", svc.categories["chat"].Actions[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NotificationOptions with Actions ---
|
||||||
|
|
||||||
|
func TestTaskSend_WithActions_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
options := NotificationOptions{
|
||||||
|
Title: "Team Chat",
|
||||||
|
Message: "New message from Alice",
|
||||||
|
CategoryID: "message",
|
||||||
|
Actions: []NotificationAction{
|
||||||
|
{ID: "reply", Title: "Reply"},
|
||||||
|
{ID: "dismiss", Title: "Dismiss"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := taskRun(c, "notification.send", TaskSend{Options: options})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "message", mock.lastOpts.CategoryID)
|
||||||
|
assert.Equal(t, 2, len(mock.lastOpts.Actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ActionNotificationActionTriggered ---
|
||||||
|
|
||||||
|
func TestActionNotificationActionTriggered_Good(t *testing.T) {
|
||||||
|
// ActionNotificationActionTriggered is broadcast by external code; confirm it can be received
|
||||||
|
_, c := newTestService(t)
|
||||||
|
var received *ActionNotificationActionTriggered
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if a, ok := msg.(ActionNotificationActionTriggered); ok {
|
||||||
|
received = &a
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
_ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "n1", ActionID: "reply"})
|
||||||
|
require.NotNil(t, received)
|
||||||
|
assert.Equal(t, "n1", received.NotificationID)
|
||||||
|
assert.Equal(t, "reply", received.ActionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionNotificationDismissed_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t)
|
||||||
|
var received *ActionNotificationDismissed
|
||||||
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
|
if a, ok := msg.(ActionNotificationDismissed); ok {
|
||||||
|
received = &a
|
||||||
|
}
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
_ = c.ACTION(ActionNotificationDismissed{ID: "n2"})
|
||||||
|
require.NotNil(t, received)
|
||||||
|
assert.Equal(t, "n2", received.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryPermission_Bad(t *testing.T) {
|
||||||
|
// No service — QUERY returns handled=false
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.QUERY(QueryPermission{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryPermission_Ugly(t *testing.T) {
|
||||||
|
// Platform returns error — QUERY returns OK=false (framework does not propagate Value for failed queries)
|
||||||
|
mock := &mockPlatform{permErr: core.NewError("platform error")}
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
r := c.QUERY(QueryPermission{})
|
||||||
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,11 @@ type QueryAtPoint struct{ X, Y int }
|
||||||
// QueryWorkAreas returns work areas for all screens. Result: []Rect
|
// QueryWorkAreas returns work areas for all screens. Result: []Rect
|
||||||
type QueryWorkAreas struct{}
|
type QueryWorkAreas struct{}
|
||||||
|
|
||||||
|
// QueryCurrent returns the most recently active screen. Result: *Screen (nil if no screens registered)
|
||||||
|
//
|
||||||
|
// result, _, _ := c.QUERY(screen.QueryCurrent{})
|
||||||
|
// current := result.(*screen.Screen)
|
||||||
|
type QueryCurrent struct{}
|
||||||
|
|
||||||
// ActionScreensChanged is broadcast when displays change (future).
|
// ActionScreensChanged is broadcast when displays change (future).
|
||||||
type ActionScreensChanged struct{ Screens []Screen }
|
type ActionScreensChanged struct{ Screens []Screen }
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,33 @@
|
||||||
package screen
|
package screen
|
||||||
|
|
||||||
// Platform abstracts the screen/display backend.
|
// Platform abstracts the screen/display backend.
|
||||||
|
//
|
||||||
|
// core.WithService(screen.Register(wailsPlatform))
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
GetAll() []Screen
|
GetAll() []Screen
|
||||||
GetPrimary() *Screen
|
GetPrimary() *Screen
|
||||||
|
// GetCurrent returns the most recently active screen, or the primary if unset.
|
||||||
|
// current := platform.GetCurrent()
|
||||||
|
GetCurrent() *Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen describes a display/monitor.
|
// Screen describes a display/monitor.
|
||||||
type Screen struct {
|
type Screen struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ScaleFactor float64 `json:"scaleFactor"`
|
ScaleFactor float64 `json:"scaleFactor"`
|
||||||
Size Size `json:"size"`
|
Size Size `json:"size"`
|
||||||
Bounds Rect `json:"bounds"`
|
Bounds Rect `json:"bounds"`
|
||||||
WorkArea Rect `json:"workArea"`
|
PhysicalBounds Rect `json:"physicalBounds"`
|
||||||
IsPrimary bool `json:"isPrimary"`
|
WorkArea Rect `json:"workArea"`
|
||||||
Rotation float64 `json:"rotation"`
|
PhysicalWorkArea Rect `json:"physicalWorkArea"`
|
||||||
|
IsPrimary bool `json:"isPrimary"`
|
||||||
|
Rotation float64 `json:"rotation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rect represents a rectangle with position and dimensions.
|
// Rect represents a rectangle with position and dimensions.
|
||||||
|
//
|
||||||
|
// if bounds.Contains(Point{X: cursor.X, Y: cursor.Y}) { highlightWindow() }
|
||||||
type Rect struct {
|
type Rect struct {
|
||||||
X int `json:"x"`
|
X int `json:"x"`
|
||||||
Y int `json:"y"`
|
Y int `json:"y"`
|
||||||
|
|
@ -27,8 +36,166 @@ type Rect struct {
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Origin returns the top-left corner of the rectangle.
|
||||||
|
//
|
||||||
|
// pt := bounds.Origin() // Point{X: bounds.X, Y: bounds.Y}
|
||||||
|
func (r Rect) Origin() Point {
|
||||||
|
return Point{X: r.X, Y: r.Y}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corner returns the exclusive bottom-right corner (X+Width, Y+Height).
|
||||||
|
//
|
||||||
|
// end := bounds.Corner() // Point{X: bounds.X+bounds.Width, Y: bounds.Y+bounds.Height}
|
||||||
|
func (r Rect) Corner() Point {
|
||||||
|
return Point{X: r.X + r.Width, Y: r.Y + r.Height}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsideCorner returns the inclusive bottom-right corner (X+Width-1, Y+Height-1).
|
||||||
|
//
|
||||||
|
// last := bounds.InsideCorner()
|
||||||
|
func (r Rect) InsideCorner() Point {
|
||||||
|
return Point{X: r.X + r.Width - 1, Y: r.Y + r.Height - 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty reports whether the rectangle has non-positive area.
|
||||||
|
//
|
||||||
|
// if r.IsEmpty() { return }
|
||||||
|
func (r Rect) IsEmpty() bool {
|
||||||
|
return r.Width <= 0 || r.Height <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether point pt lies within the rectangle.
|
||||||
|
//
|
||||||
|
// if workArea.Contains(windowOrigin) { snapToScreen() }
|
||||||
|
func (r Rect) Contains(pt Point) bool {
|
||||||
|
return pt.X >= r.X && pt.X < r.X+r.Width && pt.Y >= r.Y && pt.Y < r.Y+r.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
// RectSize returns the dimensions of the rectangle as a Size value.
|
||||||
|
//
|
||||||
|
// sz := bounds.RectSize() // Size{Width: bounds.Width, Height: bounds.Height}
|
||||||
|
func (r Rect) RectSize() Size {
|
||||||
|
return Size{Width: r.Width, Height: r.Height}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersect returns the overlapping region of r and other, or an empty Rect if they do not overlap.
|
||||||
|
//
|
||||||
|
// overlap := a.Intersect(b)
|
||||||
|
// if !overlap.IsEmpty() { handleOverlap(overlap) }
|
||||||
|
func (r Rect) Intersect(other Rect) Rect {
|
||||||
|
if r.IsEmpty() || other.IsEmpty() {
|
||||||
|
return Rect{}
|
||||||
|
}
|
||||||
|
maxLeft := max(r.X, other.X)
|
||||||
|
maxTop := max(r.Y, other.Y)
|
||||||
|
minRight := min(r.X+r.Width, other.X+other.Width)
|
||||||
|
minBottom := min(r.Y+r.Height, other.Y+other.Height)
|
||||||
|
if minRight > maxLeft && minBottom > maxTop {
|
||||||
|
return Rect{X: maxLeft, Y: maxTop, Width: minRight - maxLeft, Height: minBottom - maxTop}
|
||||||
|
}
|
||||||
|
return Rect{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point is a two-dimensional coordinate.
|
||||||
|
//
|
||||||
|
// centre := Point{X: bounds.X + bounds.Width/2, Y: bounds.Y + bounds.Height/2}
|
||||||
|
type Point struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
// Size represents dimensions.
|
// Size represents dimensions.
|
||||||
type Size struct {
|
type Size struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alignment describes which edge of a parent screen a child screen is placed against.
|
||||||
|
type Alignment int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlignTop Alignment = iota // child is above parent
|
||||||
|
AlignRight // child is to the right of parent
|
||||||
|
AlignBottom // child is below parent
|
||||||
|
AlignLeft // child is to the left of parent
|
||||||
|
)
|
||||||
|
|
||||||
|
// OffsetReference specifies whether the placement offset is measured from the
|
||||||
|
// beginning (top/left) or end (bottom/right) of the parent edge.
|
||||||
|
type OffsetReference int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OffsetBegin OffsetReference = iota // offset from top or left
|
||||||
|
OffsetEnd // offset from bottom or right
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScreenPlacement positions a screen relative to a parent screen.
|
||||||
|
//
|
||||||
|
// placement := screen.NewPlacement(parent, AlignRight, 0, OffsetBegin)
|
||||||
|
// placement.Apply()
|
||||||
|
type ScreenPlacement struct {
|
||||||
|
screen *Screen
|
||||||
|
parent *Screen
|
||||||
|
alignment Alignment
|
||||||
|
offset int
|
||||||
|
offsetReference OffsetReference
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlacement creates a ScreenPlacement that positions screen relative to parent.
|
||||||
|
//
|
||||||
|
// p := NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin)
|
||||||
|
// p.Apply()
|
||||||
|
func NewPlacement(screen, parent *Screen, alignment Alignment, offset int, reference OffsetReference) ScreenPlacement {
|
||||||
|
return ScreenPlacement{
|
||||||
|
screen: screen,
|
||||||
|
parent: parent,
|
||||||
|
alignment: alignment,
|
||||||
|
offset: offset,
|
||||||
|
offsetReference: reference,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply moves screen.Bounds so that it sits against the specified edge of parent.
|
||||||
|
//
|
||||||
|
// NewPlacement(s, p, AlignRight, 0, OffsetBegin).Apply()
|
||||||
|
func (p ScreenPlacement) Apply() {
|
||||||
|
parentBounds := p.parent.Bounds
|
||||||
|
screenBounds := p.screen.Bounds
|
||||||
|
|
||||||
|
newX := parentBounds.X
|
||||||
|
newY := parentBounds.Y
|
||||||
|
offset := p.offset
|
||||||
|
|
||||||
|
if p.alignment == AlignTop || p.alignment == AlignBottom {
|
||||||
|
if p.offsetReference == OffsetEnd {
|
||||||
|
offset = parentBounds.Width - offset - screenBounds.Width
|
||||||
|
}
|
||||||
|
offset = min(offset, parentBounds.Width)
|
||||||
|
offset = max(offset, -screenBounds.Width)
|
||||||
|
newX += offset
|
||||||
|
if p.alignment == AlignTop {
|
||||||
|
newY -= screenBounds.Height
|
||||||
|
} else {
|
||||||
|
newY += parentBounds.Height
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if p.offsetReference == OffsetEnd {
|
||||||
|
offset = parentBounds.Height - offset - screenBounds.Height
|
||||||
|
}
|
||||||
|
offset = min(offset, parentBounds.Height)
|
||||||
|
offset = max(offset, -screenBounds.Height)
|
||||||
|
newY += offset
|
||||||
|
if p.alignment == AlignLeft {
|
||||||
|
newX -= screenBounds.Width
|
||||||
|
} else {
|
||||||
|
newX += parentBounds.Width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workAreaOffsetX := p.screen.WorkArea.X - p.screen.Bounds.X
|
||||||
|
workAreaOffsetY := p.screen.WorkArea.Y - p.screen.Bounds.Y
|
||||||
|
p.screen.Bounds.X = newX
|
||||||
|
p.screen.Bounds.Y = newY
|
||||||
|
p.screen.WorkArea.X = newX + workAreaOffsetX
|
||||||
|
p.screen.WorkArea.Y = newY + workAreaOffsetY
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,53 +4,52 @@ package screen
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the screen service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service providing screen/display queries via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the screen service to a Core instance.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
// core.WithService(screen.Register(wailsScreen))
|
||||||
return func(c *core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return &Service{
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q := q.(type) {
|
switch q := q.(type) {
|
||||||
case QueryAll:
|
case QueryAll:
|
||||||
return s.platform.GetAll(), true, nil
|
return core.Result{Value: s.platform.GetAll(), OK: true}
|
||||||
case QueryPrimary:
|
case QueryPrimary:
|
||||||
return s.platform.GetPrimary(), true, nil
|
return core.Result{Value: s.platform.GetPrimary(), OK: true}
|
||||||
case QueryByID:
|
case QueryByID:
|
||||||
return s.queryByID(q.ID), true, nil
|
return core.Result{Value: s.queryByID(q.ID), OK: true}
|
||||||
case QueryAtPoint:
|
case QueryAtPoint:
|
||||||
return s.queryAtPoint(q.X, q.Y), true, nil
|
return core.Result{Value: s.queryAtPoint(q.X, q.Y), OK: true}
|
||||||
case QueryWorkAreas:
|
case QueryWorkAreas:
|
||||||
return s.queryWorkAreas(), true, nil
|
return core.Result{Value: s.queryWorkAreas(), OK: true}
|
||||||
|
case QueryCurrent:
|
||||||
|
return core.Result{Value: s.platform.GetCurrent(), OK: true}
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
screens []Screen
|
screens []Screen
|
||||||
|
current *Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) GetAll() []Screen { return m.screens }
|
func (m *mockPlatform) GetAll() []Screen { return m.screens }
|
||||||
|
|
@ -23,6 +24,12 @@ func (m *mockPlatform) GetPrimary() *Screen {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockPlatform) GetCurrent() *Screen {
|
||||||
|
if m.current != nil {
|
||||||
|
return m.current
|
||||||
|
}
|
||||||
|
return m.GetPrimary()
|
||||||
|
}
|
||||||
|
|
||||||
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -42,12 +49,11 @@ func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(mock)),
|
core.WithService(Register(mock)),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
return mock, c
|
return mock, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,19 +65,17 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryAll_Good(t *testing.T) {
|
func TestQueryAll_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryAll{})
|
r := c.QUERY(QueryAll{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
screens := r.Value.([]Screen)
|
||||||
screens := result.([]Screen)
|
|
||||||
assert.Len(t, screens, 2)
|
assert.Len(t, screens, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryPrimary_Good(t *testing.T) {
|
func TestQueryPrimary_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryPrimary{})
|
r := c.QUERY(QueryPrimary{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
scr := r.Value.(*Screen)
|
||||||
scr := result.(*Screen)
|
|
||||||
require.NotNil(t, scr)
|
require.NotNil(t, scr)
|
||||||
assert.Equal(t, "Built-in", scr.Name)
|
assert.Equal(t, "Built-in", scr.Name)
|
||||||
assert.True(t, scr.IsPrimary)
|
assert.True(t, scr.IsPrimary)
|
||||||
|
|
@ -79,54 +83,228 @@ func TestQueryPrimary_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryByID_Good(t *testing.T) {
|
func TestQueryByID_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryByID{ID: "2"})
|
r := c.QUERY(QueryByID{ID: "2"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
scr := r.Value.(*Screen)
|
||||||
scr := result.(*Screen)
|
|
||||||
require.NotNil(t, scr)
|
require.NotNil(t, scr)
|
||||||
assert.Equal(t, "External", scr.Name)
|
assert.Equal(t, "External", scr.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryByID_Bad(t *testing.T) {
|
func TestQueryByID_Bad(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryByID{ID: "99"})
|
r := c.QUERY(QueryByID{ID: "99"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Nil(t, r.Value)
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryAtPoint_Good(t *testing.T) {
|
func TestQueryAtPoint_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
|
|
||||||
// Point on primary screen
|
// Point on primary screen
|
||||||
result, handled, err := c.QUERY(QueryAtPoint{X: 100, Y: 100})
|
r := c.QUERY(QueryAtPoint{X: 100, Y: 100})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
scr := r.Value.(*Screen)
|
||||||
scr := result.(*Screen)
|
|
||||||
require.NotNil(t, scr)
|
require.NotNil(t, scr)
|
||||||
assert.Equal(t, "Built-in", scr.Name)
|
assert.Equal(t, "Built-in", scr.Name)
|
||||||
|
|
||||||
// Point on external screen
|
// Point on external screen
|
||||||
result, _, _ = c.QUERY(QueryAtPoint{X: 3000, Y: 500})
|
r2 := c.QUERY(QueryAtPoint{X: 3000, Y: 500})
|
||||||
scr = result.(*Screen)
|
scr = r2.Value.(*Screen)
|
||||||
require.NotNil(t, scr)
|
require.NotNil(t, scr)
|
||||||
assert.Equal(t, "External", scr.Name)
|
assert.Equal(t, "External", scr.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryAtPoint_Bad(t *testing.T) {
|
func TestQueryAtPoint_Bad(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryAtPoint{X: -1000, Y: -1000})
|
r := c.QUERY(QueryAtPoint{X: -1000, Y: -1000})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Nil(t, r.Value)
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWorkAreas_Good(t *testing.T) {
|
func TestQueryWorkAreas_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.QUERY(QueryWorkAreas{})
|
r := c.QUERY(QueryWorkAreas{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
areas := r.Value.([]Rect)
|
||||||
areas := result.([]Rect)
|
|
||||||
assert.Len(t, areas, 2)
|
assert.Len(t, areas, 2)
|
||||||
assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset
|
assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- QueryCurrent ---
|
||||||
|
|
||||||
|
func TestQueryCurrent_Good(t *testing.T) {
|
||||||
|
// current falls back to primary when not explicitly set
|
||||||
|
_, c := newTestService(t)
|
||||||
|
r := c.QUERY(QueryCurrent{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
scr := r.Value.(*Screen)
|
||||||
|
require.NotNil(t, scr)
|
||||||
|
assert.True(t, scr.IsPrimary)
|
||||||
|
assert.Equal(t, "Built-in", scr.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryCurrent_Bad(t *testing.T) {
|
||||||
|
// no screens at all → GetCurrent returns nil
|
||||||
|
mock := &mockPlatform{screens: []Screen{}}
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryCurrent{})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Nil(t, r.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryCurrent_Ugly(t *testing.T) {
|
||||||
|
// current is explicitly set to the external screen
|
||||||
|
mock := &mockPlatform{
|
||||||
|
screens: []Screen{
|
||||||
|
{ID: "1", Name: "Built-in", IsPrimary: true,
|
||||||
|
Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}},
|
||||||
|
{ID: "2", Name: "External",
|
||||||
|
Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock.current = &mock.screens[1]
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
|
||||||
|
r := c.QUERY(QueryCurrent{})
|
||||||
|
scr := r.Value.(*Screen)
|
||||||
|
require.NotNil(t, scr)
|
||||||
|
assert.Equal(t, "External", scr.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rect geometry helpers ---
|
||||||
|
|
||||||
|
func TestRect_Origin_Good(t *testing.T) {
|
||||||
|
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
|
||||||
|
pt := r.Origin()
|
||||||
|
assert.Equal(t, Point{X: 10, Y: 20}, pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Corner_Good(t *testing.T) {
|
||||||
|
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
|
||||||
|
pt := r.Corner()
|
||||||
|
assert.Equal(t, Point{X: 110, Y: 70}, pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_InsideCorner_Good(t *testing.T) {
|
||||||
|
r := Rect{X: 10, Y: 20, Width: 100, Height: 50}
|
||||||
|
pt := r.InsideCorner()
|
||||||
|
assert.Equal(t, Point{X: 109, Y: 69}, pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_IsEmpty_Good(t *testing.T) {
|
||||||
|
assert.False(t, Rect{X: 0, Y: 0, Width: 1, Height: 1}.IsEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_IsEmpty_Bad(t *testing.T) {
|
||||||
|
assert.True(t, Rect{}.IsEmpty())
|
||||||
|
assert.True(t, Rect{Width: 0, Height: 10}.IsEmpty())
|
||||||
|
assert.True(t, Rect{Width: 10, Height: -1}.IsEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Contains_Good(t *testing.T) {
|
||||||
|
r := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||||
|
assert.True(t, r.Contains(Point{X: 0, Y: 0}))
|
||||||
|
assert.True(t, r.Contains(Point{X: 50, Y: 50}))
|
||||||
|
assert.True(t, r.Contains(Point{X: 99, Y: 99}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Contains_Bad(t *testing.T) {
|
||||||
|
r := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||||
|
// exclusive right/bottom edge
|
||||||
|
assert.False(t, r.Contains(Point{X: 100, Y: 50}))
|
||||||
|
assert.False(t, r.Contains(Point{X: 50, Y: 100}))
|
||||||
|
assert.False(t, r.Contains(Point{X: -1, Y: 50}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Contains_Ugly(t *testing.T) {
|
||||||
|
// zero-size rect never contains anything
|
||||||
|
r := Rect{X: 5, Y: 5, Width: 0, Height: 0}
|
||||||
|
assert.False(t, r.Contains(Point{X: 5, Y: 5}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_RectSize_Good(t *testing.T) {
|
||||||
|
r := Rect{X: 100, Y: 200, Width: 1920, Height: 1080}
|
||||||
|
sz := r.RectSize()
|
||||||
|
assert.Equal(t, Size{Width: 1920, Height: 1080}, sz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Intersect_Good(t *testing.T) {
|
||||||
|
a := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||||
|
b := Rect{X: 50, Y: 50, Width: 100, Height: 100}
|
||||||
|
overlap := a.Intersect(b)
|
||||||
|
assert.Equal(t, Rect{X: 50, Y: 50, Width: 50, Height: 50}, overlap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Intersect_Bad(t *testing.T) {
|
||||||
|
// no overlap
|
||||||
|
a := Rect{X: 0, Y: 0, Width: 50, Height: 50}
|
||||||
|
b := Rect{X: 100, Y: 100, Width: 50, Height: 50}
|
||||||
|
overlap := a.Intersect(b)
|
||||||
|
assert.True(t, overlap.IsEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRect_Intersect_Ugly(t *testing.T) {
|
||||||
|
// empty rect intersects nothing
|
||||||
|
a := Rect{X: 0, Y: 0, Width: 0, Height: 0}
|
||||||
|
b := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||||
|
overlap := a.Intersect(b)
|
||||||
|
assert.True(t, overlap.IsEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ScreenPlacement ---
|
||||||
|
|
||||||
|
func TestScreenPlacement_Apply_Good(t *testing.T) {
|
||||||
|
// secondary placed to the RIGHT of primary, no offset
|
||||||
|
primary := &Screen{
|
||||||
|
Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600},
|
||||||
|
WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562},
|
||||||
|
}
|
||||||
|
secondary := &Screen{
|
||||||
|
Bounds: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
WorkArea: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
}
|
||||||
|
NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin).Apply()
|
||||||
|
assert.Equal(t, 2560, secondary.Bounds.X)
|
||||||
|
assert.Equal(t, 0, secondary.Bounds.Y)
|
||||||
|
assert.Equal(t, 2560, secondary.WorkArea.X)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenPlacement_Apply_Bad(t *testing.T) {
|
||||||
|
// screen placed ABOVE primary: newY = primary.Y - secondary.Height
|
||||||
|
primary := &Screen{
|
||||||
|
Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
}
|
||||||
|
secondary := &Screen{
|
||||||
|
Bounds: Rect{X: 0, Y: -600, Width: 1920, Height: 600},
|
||||||
|
WorkArea: Rect{X: 0, Y: -600, Width: 1920, Height: 600},
|
||||||
|
}
|
||||||
|
NewPlacement(secondary, primary, AlignTop, 0, OffsetBegin).Apply()
|
||||||
|
assert.Equal(t, 0, secondary.Bounds.X)
|
||||||
|
assert.Equal(t, -600, secondary.Bounds.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenPlacement_Apply_Ugly(t *testing.T) {
|
||||||
|
// END offset reference — places secondary flush to the bottom-right of parent
|
||||||
|
primary := &Screen{
|
||||||
|
Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
}
|
||||||
|
secondary := &Screen{
|
||||||
|
Bounds: Rect{X: 0, Y: 0, Width: 800, Height: 600},
|
||||||
|
WorkArea: Rect{X: 0, Y: 0, Width: 800, Height: 600},
|
||||||
|
}
|
||||||
|
// AlignBottom + OffsetEnd + offset=0 → secondary starts at right edge of parent
|
||||||
|
NewPlacement(secondary, primary, AlignBottom, 0, OffsetEnd).Apply()
|
||||||
|
assert.Equal(t, 1920-800, secondary.Bounds.X) // flush right
|
||||||
|
assert.Equal(t, 1080, secondary.Bounds.Y) // just below parent
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,29 @@
|
||||||
// pkg/systray/menu.go
|
// pkg/systray/menu.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import "fmt"
|
import coreerr "dappco.re/go/core/log"
|
||||||
|
|
||||||
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
||||||
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.SetMenu", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
menu := m.buildMenu(items)
|
menu := m.platform.NewMenu()
|
||||||
|
m.buildMenu(menu, items)
|
||||||
m.tray.SetMenu(menu)
|
m.tray.SetMenu(menu)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
||||||
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
|
||||||
menu := m.platform.NewMenu()
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Type == "separator" {
|
if item.Type == "separator" {
|
||||||
menu.AddSeparator()
|
menu.AddSeparator()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(item.Submenu) > 0 {
|
if len(item.Submenu) > 0 {
|
||||||
sub := m.buildMenu(item.Submenu)
|
sub := menu.AddSubmenu(item.Label)
|
||||||
mi := menu.Add(item.Label)
|
m.buildMenu(sub, item.Submenu)
|
||||||
_ = mi.AddSubmenu()
|
|
||||||
_ = sub // TODO: wire sub into parent via platform
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mi := menu.Add(item.Label)
|
mi := menu.Add(item.Label)
|
||||||
|
|
@ -47,7 +45,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return menu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterCallback registers a callback for a menu action ID.
|
// RegisterCallback registers a callback for a menu action ID.
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,17 @@
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
// QueryConfig requests this service's config section from the display orchestrator.
|
|
||||||
// Result: map[string]any
|
|
||||||
type QueryConfig struct{}
|
type QueryConfig struct{}
|
||||||
|
|
||||||
// --- Tasks ---
|
|
||||||
|
|
||||||
// TaskSetTrayIcon sets the tray icon.
|
|
||||||
type TaskSetTrayIcon struct{ Data []byte }
|
type TaskSetTrayIcon struct{ Data []byte }
|
||||||
|
|
||||||
// TaskSetTrayMenu sets the tray menu items.
|
|
||||||
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
||||||
|
|
||||||
// TaskShowPanel shows the tray panel window.
|
|
||||||
type TaskShowPanel struct{}
|
type TaskShowPanel struct{}
|
||||||
|
|
||||||
// TaskHidePanel hides the tray panel window.
|
|
||||||
type TaskHidePanel struct{}
|
type TaskHidePanel struct{}
|
||||||
|
|
||||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
type TaskSaveConfig struct{ Config map[string]any }
|
||||||
type TaskSaveConfig struct{ Value map[string]any }
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
// ActionTrayClicked is broadcast when the tray icon is clicked.
|
|
||||||
type ActionTrayClicked struct{}
|
type ActionTrayClicked struct{}
|
||||||
|
|
||||||
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
|
|
||||||
type ActionTrayMenuItemClicked struct{ ActionID string }
|
type ActionTrayMenuItemClicked struct{ ActionID string }
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,17 @@ type exportedMockTray struct {
|
||||||
tooltip, label string
|
tooltip, label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
|
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
|
||||||
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||||
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
|
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
|
||||||
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
|
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
|
||||||
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
|
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
|
||||||
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
|
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
|
||||||
|
|
||||||
type exportedMockMenu struct{ items []exportedMockMenuItem }
|
type exportedMockMenu struct {
|
||||||
|
items []exportedMockMenuItem
|
||||||
|
subs []*exportedMockMenu
|
||||||
|
}
|
||||||
|
|
||||||
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||||
mi := &exportedMockMenuItem{label: label}
|
mi := &exportedMockMenuItem{label: label}
|
||||||
|
|
@ -28,15 +31,20 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||||
return mi
|
return mi
|
||||||
}
|
}
|
||||||
func (m *exportedMockMenu) AddSeparator() {}
|
func (m *exportedMockMenu) AddSeparator() {}
|
||||||
|
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
|
||||||
|
m.items = append(m.items, exportedMockMenuItem{label: label})
|
||||||
|
sub := &exportedMockMenu{}
|
||||||
|
m.subs = append(m.subs, sub)
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
type exportedMockMenuItem struct {
|
type exportedMockMenuItem struct {
|
||||||
label, tooltip string
|
label, tooltip string
|
||||||
checked, enabled bool
|
checked, enabled bool
|
||||||
onClick func()
|
onClick func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
|
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
|
||||||
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
||||||
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
||||||
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
||||||
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
|
||||||
|
|
||||||
type mockTrayMenu struct {
|
type mockTrayMenu struct {
|
||||||
items []string
|
items []string
|
||||||
|
subs []*mockTrayMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||||
|
|
@ -29,14 +30,19 @@ func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||||
return &mockTrayMenuItem{}
|
return &mockTrayMenuItem{}
|
||||||
}
|
}
|
||||||
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
|
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.subs = append(m.subs, sub)
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
type mockTrayMenuItem struct{}
|
type mockTrayMenuItem struct{}
|
||||||
|
|
||||||
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||||
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
|
|
||||||
|
|
||||||
type mockTray struct {
|
type mockTray struct {
|
||||||
icon, templateIcon []byte
|
icon, templateIcon []byte
|
||||||
|
|
@ -45,9 +51,9 @@ type mockTray struct {
|
||||||
attachedWindow WindowHandle
|
attachedWindow WindowHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
||||||
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||||
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
|
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
|
||||||
func (t *mockTray) SetLabel(text string) { t.label = text }
|
func (t *mockTray) SetLabel(text string) { t.label = text }
|
||||||
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
|
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
|
||||||
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type PlatformTray interface {
|
||||||
type PlatformMenu interface {
|
type PlatformMenu interface {
|
||||||
Add(label string) PlatformMenuItem
|
Add(label string) PlatformMenuItem
|
||||||
AddSeparator()
|
AddSeparator()
|
||||||
|
AddSubmenu(label string) PlatformMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformMenuItem is a single item in a tray menu.
|
// PlatformMenuItem is a single item in a tray menu.
|
||||||
|
|
@ -29,7 +30,6 @@ type PlatformMenuItem interface {
|
||||||
SetChecked(checked bool)
|
SetChecked(checked bool)
|
||||||
SetEnabled(enabled bool)
|
SetEnabled(enabled bool)
|
||||||
OnClick(fn func())
|
OnClick(fn func())
|
||||||
AddSubmenu() PlatformMenu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WindowHandle is a cross-package interface for window operations.
|
// WindowHandle is a cross-package interface for window operations.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the systray service to a Core instance.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
// core.WithService(systray.Register(wailsSystray))
|
||||||
return func(c *core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return &Service{
|
return func(c *core.Core) core.Result {
|
||||||
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
manager: NewManager(p),
|
manager: NewManager(p),
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@ package systray
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the systray service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing the system tray via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
|
|
@ -17,52 +15,48 @@ type Service struct {
|
||||||
iconPath string
|
iconPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup queries config and registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
r := s.Core().QUERY(QueryConfig{})
|
||||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
if r.OK {
|
||||||
if handled {
|
if trayConfig, ok := r.Value.(map[string]any); ok {
|
||||||
if tCfg, ok := cfg.(map[string]any); ok {
|
s.applyConfig(trayConfig)
|
||||||
s.applyConfig(tCfg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().Action("systray.setIcon", func(_ context.Context, opts core.Options) core.Result {
|
||||||
return nil
|
t, _ := opts.Get("task").Value.(TaskSetTrayIcon)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.manager.SetIcon(t.Data))
|
||||||
|
})
|
||||||
|
s.Core().Action("systray.setMenu", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetTrayMenu)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetTrayMenu(t))
|
||||||
|
})
|
||||||
|
s.Core().Action("systray.showPanel", func(_ context.Context, _ core.Options) core.Result {
|
||||||
|
// Panel show — deferred (requires WindowHandle integration)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
s.Core().Action("systray.hidePanel", func(_ context.Context, _ core.Options) core.Result {
|
||||||
|
// Panel hide — deferred (requires WindowHandle integration)
|
||||||
|
return core.Result{OK: true}
|
||||||
|
})
|
||||||
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(cfg map[string]any) {
|
func (s *Service) applyConfig(configData map[string]any) {
|
||||||
tooltip, _ := cfg["tooltip"].(string)
|
tooltip, _ := configData["tooltip"].(string)
|
||||||
if tooltip == "" {
|
if tooltip == "" {
|
||||||
tooltip = "Core"
|
tooltip = "Core"
|
||||||
}
|
}
|
||||||
_ = s.manager.Setup(tooltip, tooltip)
|
_ = 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.
|
// Icon loading is deferred to when assets are available.
|
||||||
// Store the path for later use.
|
// Store the path for later use.
|
||||||
s.iconPath = iconPath
|
s.iconPath = iconPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|
||||||
switch t := t.(type) {
|
|
||||||
case TaskSetTrayIcon:
|
|
||||||
return nil, true, s.manager.SetIcon(t.Data)
|
|
||||||
case TaskSetTrayMenu:
|
|
||||||
return nil, true, s.taskSetTrayMenu(t)
|
|
||||||
case TaskShowPanel:
|
|
||||||
// Panel show — deferred (requires WindowHandle integration)
|
|
||||||
return nil, true, nil
|
|
||||||
case TaskHidePanel:
|
|
||||||
// Panel hide — deferred (requires WindowHandle integration)
|
|
||||||
return nil, true, nil
|
|
||||||
default:
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
||||||
|
|
@ -78,7 +72,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
||||||
return s.manager.SetMenu(t.Items)
|
return s.manager.SetMenu(t.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager returns the underlying systray Manager.
|
|
||||||
func (s *Service) Manager() *Manager {
|
func (s *Service) Manager() *Manager {
|
||||||
return s.manager
|
return s.manager
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,28 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
|
func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(newMockPlatform())),
|
core.WithService(Register(newMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "systray")
|
svc := core.MustServiceFor[*Service](c, "systray")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
svc, _ := newTestSystrayService(t)
|
svc, _ := newTestSystrayService(t)
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
|
|
@ -34,9 +39,8 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
|
||||||
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
|
||||||
icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header
|
icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header
|
||||||
_, handled, err := c.PERFORM(TaskSetTrayIcon{Data: icon})
|
r := taskRun(c, "systray.setIcon", TaskSetTrayIcon{Data: icon})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetTrayMenu_Good(t *testing.T) {
|
func TestTaskSetTrayMenu_Good(t *testing.T) {
|
||||||
|
|
@ -49,15 +53,13 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
|
||||||
{Type: "separator"},
|
{Type: "separator"},
|
||||||
{Label: "Quit", ActionID: "quit"},
|
{Label: "Quit", ActionID: "quit"},
|
||||||
}
|
}
|
||||||
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: items})
|
r := taskRun(c, "systray.setMenu", TaskSetTrayMenu{Items: items})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
||||||
// No systray service — PERFORM returns handled=false
|
// No systray service — action is not registered
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
r := c.Action("systray.setIcon").Run(context.Background(), core.NewOptions())
|
||||||
_, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
|
assert.False(t, r.OK)
|
||||||
assert.False(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ package systray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/apptray.png
|
//go:embed assets/apptray.png
|
||||||
|
|
@ -31,7 +32,7 @@ func NewManager(platform Platform) *Manager {
|
||||||
func (m *Manager) Setup(tooltip, label string) error {
|
func (m *Manager) Setup(tooltip, label string) error {
|
||||||
m.tray = m.platform.NewTray()
|
m.tray = m.platform.NewTray()
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("platform returned nil tray")
|
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTemplateIcon(defaultIcon)
|
m.tray.SetTemplateIcon(defaultIcon)
|
||||||
m.tray.SetTooltip(tooltip)
|
m.tray.SetTooltip(tooltip)
|
||||||
|
|
@ -42,7 +43,7 @@ func (m *Manager) Setup(tooltip, label string) error {
|
||||||
// SetIcon sets the tray icon.
|
// SetIcon sets the tray icon.
|
||||||
func (m *Manager) SetIcon(data []byte) error {
|
func (m *Manager) SetIcon(data []byte) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetIcon(data)
|
m.tray.SetIcon(data)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -51,7 +52,7 @@ func (m *Manager) SetIcon(data []byte) error {
|
||||||
// SetTemplateIcon sets the template icon (macOS).
|
// SetTemplateIcon sets the template icon (macOS).
|
||||||
func (m *Manager) SetTemplateIcon(data []byte) error {
|
func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTemplateIcon(data)
|
m.tray.SetTemplateIcon(data)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -60,7 +61,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||||
// SetTooltip sets the tray tooltip.
|
// SetTooltip sets the tray tooltip.
|
||||||
func (m *Manager) SetTooltip(text string) error {
|
func (m *Manager) SetTooltip(text string) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTooltip(text)
|
m.tray.SetTooltip(text)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -69,7 +70,7 @@ func (m *Manager) SetTooltip(text string) error {
|
||||||
// SetLabel sets the tray label.
|
// SetLabel sets the tray label.
|
||||||
func (m *Manager) SetLabel(text string) error {
|
func (m *Manager) SetLabel(text string) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetLabel(text)
|
m.tray.SetLabel(text)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -78,7 +79,7 @@ func (m *Manager) SetLabel(text string) error {
|
||||||
// AttachWindow attaches a panel window to the tray.
|
// AttachWindow attaches a panel window to the tray.
|
||||||
func (m *Manager) AttachWindow(w WindowHandle) error {
|
func (m *Manager) AttachWindow(w WindowHandle) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return fmt.Errorf("tray not initialised")
|
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.AttachWindow(w)
|
m.tray.AttachWindow(w)
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,29 @@ func TestManager_GetInfo_Good(t *testing.T) {
|
||||||
info = m.GetInfo()
|
info = m.GetInfo()
|
||||||
assert.True(t, info["active"].(bool))
|
assert.True(t, info["active"].(bool))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManager_Build_Submenu_Recursive_Good(t *testing.T) {
|
||||||
|
m, p := newTestManager()
|
||||||
|
require.NoError(t, m.Setup("Core", "Core"))
|
||||||
|
|
||||||
|
items := []TrayMenuItem{
|
||||||
|
{
|
||||||
|
Label: "Parent",
|
||||||
|
Submenu: []TrayMenuItem{
|
||||||
|
{Label: "Child 1"},
|
||||||
|
{Label: "Child 2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, m.SetMenu(items))
|
||||||
|
require.Len(t, p.menus, 1)
|
||||||
|
|
||||||
|
menu := p.menus[0]
|
||||||
|
require.Len(t, menu.items, 1)
|
||||||
|
assert.Equal(t, "Parent", menu.items[0])
|
||||||
|
require.Len(t, menu.subs, 1)
|
||||||
|
require.Len(t, menu.subs[0].items, 2)
|
||||||
|
assert.Equal(t, "Child 1", menu.subs[0].items[0])
|
||||||
|
assert.Equal(t, "Child 2", menu.subs[0].items[1])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ type wailsTray struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
|
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
|
||||||
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
|
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
|
||||||
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
|
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
|
||||||
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
|
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
|
||||||
|
|
||||||
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
|
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
|
||||||
if wm, ok := menu.(*wailsTrayMenu); ok {
|
if wm, ok := menu.(*wailsTrayMenu); ok {
|
||||||
|
|
@ -56,18 +56,18 @@ func (m *wailsTrayMenu) AddSeparator() {
|
||||||
m.menu.AddSeparator()
|
m.menu.AddSeparator()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu {
|
||||||
|
return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)}
|
||||||
|
}
|
||||||
|
|
||||||
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
|
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
|
||||||
type wailsTrayMenuItem struct {
|
type wailsTrayMenuItem struct {
|
||||||
item *application.MenuItem
|
item *application.MenuItem
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
|
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
|
||||||
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
|
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
|
||||||
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
|
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
|
||||||
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
||||||
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
||||||
}
|
}
|
||||||
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
|
|
||||||
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
|
|
||||||
return &wailsTrayMenu{menu: application.NewMenu()}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,32 @@ type TaskSetViewport struct {
|
||||||
// TaskClearConsole clears captured console messages. Result: nil
|
// TaskClearConsole clears captured console messages. Result: nil
|
||||||
type TaskClearConsole struct{ Window string `json:"window"` }
|
type TaskClearConsole struct{ Window string `json:"window"` }
|
||||||
|
|
||||||
|
// TaskSetURL navigates to a URL (alias for TaskNavigate, preferred for direct URL setting). Result: nil
|
||||||
|
type TaskSetURL struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSetZoom sets the page zoom level. Result: nil
|
||||||
|
// zoom := 1.0 is normal; 1.5 is 150%; 0.5 is 50%.
|
||||||
|
type TaskSetZoom struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Zoom float64 `json:"zoom"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskPrint triggers the browser print dialog or prints to PDF. Result: *PrintResult
|
||||||
|
// c.PERFORM(TaskPrint{Window: "main"}) // opens print dialog via window.print()
|
||||||
|
// c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) // returns base64 PDF bytes
|
||||||
|
type TaskPrint struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
ToPDF bool `json:"toPDF,omitempty"` // true = return PDF bytes; false = open print dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryZoom gets the current page zoom level. Result: float64
|
||||||
|
// result, _, _ := c.QUERY(QueryZoom{Window: "main"})
|
||||||
|
// zoom := result.(float64)
|
||||||
|
type QueryZoom struct{ Window string `json:"window"` }
|
||||||
|
|
||||||
// --- Actions (broadcast) ---
|
// --- Actions (broadcast) ---
|
||||||
|
|
||||||
// ActionConsoleMessage is broadcast when a console message is captured.
|
// ActionConsoleMessage is broadcast when a console message is captured.
|
||||||
|
|
@ -169,3 +195,9 @@ type ScreenshotResult struct {
|
||||||
Base64 string `json:"base64"`
|
Base64 string `json:"base64"`
|
||||||
MimeType string `json:"mimeType"` // always "image/png"
|
MimeType string `json:"mimeType"` // always "image/png"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrintResult wraps PDF bytes as base64 when TaskPrint.ToPDF is true.
|
||||||
|
type PrintResult struct {
|
||||||
|
Base64 string `json:"base64"`
|
||||||
|
MimeType string `json:"mimeType"` // always "application/pdf"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gowebview "forge.lthn.ai/core/go-webview"
|
gowebview "forge.lthn.ai/core/go-webview"
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,50 +34,53 @@ type connector interface {
|
||||||
ClearConsole()
|
ClearConsole()
|
||||||
SetViewport(width, height int) error
|
SetViewport(width, height int) error
|
||||||
UploadFile(selector string, paths []string) error
|
UploadFile(selector string, paths []string) error
|
||||||
|
GetZoom() (float64, error)
|
||||||
|
SetZoom(zoom float64) error
|
||||||
|
Print(toPDF bool) ([]byte, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options holds configuration for the webview service.
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
||||||
Timeout time.Duration // Operation timeout (default: 30s)
|
Timeout time.Duration // Operation timeout (default: 30s)
|
||||||
ConsoleLimit int // Max console messages per window (default: 1000)
|
ConsoleLimit int // Max console messages per window (default: 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is a core.Service managing webview interactions via IPC.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
opts Options
|
options Options
|
||||||
connections map[string]connector
|
connections map[string]connector
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||||
watcherSetup func(conn connector, windowName string) // called after connection creation
|
watcherSetup func(conn connector, windowName string) // called after connection creation
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure with the given options.
|
// Register binds the webview service to a Core instance.
|
||||||
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
|
// core.WithService(webview.Register())
|
||||||
|
// core.WithService(webview.Register(func(o *Options) { o.DebugURL = "http://localhost:9223" }))
|
||||||
|
func Register(optionFns ...func(*Options)) func(*core.Core) core.Result {
|
||||||
o := Options{
|
o := Options{
|
||||||
DebugURL: "http://localhost:9222",
|
DebugURL: "http://localhost:9222",
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
ConsoleLimit: 1000,
|
ConsoleLimit: 1000,
|
||||||
}
|
}
|
||||||
for _, fn := range opts {
|
for _, fn := range optionFns {
|
||||||
fn(&o)
|
fn(&o)
|
||||||
}
|
}
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||||
opts: o,
|
options: o,
|
||||||
connections: make(map[string]connector),
|
connections: make(map[string]connector),
|
||||||
newConn: defaultNewConn(o),
|
newConn: defaultNewConn(o),
|
||||||
}
|
}
|
||||||
svc.watcherSetup = svc.defaultWatcherSetup
|
svc.watcherSetup = svc.defaultWatcherSetup
|
||||||
return svc, nil
|
return core.Result{Value: svc, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultNewConn creates real go-webview connections.
|
// 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) {
|
return func(debugURL, windowName string) (connector, error) {
|
||||||
// Enumerate targets, match by title/URL containing window name
|
// Enumerate targets, match by title/URL containing window name
|
||||||
targets, err := gowebview.ListTargets(debugURL)
|
targets, err := gowebview.ListTargets(debugURL)
|
||||||
|
|
@ -86,7 +89,7 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
||||||
}
|
}
|
||||||
var wsURL string
|
var wsURL string
|
||||||
for _, t := range targets {
|
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
|
wsURL = t.WebSocketDebuggerURL
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -105,13 +108,13 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
||||||
}
|
}
|
||||||
wv, err := gowebview.New(
|
wv, err := gowebview.New(
|
||||||
gowebview.WithDebugURL(debugURL),
|
gowebview.WithDebugURL(debugURL),
|
||||||
gowebview.WithTimeout(opts.Timeout),
|
gowebview.WithTimeout(options.Timeout),
|
||||||
gowebview.WithConsoleLimit(opts.ConsoleLimit),
|
gowebview.WithConsoleLimit(options.ConsoleLimit),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &realConnector{wv: wv}, nil
|
return &realConnector{wv: wv, debugURL: debugURL}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,26 +157,25 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(_ context.Context) error {
|
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.registerTaskActions()
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnShutdown closes all CDP connections.
|
// OnShutdown closes all CDP connections.
|
||||||
func (s *Service) OnShutdown(_ context.Context) error {
|
func (s *Service) OnShutdown(_ context.Context) core.Result {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for name, conn := range s.connections {
|
for name, conn := range s.connections {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
delete(s.connections, name)
|
delete(s.connections, name)
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents listens for window close events to clean up connections.
|
// HandleIPCEvents listens for window close events to clean up connections.
|
||||||
func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result {
|
||||||
switch m := msg.(type) {
|
switch m := msg.(type) {
|
||||||
case window.ActionWindowClosed:
|
case window.ActionWindowClosed:
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
@ -183,7 +185,7 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConn returns the connector for a window, creating it if needed.
|
// getConn returns the connector for a window, creating it if needed.
|
||||||
|
|
@ -201,7 +203,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
|
||||||
if conn, ok := s.connections[windowName]; ok {
|
if conn, ok := s.connections[windowName]; ok {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
conn, err := s.newConn(s.opts.DebugURL, windowName)
|
conn, err := s.newConn(s.options.DebugURL, windowName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -212,26 +214,26 @@ func (s *Service) getConn(windowName string) (connector, error) {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
switch q := q.(type) {
|
switch q := q.(type) {
|
||||||
case QueryURL:
|
case QueryURL:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
url, err := conn.GetURL()
|
url, err := conn.GetURL()
|
||||||
return url, true, err
|
return core.Result{}.New(url, err)
|
||||||
case QueryTitle:
|
case QueryTitle:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
title, err := conn.GetTitle()
|
title, err := conn.GetTitle()
|
||||||
return title, true, err
|
return core.Result{}.New(title, err)
|
||||||
case QueryConsole:
|
case QueryConsole:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
msgs := conn.GetConsole()
|
msgs := conn.GetConsole()
|
||||||
// Filter by level if specified
|
// Filter by level if specified
|
||||||
|
|
@ -248,144 +250,287 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
||||||
if q.Limit > 0 && len(msgs) > q.Limit {
|
if q.Limit > 0 && len(msgs) > q.Limit {
|
||||||
msgs = msgs[len(msgs)-q.Limit:]
|
msgs = msgs[len(msgs)-q.Limit:]
|
||||||
}
|
}
|
||||||
return msgs, true, nil
|
return core.Result{Value: msgs, OK: true}
|
||||||
case QuerySelector:
|
case QuerySelector:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
el, err := conn.QuerySelector(q.Selector)
|
el, err := conn.QuerySelector(q.Selector)
|
||||||
return el, true, err
|
return core.Result{}.New(el, err)
|
||||||
case QuerySelectorAll:
|
case QuerySelectorAll:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
els, err := conn.QuerySelectorAll(q.Selector)
|
els, err := conn.QuerySelectorAll(q.Selector)
|
||||||
return els, true, err
|
return core.Result{}.New(els, err)
|
||||||
case QueryDOMTree:
|
case QueryDOMTree:
|
||||||
conn, err := s.getConn(q.Window)
|
conn, err := s.getConn(q.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
selector := q.Selector
|
selector := q.Selector
|
||||||
if selector == "" {
|
if selector == "" {
|
||||||
selector = "html"
|
selector = "html"
|
||||||
}
|
}
|
||||||
html, err := conn.GetHTML(selector)
|
html, err := conn.GetHTML(selector)
|
||||||
return html, true, err
|
return core.Result{}.New(html, err)
|
||||||
|
case QueryZoom:
|
||||||
|
conn, err := s.getConn(q.Window)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
zoom, err := conn.GetZoom()
|
||||||
|
return core.Result{}.New(zoom, err)
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
|
// registerTaskActions registers all webview task handlers as named Core actions.
|
||||||
switch t := t.(type) {
|
func (s *Service) registerTaskActions() {
|
||||||
case TaskEvaluate:
|
c := s.Core()
|
||||||
|
c.Action("webview.evaluate", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskEvaluate)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
result, err := conn.Evaluate(t.Script)
|
result, err := conn.Evaluate(t.Script)
|
||||||
return result, true, err
|
return core.Result{}.New(result, err)
|
||||||
case TaskClick:
|
})
|
||||||
|
c.Action("webview.click", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskClick)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Click(t.Selector)
|
return core.Result{Value: nil, OK: true}.New(conn.Click(t.Selector))
|
||||||
case TaskType:
|
})
|
||||||
|
c.Action("webview.type", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskType)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Type(t.Selector, t.Text)
|
return core.Result{Value: nil, OK: true}.New(conn.Type(t.Selector, t.Text))
|
||||||
case TaskNavigate:
|
})
|
||||||
|
c.Action("webview.navigate", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskNavigate)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Navigate(t.URL)
|
return core.Result{Value: nil, OK: true}.New(conn.Navigate(t.URL))
|
||||||
case TaskScreenshot:
|
})
|
||||||
|
c.Action("webview.screenshot", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskScreenshot)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
png, err := conn.Screenshot()
|
png, err := conn.Screenshot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return ScreenshotResult{
|
return core.Result{Value: ScreenshotResult{
|
||||||
Base64: base64.StdEncoding.EncodeToString(png),
|
Base64: base64.StdEncoding.EncodeToString(png),
|
||||||
MimeType: "image/png",
|
MimeType: "image/png",
|
||||||
}, true, nil
|
}, OK: true}
|
||||||
case TaskScroll:
|
})
|
||||||
|
c.Action("webview.scroll", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskScroll)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
_, err = conn.Evaluate("window.scrollTo(" + strconv.Itoa(t.X) + "," + strconv.Itoa(t.Y) + ")")
|
_, err = conn.Evaluate("window.scrollTo(" + strconv.Itoa(t.X) + "," + strconv.Itoa(t.Y) + ")")
|
||||||
return nil, true, err
|
return core.Result{Value: nil, OK: true}.New(err)
|
||||||
case TaskHover:
|
})
|
||||||
|
c.Action("webview.hover", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskHover)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Hover(t.Selector)
|
return core.Result{Value: nil, OK: true}.New(conn.Hover(t.Selector))
|
||||||
case TaskSelect:
|
})
|
||||||
|
c.Action("webview.select", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSelect)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Select(t.Selector, t.Value)
|
return core.Result{Value: nil, OK: true}.New(conn.Select(t.Selector, t.Value))
|
||||||
case TaskCheck:
|
})
|
||||||
|
c.Action("webview.check", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskCheck)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.Check(t.Selector, t.Checked)
|
return core.Result{Value: nil, OK: true}.New(conn.Check(t.Selector, t.Checked))
|
||||||
case TaskUploadFile:
|
})
|
||||||
|
c.Action("webview.uploadFile", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskUploadFile)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.UploadFile(t.Selector, t.Paths)
|
return core.Result{Value: nil, OK: true}.New(conn.UploadFile(t.Selector, t.Paths))
|
||||||
case TaskSetViewport:
|
})
|
||||||
|
c.Action("webview.setViewport", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetViewport)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
return nil, true, conn.SetViewport(t.Width, t.Height)
|
return core.Result{Value: nil, OK: true}.New(conn.SetViewport(t.Width, t.Height))
|
||||||
case TaskClearConsole:
|
})
|
||||||
|
c.Action("webview.clearConsole", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskClearConsole)
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
conn.ClearConsole()
|
conn.ClearConsole()
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
default:
|
})
|
||||||
return nil, false, nil
|
c.Action("webview.setURL", func(_ context.Context, opts core.Options) core.Result {
|
||||||
}
|
t, _ := opts.Get("task").Value.(TaskSetURL)
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{Value: nil, OK: true}.New(conn.Navigate(t.URL))
|
||||||
|
})
|
||||||
|
c.Action("webview.setZoom", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetZoom)
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{Value: nil, OK: true}.New(conn.SetZoom(t.Zoom))
|
||||||
|
})
|
||||||
|
c.Action("webview.print", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskPrint)
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
pdfBytes, err := conn.Print(t.ToPDF)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
if !t.ToPDF {
|
||||||
|
return core.Result{OK: true}
|
||||||
|
}
|
||||||
|
return core.Result{Value: PrintResult{
|
||||||
|
Base64: base64.StdEncoding.EncodeToString(pdfBytes),
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
}, OK: true}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// realConnector wraps *gowebview.Webview, converting types at the boundary.
|
// realConnector wraps *gowebview.Webview, converting types at the boundary.
|
||||||
|
// debugURL is retained so that PDF printing can issue a Page.printToPDF CDP call
|
||||||
|
// via a fresh CDPClient, since go-webview v0.1.7 does not expose a PrintToPDF helper.
|
||||||
type realConnector struct {
|
type realConnector struct {
|
||||||
wv *gowebview.Webview
|
wv *gowebview.Webview
|
||||||
|
debugURL string // Chrome debug HTTP endpoint (e.g., http://localhost:9222) for direct CDP calls
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
|
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
|
||||||
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
|
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
|
||||||
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
|
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
|
||||||
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
|
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
|
||||||
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
|
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
|
||||||
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
|
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
|
||||||
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
|
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
|
||||||
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
|
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
|
||||||
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
|
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
|
||||||
func (r *realConnector) Close() error { return r.wv.Close() }
|
func (r *realConnector) Close() error { return r.wv.Close() }
|
||||||
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
|
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
|
||||||
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
|
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
|
||||||
|
|
||||||
|
// GetZoom returns the current CSS zoom level as a float64.
|
||||||
|
// zoom, _ := conn.GetZoom() // 1.0 = 100%, 1.5 = 150%
|
||||||
|
func (r *realConnector) GetZoom() (float64, error) {
|
||||||
|
raw, err := r.wv.Evaluate("parseFloat(document.documentElement.style.zoom) || 1.0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, core.E("realConnector.GetZoom", "failed to get zoom", err)
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case float64:
|
||||||
|
return v, nil
|
||||||
|
case int:
|
||||||
|
return float64(v), nil
|
||||||
|
default:
|
||||||
|
return 1.0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetZoom sets the CSS zoom level on the document root element.
|
||||||
|
// conn.SetZoom(1.5) // 150%
|
||||||
|
// conn.SetZoom(1.0) // reset to normal
|
||||||
|
func (r *realConnector) SetZoom(zoom float64) error {
|
||||||
|
script := "document.documentElement.style.zoom = '" + strconv.FormatFloat(zoom, 'g', -1, 64) + "'; undefined"
|
||||||
|
_, err := r.wv.Evaluate(script)
|
||||||
|
if err != nil {
|
||||||
|
return core.E("realConnector.SetZoom", "failed to set zoom", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print triggers window.print() or exports to PDF via Page.printToPDF.
|
||||||
|
// When toPDF is false the browser print dialog is opened (via window.print()) and nil bytes are returned.
|
||||||
|
// When toPDF is true a fresh CDPClient is opened against the stored WebSocket URL to issue
|
||||||
|
// Page.printToPDF, which returns raw PDF bytes.
|
||||||
|
func (r *realConnector) Print(toPDF bool) ([]byte, error) {
|
||||||
|
if !toPDF {
|
||||||
|
_, err := r.wv.Evaluate("window.print(); undefined")
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("realConnector.Print", "failed to open print dialog", err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.debugURL == "" {
|
||||||
|
return nil, core.E("realConnector.Print", "no debug URL stored; cannot issue Page.printToPDF", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a dedicated CDPClient for the single Page.printToPDF call.
|
||||||
|
// NewCDPClient connects to the first page target at the debug endpoint.
|
||||||
|
client, err := gowebview.NewCDPClient(r.debugURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("realConnector.Print", "failed to connect for PDF export", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := client.Call(ctx, "Page.printToPDF", map[string]any{
|
||||||
|
"printBackground": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("realConnector.Print", "Page.printToPDF failed", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStr, ok := result["data"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, core.E("realConnector.Print", "Page.printToPDF returned no data", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := base64.StdEncoding.DecodeString(dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("realConnector.Print", "failed to decode PDF data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *realConnector) Hover(sel string) error {
|
func (r *realConnector) Hover(sel string) error {
|
||||||
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)
|
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -35,6 +35,13 @@ type mockConnector struct {
|
||||||
lastViewportW int
|
lastViewportW int
|
||||||
lastViewportH int
|
lastViewportH int
|
||||||
consoleClearCalled bool
|
consoleClearCalled bool
|
||||||
|
|
||||||
|
zoom float64
|
||||||
|
lastZoomSet float64
|
||||||
|
printToPDF bool
|
||||||
|
printCalled bool
|
||||||
|
printPDFBytes []byte
|
||||||
|
printErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
|
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
|
||||||
|
|
@ -66,18 +73,42 @@ func (m *mockConnector) QuerySelectorAll(sel string) ([]*ElementInfo, error) {
|
||||||
|
|
||||||
func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
|
func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
|
||||||
|
|
||||||
|
func (m *mockConnector) GetZoom() (float64, error) {
|
||||||
|
if m.zoom == 0 {
|
||||||
|
return 1.0, nil
|
||||||
|
}
|
||||||
|
return m.zoom, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConnector) SetZoom(zoom float64) error {
|
||||||
|
m.lastZoomSet = zoom
|
||||||
|
m.zoom = zoom
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConnector) Print(toPDF bool) ([]byte, error) {
|
||||||
|
m.printCalled = true
|
||||||
|
m.printToPDF = toPDF
|
||||||
|
return m.printPDFBytes, m.printErr
|
||||||
|
}
|
||||||
|
|
||||||
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
factory := Register()
|
factory := Register()
|
||||||
c, err := core.New(core.WithService(factory), core.WithServiceLock())
|
c := core.New(core.WithService(factory), core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "webview")
|
svc := core.MustServiceFor[*Service](c, "webview")
|
||||||
// Inject mock connector
|
// Inject mock connector
|
||||||
svc.newConn = func(_, _ string) (connector, error) { return mock, nil }
|
svc.newConn = func(_, _ string) (connector, error) { return mock, nil }
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
svc, _ := newTestService(t, &mockConnector{})
|
svc, _ := newTestService(t, &mockConnector{})
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
|
|
@ -85,18 +116,16 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryURL_Good(t *testing.T) {
|
func TestQueryURL_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{url: "https://example.com"})
|
_, c := newTestService(t, &mockConnector{url: "https://example.com"})
|
||||||
result, handled, err := c.QUERY(QueryURL{Window: "main"})
|
r := c.QUERY(QueryURL{Window: "main"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, "https://example.com", r.Value)
|
||||||
assert.Equal(t, "https://example.com", result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryTitle_Good(t *testing.T) {
|
func TestQueryTitle_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{title: "Test Page"})
|
_, c := newTestService(t, &mockConnector{title: "Test Page"})
|
||||||
result, handled, err := c.QUERY(QueryTitle{Window: "main"})
|
r := c.QUERY(QueryTitle{Window: "main"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, "Test Page", r.Value)
|
||||||
assert.Equal(t, "Test Page", result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryConsole_Good(t *testing.T) {
|
func TestQueryConsole_Good(t *testing.T) {
|
||||||
|
|
@ -106,10 +135,9 @@ func TestQueryConsole_Good(t *testing.T) {
|
||||||
{Type: "log", Text: "world"},
|
{Type: "log", Text: "world"},
|
||||||
}}
|
}}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
result, handled, err := c.QUERY(QueryConsole{Window: "main", Level: "error", Limit: 10})
|
r := c.QUERY(QueryConsole{Window: "main", Level: "error", Limit: 10})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
msgs, _ := r.Value.([]ConsoleMessage)
|
||||||
msgs, _ := result.([]ConsoleMessage)
|
|
||||||
assert.Len(t, msgs, 1)
|
assert.Len(t, msgs, 1)
|
||||||
assert.Equal(t, "oops", msgs[0].Text)
|
assert.Equal(t, "oops", msgs[0].Text)
|
||||||
}
|
}
|
||||||
|
|
@ -121,45 +149,41 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
|
||||||
{Type: "log", Text: "c"},
|
{Type: "log", Text: "c"},
|
||||||
}}
|
}}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
result, _, _ := c.QUERY(QueryConsole{Window: "main", Limit: 2})
|
r := c.QUERY(QueryConsole{Window: "main", Limit: 2})
|
||||||
msgs, _ := result.([]ConsoleMessage)
|
msgs, _ := r.Value.([]ConsoleMessage)
|
||||||
assert.Len(t, msgs, 2)
|
assert.Len(t, msgs, 2)
|
||||||
assert.Equal(t, "b", msgs[0].Text) // last 2
|
assert.Equal(t, "b", msgs[0].Text) // last 2
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskEvaluate_Good(t *testing.T) {
|
func TestTaskEvaluate_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
||||||
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
r := taskRun(c, "webview.evaluate", TaskEvaluate{Window: "main", Script: "21*2"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
assert.Equal(t, 42, r.Value)
|
||||||
assert.Equal(t, 42, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskClick_Good(t *testing.T) {
|
func TestTaskClick_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
_, handled, err := c.PERFORM(TaskClick{Window: "main", Selector: "#btn"})
|
r := taskRun(c, "webview.click", TaskClick{Window: "main", Selector: "#btn"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.Equal(t, "#btn", mock.lastClickSel)
|
assert.Equal(t, "#btn", mock.lastClickSel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskNavigate_Good(t *testing.T) {
|
func TestTaskNavigate_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
_, handled, err := c.PERFORM(TaskNavigate{Window: "main", URL: "https://example.com"})
|
r := taskRun(c, "webview.navigate", TaskNavigate{Window: "main", URL: "https://example.com"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.Equal(t, "https://example.com", mock.lastNavURL)
|
assert.Equal(t, "https://example.com", mock.lastNavURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskScreenshot_Good(t *testing.T) {
|
func TestTaskScreenshot_Good(t *testing.T) {
|
||||||
mock := &mockConnector{screenshot: []byte{0x89, 0x50, 0x4E, 0x47}}
|
mock := &mockConnector{screenshot: []byte{0x89, 0x50, 0x4E, 0x47}}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
result, handled, err := c.PERFORM(TaskScreenshot{Window: "main"})
|
r := taskRun(c, "webview.screenshot", TaskScreenshot{Window: "main"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
sr, ok := r.Value.(ScreenshotResult)
|
||||||
sr, ok := result.(ScreenshotResult)
|
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "image/png", sr.MimeType)
|
assert.Equal(t, "image/png", sr.MimeType)
|
||||||
assert.NotEmpty(t, sr.Base64)
|
assert.NotEmpty(t, sr.Base64)
|
||||||
|
|
@ -168,9 +192,8 @@ func TestTaskScreenshot_Good(t *testing.T) {
|
||||||
func TestTaskClearConsole_Good(t *testing.T) {
|
func TestTaskClearConsole_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
_, handled, err := c.PERFORM(TaskClearConsole{Window: "main"})
|
r := taskRun(c, "webview.clearConsole", TaskClearConsole{Window: "main"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
assert.True(t, mock.consoleClearCalled)
|
assert.True(t, mock.consoleClearCalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +201,7 @@ func TestConnectionCleanup_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
// Access creates connection
|
// Access creates connection
|
||||||
_, _, _ = c.QUERY(QueryURL{Window: "main"})
|
c.QUERY(QueryURL{Window: "main"})
|
||||||
assert.False(t, mock.closed)
|
assert.False(t, mock.closed)
|
||||||
// Window close action triggers cleanup
|
// Window close action triggers cleanup
|
||||||
_ = c.ACTION(window.ActionWindowClosed{Name: "main"})
|
_ = c.ACTION(window.ActionWindowClosed{Name: "main"})
|
||||||
|
|
@ -186,7 +209,141 @@ func TestConnectionCleanup_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryURL_Bad_NoService(t *testing.T) {
|
func TestQueryURL_Bad_NoService(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
_, handled, _ := c.QUERY(QueryURL{Window: "main"})
|
r := c.QUERY(QueryURL{Window: "main"})
|
||||||
assert.False(t, handled)
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SetURL ---
|
||||||
|
|
||||||
|
func TestTaskSetURL_Good(t *testing.T) {
|
||||||
|
mock := &mockConnector{}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.setURL", TaskSetURL{Window: "main", URL: "https://example.com/page"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "https://example.com/page", mock.lastNavURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetURL_Bad_UnknownWindow(t *testing.T) {
|
||||||
|
_, c := newTestService(t, &mockConnector{})
|
||||||
|
// Inject a connector factory that errors
|
||||||
|
svc := core.MustServiceFor[*Service](c, "webview")
|
||||||
|
svc.newConn = func(_, _ string) (connector, error) {
|
||||||
|
return nil, core.E("test", "no connection", nil)
|
||||||
|
}
|
||||||
|
r := taskRun(c, "webview.setURL", TaskSetURL{Window: "bad", URL: "https://example.com"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetURL_Ugly_EmptyURL(t *testing.T) {
|
||||||
|
mock := &mockConnector{}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.setURL", TaskSetURL{Window: "main", URL: ""})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, "", mock.lastNavURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zoom ---
|
||||||
|
|
||||||
|
func TestQueryZoom_Good(t *testing.T) {
|
||||||
|
mock := &mockConnector{zoom: 1.5}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := c.QUERY(QueryZoom{Window: "main"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.InDelta(t, 1.5, r.Value.(float64), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryZoom_Good_DefaultsToOne(t *testing.T) {
|
||||||
|
mock := &mockConnector{} // zoom not set → GetZoom returns 1.0
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := c.QUERY(QueryZoom{Window: "main"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.InDelta(t, 1.0, r.Value.(float64), 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryZoom_Bad_NoService(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.QUERY(QueryZoom{Window: "main"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Good(t *testing.T) {
|
||||||
|
mock := &mockConnector{}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.setZoom", TaskSetZoom{Window: "main", Zoom: 2.0})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.InDelta(t, 2.0, mock.lastZoomSet, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Good_Reset(t *testing.T) {
|
||||||
|
mock := &mockConnector{zoom: 1.5}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.setZoom", TaskSetZoom{Window: "main", Zoom: 1.0})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.InDelta(t, 1.0, mock.zoom, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Bad_NoService(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("webview.setZoom").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Ugly_ZeroZoom(t *testing.T) {
|
||||||
|
mock := &mockConnector{}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
// Zero zoom is technically valid input; the connector accepts it.
|
||||||
|
r := taskRun(c, "webview.setZoom", TaskSetZoom{Window: "main", Zoom: 0})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.InDelta(t, 0.0, mock.lastZoomSet, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print ---
|
||||||
|
|
||||||
|
func TestTaskPrint_Good_Dialog(t *testing.T) {
|
||||||
|
mock := &mockConnector{}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.print", TaskPrint{Window: "main", ToPDF: false})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Nil(t, r.Value)
|
||||||
|
assert.True(t, mock.printCalled)
|
||||||
|
assert.False(t, mock.printToPDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskPrint_Good_PDF(t *testing.T) {
|
||||||
|
pdfHeader := []byte{0x25, 0x50, 0x44, 0x46} // %PDF
|
||||||
|
mock := &mockConnector{printPDFBytes: pdfHeader}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.print", TaskPrint{Window: "main", ToPDF: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
pr, ok := r.Value.(PrintResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "application/pdf", pr.MimeType)
|
||||||
|
assert.NotEmpty(t, pr.Base64)
|
||||||
|
assert.True(t, mock.printToPDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskPrint_Bad_NoService(t *testing.T) {
|
||||||
|
c := core.New(core.WithServiceLock())
|
||||||
|
r := c.Action("webview.print").Run(context.Background(), core.NewOptions())
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskPrint_Bad_Error(t *testing.T) {
|
||||||
|
mock := &mockConnector{printErr: core.E("test", "print failed", nil)}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.print", TaskPrint{Window: "main", ToPDF: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskPrint_Ugly_EmptyPDF(t *testing.T) {
|
||||||
|
// toPDF=true but connector returns zero bytes — should still wrap as PrintResult
|
||||||
|
mock := &mockConnector{printPDFBytes: []byte{}}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
r := taskRun(c, "webview.print", TaskPrint{Window: "main", ToPDF: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
pr, ok := r.Value.(PrintResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "application/pdf", pr.MimeType)
|
||||||
|
assert.Equal(t, "", pr.Base64) // empty PDF encodes to empty base64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Layout is a named window arrangement.
|
// Layout is a named window arrangement.
|
||||||
|
|
@ -38,9 +38,8 @@ func NewLayoutManager() *LayoutManager {
|
||||||
lm := &LayoutManager{
|
lm := &LayoutManager{
|
||||||
layouts: make(map[string]Layout),
|
layouts: make(map[string]Layout),
|
||||||
}
|
}
|
||||||
configDir, err := os.UserConfigDir()
|
if configDir := core.Env("DIR_CONFIG"); configDir != "" {
|
||||||
if err == nil {
|
lm.configDir = core.JoinPath(configDir, "Core")
|
||||||
lm.configDir = filepath.Join(configDir, "Core")
|
|
||||||
}
|
}
|
||||||
lm.load()
|
lm.load()
|
||||||
return lm
|
return lm
|
||||||
|
|
@ -58,20 +57,20 @@ func NewLayoutManagerWithDir(configDir string) *LayoutManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) filePath() string {
|
func (lm *LayoutManager) filePath() string {
|
||||||
return filepath.Join(lm.configDir, "layouts.json")
|
return core.JoinPath(lm.configDir, "layouts.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) load() {
|
func (lm *LayoutManager) load() {
|
||||||
if lm.configDir == "" {
|
if lm.configDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(lm.filePath())
|
content, err := coreio.Local.Read(lm.filePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lm.mu.Lock()
|
lm.mu.Lock()
|
||||||
defer lm.mu.Unlock()
|
defer lm.mu.Unlock()
|
||||||
_ = json.Unmarshal(data, &lm.layouts)
|
_ = core.JSONUnmarshalString(content, &lm.layouts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) save() {
|
func (lm *LayoutManager) save() {
|
||||||
|
|
@ -79,19 +78,20 @@ func (lm *LayoutManager) save() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lm.mu.RLock()
|
lm.mu.RLock()
|
||||||
data, err := json.MarshalIndent(lm.layouts, "", " ")
|
result := core.JSONMarshal(lm.layouts)
|
||||||
lm.mu.RUnlock()
|
lm.mu.RUnlock()
|
||||||
if err != nil {
|
if !result.OK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.MkdirAll(lm.configDir, 0o755)
|
data := result.Value.([]byte)
|
||||||
_ = os.WriteFile(lm.filePath(), data, 0o644)
|
_ = coreio.Local.EnsureDir(lm.configDir)
|
||||||
|
_ = coreio.Local.Write(lm.filePath(), string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveLayout creates or updates a named layout.
|
// SaveLayout creates or updates a named layout.
|
||||||
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
||||||
if name == "" {
|
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()
|
now := time.Now().UnixMilli()
|
||||||
lm.mu.Lock()
|
lm.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package window
|
package window
|
||||||
|
|
||||||
// WindowInfo contains information about a window.
|
|
||||||
type WindowInfo struct {
|
type WindowInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -12,103 +11,97 @@ type WindowInfo struct {
|
||||||
Focused bool `json:"focused"`
|
Focused bool `json:"focused"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Queries (read-only) ---
|
|
||||||
|
|
||||||
// QueryWindowList returns all tracked windows. Result: []WindowInfo
|
|
||||||
type QueryWindowList struct{}
|
type QueryWindowList struct{}
|
||||||
|
|
||||||
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
|
|
||||||
type QueryWindowByName struct{ Name string }
|
type QueryWindowByName struct{ Name string }
|
||||||
|
|
||||||
// QueryConfig requests this service's config section from the display orchestrator.
|
|
||||||
// Result: map[string]any
|
|
||||||
type QueryConfig struct{}
|
type QueryConfig struct{}
|
||||||
|
|
||||||
// --- Tasks (side-effects) ---
|
type TaskOpenWindow struct {
|
||||||
|
Window *Window
|
||||||
|
Options []WindowOption
|
||||||
|
}
|
||||||
|
|
||||||
// TaskOpenWindow creates a new window. Result: WindowInfo
|
|
||||||
type TaskOpenWindow struct{ Opts []WindowOption }
|
|
||||||
|
|
||||||
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
|
|
||||||
type TaskCloseWindow struct{ Name string }
|
type TaskCloseWindow struct{ Name string }
|
||||||
|
|
||||||
// TaskSetPosition moves a window.
|
|
||||||
type TaskSetPosition struct {
|
type TaskSetPosition struct {
|
||||||
Name string
|
Name string
|
||||||
X, Y int
|
X, Y int
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSetSize resizes a window.
|
|
||||||
type TaskSetSize struct {
|
type TaskSetSize struct {
|
||||||
Name string
|
Name string
|
||||||
W, H int
|
Width, Height int
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskMaximise maximises a window.
|
|
||||||
type TaskMaximise struct{ Name string }
|
type TaskMaximise struct{ Name string }
|
||||||
|
|
||||||
// TaskMinimise minimises a window.
|
|
||||||
type TaskMinimise struct{ Name string }
|
type TaskMinimise struct{ Name string }
|
||||||
|
|
||||||
// TaskFocus brings a window to the front.
|
|
||||||
type TaskFocus struct{ Name string }
|
type TaskFocus struct{ Name string }
|
||||||
|
|
||||||
// TaskRestore restores a maximised or minimised window to its normal state.
|
|
||||||
type TaskRestore struct{ Name string }
|
type TaskRestore struct{ Name string }
|
||||||
|
|
||||||
// TaskSetTitle changes a window's title.
|
|
||||||
type TaskSetTitle struct {
|
type TaskSetTitle struct {
|
||||||
Name string
|
Name string
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSetVisibility shows or hides a window.
|
type TaskSetAlwaysOnTop struct {
|
||||||
|
Name string
|
||||||
|
AlwaysOnTop bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskSetBackgroundColour struct {
|
||||||
|
Name string
|
||||||
|
Red uint8
|
||||||
|
Green uint8
|
||||||
|
Blue uint8
|
||||||
|
Alpha uint8
|
||||||
|
}
|
||||||
|
|
||||||
type TaskSetVisibility struct {
|
type TaskSetVisibility struct {
|
||||||
Name string
|
Name string
|
||||||
Visible bool
|
Visible bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskFullscreen enters or exits fullscreen mode.
|
|
||||||
type TaskFullscreen struct {
|
type TaskFullscreen struct {
|
||||||
Name string
|
Name string
|
||||||
Fullscreen bool
|
Fullscreen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Layout Queries ---
|
|
||||||
|
|
||||||
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
|
|
||||||
type QueryLayoutList struct{}
|
type QueryLayoutList struct{}
|
||||||
|
|
||||||
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
|
|
||||||
type QueryLayoutGet struct{ Name string }
|
type QueryLayoutGet struct{ Name string }
|
||||||
|
|
||||||
// --- Layout Tasks ---
|
|
||||||
|
|
||||||
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
|
|
||||||
type TaskSaveLayout struct{ Name string }
|
type TaskSaveLayout struct{ Name string }
|
||||||
|
|
||||||
// TaskRestoreLayout restores a saved layout by name.
|
|
||||||
type TaskRestoreLayout struct{ Name string }
|
type TaskRestoreLayout struct{ Name string }
|
||||||
|
|
||||||
// TaskDeleteLayout removes a saved layout by name.
|
|
||||||
type TaskDeleteLayout struct{ Name string }
|
type TaskDeleteLayout struct{ Name string }
|
||||||
|
|
||||||
// TaskTileWindows arranges windows in a tiling mode.
|
|
||||||
type TaskTileWindows struct {
|
type TaskTileWindows struct {
|
||||||
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
||||||
Windows []string // window names; empty = all
|
Windows []string // window names; empty = all
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSnapWindow snaps a window to a screen edge/corner.
|
type TaskStackWindows struct {
|
||||||
|
Windows []string // window names; empty = all
|
||||||
|
OffsetX int
|
||||||
|
OffsetY int
|
||||||
|
}
|
||||||
|
|
||||||
type TaskSnapWindow struct {
|
type TaskSnapWindow struct {
|
||||||
Name string // window name
|
Name string // window name
|
||||||
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
type TaskApplyWorkflow struct {
|
||||||
type TaskSaveConfig struct{ Value map[string]any }
|
Workflow string
|
||||||
|
Windows []string // window names; empty = all
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actions (broadcasts) ---
|
type TaskSaveConfig struct{ Config map[string]any }
|
||||||
|
|
||||||
type ActionWindowOpened struct{ Name string }
|
type ActionWindowOpened struct{ Name string }
|
||||||
type ActionWindowClosed struct{ Name string }
|
type ActionWindowClosed struct{ Name string }
|
||||||
|
|
@ -119,15 +112,84 @@ type ActionWindowMoved struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionWindowResized struct {
|
type ActionWindowResized struct {
|
||||||
Name string
|
Name string
|
||||||
W, H int
|
Width, Height int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionWindowFocused struct{ Name string }
|
type ActionWindowFocused struct{ Name string }
|
||||||
type ActionWindowBlurred struct{ Name string }
|
type ActionWindowBlurred struct{ Name string }
|
||||||
|
|
||||||
type ActionFilesDropped struct {
|
type ActionFilesDropped struct {
|
||||||
Name string `json:"name"` // window name
|
Name string `json:"name"` // window name
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
TargetID string `json:"targetId,omitempty"`
|
TargetID string `json:"targetId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Zoom ---
|
||||||
|
|
||||||
|
type QueryWindowZoom struct{ Name string }
|
||||||
|
|
||||||
|
type TaskSetZoom struct {
|
||||||
|
Name string
|
||||||
|
Magnification float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskZoomIn struct{ Name string }
|
||||||
|
|
||||||
|
type TaskZoomOut struct{ Name string }
|
||||||
|
|
||||||
|
type TaskZoomReset struct{ Name string }
|
||||||
|
|
||||||
|
// --- Content ---
|
||||||
|
|
||||||
|
type TaskSetURL struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskSetHTML struct {
|
||||||
|
Name string
|
||||||
|
HTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskExecJS struct {
|
||||||
|
Name string
|
||||||
|
JS string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State toggles ---
|
||||||
|
|
||||||
|
type TaskToggleFullscreen struct{ Name string }
|
||||||
|
|
||||||
|
type TaskToggleMaximise struct{ Name string }
|
||||||
|
|
||||||
|
// --- Bounds ---
|
||||||
|
|
||||||
|
type QueryWindowBounds struct{ Name string }
|
||||||
|
|
||||||
|
type WindowBounds struct {
|
||||||
|
X, Y, Width, Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskSetBounds struct {
|
||||||
|
Name string
|
||||||
|
X, Y, Width, Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content protection ---
|
||||||
|
|
||||||
|
type TaskSetContentProtection struct {
|
||||||
|
Name string
|
||||||
|
Protection bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flash ---
|
||||||
|
|
||||||
|
type TaskFlash struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print ---
|
||||||
|
|
||||||
|
type TaskPrint struct{ Name string }
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ func NewMockPlatform() *MockPlatform {
|
||||||
return &MockPlatform{}
|
return &MockPlatform{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||||
w := &MockWindow{
|
w := &MockWindow{
|
||||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
name: options.Name, title: options.Title, url: options.URL,
|
||||||
width: opts.Width, height: opts.Height,
|
width: options.Width, height: options.Height,
|
||||||
x: opts.X, y: opts.Y,
|
x: options.X, y: options.Y,
|
||||||
}
|
}
|
||||||
m.Windows = append(m.Windows, w)
|
m.Windows = append(m.Windows, w)
|
||||||
return w
|
return w
|
||||||
|
|
@ -29,37 +29,72 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockWindow struct {
|
type MockWindow struct {
|
||||||
name, title, url string
|
name, title, url string
|
||||||
width, height, x, y int
|
width, height, x, y int
|
||||||
maximised, focused bool
|
maximised, focused bool
|
||||||
visible, alwaysOnTop bool
|
visible, alwaysOnTop bool
|
||||||
closed bool
|
backgroundColour [4]uint8
|
||||||
eventHandlers []func(WindowEvent)
|
closed bool
|
||||||
fileDropHandlers []func(paths []string, targetID string)
|
minimised bool
|
||||||
|
fullscreened bool
|
||||||
|
zoom float64
|
||||||
|
html string
|
||||||
|
contentProtection bool
|
||||||
|
flashed bool
|
||||||
|
execJSCalls []string
|
||||||
|
eventHandlers []func(WindowEvent)
|
||||||
|
fileDropHandlers []func(paths []string, targetID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *MockWindow) Name() string { return w.name }
|
func (w *MockWindow) Name() string { return w.name }
|
||||||
func (w *MockWindow) Title() string { return w.title }
|
func (w *MockWindow) Title() string { return w.title }
|
||||||
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
|
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) Size() (int, int) { return w.width, w.height }
|
||||||
func (w *MockWindow) IsMaximised() bool { return w.maximised }
|
func (w *MockWindow) IsMaximised() bool { return w.maximised }
|
||||||
func (w *MockWindow) IsFocused() bool { return w.focused }
|
func (w *MockWindow) IsFocused() bool { return w.focused }
|
||||||
func (w *MockWindow) SetTitle(title string) { w.title = title }
|
func (w *MockWindow) IsVisible() bool { return w.visible }
|
||||||
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
func (w *MockWindow) IsFullscreen() bool { return w.fullscreened }
|
||||||
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
func (w *MockWindow) IsMinimised() bool { return w.minimised }
|
||||||
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
|
func (w *MockWindow) GetBounds() (int, int, int, int) { return w.x, w.y, w.width, w.height }
|
||||||
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
|
func (w *MockWindow) GetZoom() float64 {
|
||||||
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
if w.zoom == 0 {
|
||||||
func (w *MockWindow) Maximise() { w.maximised = true }
|
return 1.0
|
||||||
func (w *MockWindow) Restore() { w.maximised = false }
|
}
|
||||||
func (w *MockWindow) Minimise() {}
|
return w.zoom
|
||||||
func (w *MockWindow) Focus() { w.focused = true }
|
}
|
||||||
func (w *MockWindow) Close() { w.closed = true }
|
func (w *MockWindow) SetTitle(title string) { w.title = title }
|
||||||
func (w *MockWindow) Show() { w.visible = true }
|
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
||||||
func (w *MockWindow) Hide() { w.visible = false }
|
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
||||||
func (w *MockWindow) Fullscreen() {}
|
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
|
||||||
func (w *MockWindow) UnFullscreen() {}
|
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||||
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
|
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||||
|
func (w *MockWindow) SetBounds(x, y, width, height int) {
|
||||||
|
w.x = x
|
||||||
|
w.y = y
|
||||||
|
w.width = width
|
||||||
|
w.height = height
|
||||||
|
}
|
||||||
|
func (w *MockWindow) SetURL(url string) { w.url = url }
|
||||||
|
func (w *MockWindow) SetHTML(html string) { w.html = html }
|
||||||
|
func (w *MockWindow) SetZoom(magnification float64) { w.zoom = magnification }
|
||||||
|
func (w *MockWindow) SetContentProtection(protection bool) { w.contentProtection = protection }
|
||||||
|
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 }
|
||||||
|
func (w *MockWindow) Show() { w.visible = true }
|
||||||
|
func (w *MockWindow) Hide() { w.visible = false }
|
||||||
|
func (w *MockWindow) Fullscreen() { w.fullscreened = true }
|
||||||
|
func (w *MockWindow) UnFullscreen() { w.fullscreened = false }
|
||||||
|
func (w *MockWindow) ToggleFullscreen() { w.fullscreened = !w.fullscreened }
|
||||||
|
func (w *MockWindow) ToggleMaximise() { w.maximised = !w.maximised }
|
||||||
|
func (w *MockWindow) ExecJS(js string) { w.execJSCalls = append(w.execJSCalls, js) }
|
||||||
|
func (w *MockWindow) Flash(enabled bool) { w.flashed = enabled }
|
||||||
|
func (w *MockWindow) Print() error { return nil }
|
||||||
|
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||||
|
w.eventHandlers = append(w.eventHandlers, handler)
|
||||||
|
}
|
||||||
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
||||||
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// pkg/window/mock_test.go
|
|
||||||
package window
|
package window
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
|
|
@ -9,11 +8,11 @@ func newMockPlatform() *mockPlatform {
|
||||||
return &mockPlatform{}
|
return &mockPlatform{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||||
w := &mockWindow{
|
w := &mockWindow{
|
||||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
name: options.Name, title: options.Title, url: options.URL,
|
||||||
width: opts.Width, height: opts.Height,
|
width: options.Width, height: options.Height,
|
||||||
x: opts.X, y: opts.Y,
|
x: options.X, y: options.Y,
|
||||||
}
|
}
|
||||||
m.windows = append(m.windows, w)
|
m.windows = append(m.windows, w)
|
||||||
return w
|
return w
|
||||||
|
|
@ -28,37 +27,72 @@ func (m *mockPlatform) GetWindows() []PlatformWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockWindow struct {
|
type mockWindow struct {
|
||||||
name, title, url string
|
name, title, url string
|
||||||
width, height, x, y int
|
width, height, x, y int
|
||||||
maximised, focused bool
|
maximised, focused bool
|
||||||
visible, alwaysOnTop bool
|
visible, alwaysOnTop bool
|
||||||
closed bool
|
backgroundColour [4]uint8
|
||||||
eventHandlers []func(WindowEvent)
|
closed bool
|
||||||
fileDropHandlers []func(paths []string, targetID string)
|
minimised bool
|
||||||
|
fullscreened bool
|
||||||
|
zoom float64
|
||||||
|
html string
|
||||||
|
contentProtection bool
|
||||||
|
flashed bool
|
||||||
|
execJSCalls []string
|
||||||
|
eventHandlers []func(WindowEvent)
|
||||||
|
fileDropHandlers []func(paths []string, targetID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *mockWindow) Name() string { return w.name }
|
func (w *mockWindow) Name() string { return w.name }
|
||||||
func (w *mockWindow) Title() string { return w.title }
|
func (w *mockWindow) Title() string { return w.title }
|
||||||
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
|
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) Size() (int, int) { return w.width, w.height }
|
||||||
func (w *mockWindow) IsMaximised() bool { return w.maximised }
|
func (w *mockWindow) IsMaximised() bool { return w.maximised }
|
||||||
func (w *mockWindow) IsFocused() bool { return w.focused }
|
func (w *mockWindow) IsFocused() bool { return w.focused }
|
||||||
func (w *mockWindow) SetTitle(title string) { w.title = title }
|
func (w *mockWindow) IsVisible() bool { return w.visible }
|
||||||
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
func (w *mockWindow) IsFullscreen() bool { return w.fullscreened }
|
||||||
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
func (w *mockWindow) IsMinimised() bool { return w.minimised }
|
||||||
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
|
func (w *mockWindow) GetBounds() (int, int, int, int) { return w.x, w.y, w.width, w.height }
|
||||||
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
|
func (w *mockWindow) GetZoom() float64 {
|
||||||
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
if w.zoom == 0 {
|
||||||
func (w *mockWindow) Maximise() { w.maximised = true }
|
return 1.0
|
||||||
func (w *mockWindow) Restore() { w.maximised = false }
|
}
|
||||||
func (w *mockWindow) Minimise() {}
|
return w.zoom
|
||||||
func (w *mockWindow) Focus() { w.focused = true }
|
}
|
||||||
func (w *mockWindow) Close() { w.closed = true }
|
func (w *mockWindow) SetTitle(title string) { w.title = title }
|
||||||
func (w *mockWindow) Show() { w.visible = true }
|
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
||||||
func (w *mockWindow) Hide() { w.visible = false }
|
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
||||||
func (w *mockWindow) Fullscreen() {}
|
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
|
||||||
func (w *mockWindow) UnFullscreen() {}
|
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||||
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
|
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||||
|
func (w *mockWindow) SetBounds(x, y, width, height int) {
|
||||||
|
w.x = x
|
||||||
|
w.y = y
|
||||||
|
w.width = width
|
||||||
|
w.height = height
|
||||||
|
}
|
||||||
|
func (w *mockWindow) SetURL(url string) { w.url = url }
|
||||||
|
func (w *mockWindow) SetHTML(html string) { w.html = html }
|
||||||
|
func (w *mockWindow) SetZoom(magnification float64) { w.zoom = magnification }
|
||||||
|
func (w *mockWindow) SetContentProtection(protection bool) { w.contentProtection = protection }
|
||||||
|
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 }
|
||||||
|
func (w *mockWindow) Show() { w.visible = true }
|
||||||
|
func (w *mockWindow) Hide() { w.visible = false }
|
||||||
|
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
|
||||||
|
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
|
||||||
|
func (w *mockWindow) ToggleFullscreen() { w.fullscreened = !w.fullscreened }
|
||||||
|
func (w *mockWindow) ToggleMaximise() { w.maximised = !w.maximised }
|
||||||
|
func (w *mockWindow) ExecJS(js string) { w.execJSCalls = append(w.execJSCalls, js) }
|
||||||
|
func (w *mockWindow) Flash(enabled bool) { w.flashed = enabled }
|
||||||
|
func (w *mockWindow) Print() error { return nil }
|
||||||
|
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||||
|
w.eventHandlers = append(w.eventHandlers, handler)
|
||||||
|
}
|
||||||
func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
||||||
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ package window
|
||||||
type WindowOption func(*Window) error
|
type WindowOption func(*Window) error
|
||||||
|
|
||||||
// ApplyOptions creates a Window and applies all options in order.
|
// ApplyOptions creates a Window and applies all options in order.
|
||||||
func ApplyOptions(opts ...WindowOption) (*Window, error) {
|
func ApplyOptions(options ...WindowOption) (*Window, error) {
|
||||||
w := &Window{}
|
w := &Window{}
|
||||||
for _, opt := range opts {
|
for _, option := range options {
|
||||||
if opt == nil {
|
if option == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := opt(w); err != nil {
|
if err := option(w); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
pkg/window/persistence_test.go
Normal file
334
pkg/window/persistence_test.go
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
// pkg/window/persistence_test.go
|
||||||
|
package window
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
"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 := core.JoinPath(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)
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,25 @@ package window
|
||||||
|
|
||||||
// Platform abstracts the windowing backend (Wails v3).
|
// Platform abstracts the windowing backend (Wails v3).
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
CreateWindow(opts PlatformWindowOptions) PlatformWindow
|
CreateWindow(options PlatformWindowOptions) PlatformWindow
|
||||||
GetWindows() []PlatformWindow
|
GetWindows() []PlatformWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
|
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
|
||||||
type PlatformWindowOptions struct {
|
type PlatformWindowOptions struct {
|
||||||
Name string
|
Name string
|
||||||
Title string
|
Title string
|
||||||
URL string
|
URL string
|
||||||
Width, Height int
|
Width, Height int
|
||||||
X, Y int
|
X, Y int
|
||||||
MinWidth, MinHeight int
|
MinWidth, MinHeight int
|
||||||
MaxWidth, MaxHeight int
|
MaxWidth, MaxHeight int
|
||||||
Frameless bool
|
Frameless bool
|
||||||
Hidden bool
|
Hidden bool
|
||||||
AlwaysOnTop bool
|
AlwaysOnTop bool
|
||||||
BackgroundColour [4]uint8 // RGBA
|
BackgroundColour [4]uint8 // RGBA
|
||||||
DisableResize bool
|
DisableResize bool
|
||||||
EnableFileDrop bool
|
EnableFileDrop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformWindow is a live window handle from the backend.
|
// PlatformWindow is a live window handle from the backend.
|
||||||
|
|
@ -35,6 +35,11 @@ type PlatformWindow interface {
|
||||||
Size() (int, int)
|
Size() (int, int)
|
||||||
IsMaximised() bool
|
IsMaximised() bool
|
||||||
IsFocused() bool
|
IsFocused() bool
|
||||||
|
IsVisible() bool
|
||||||
|
IsFullscreen() bool
|
||||||
|
IsMinimised() bool
|
||||||
|
GetBounds() (x, y, width, height int)
|
||||||
|
GetZoom() float64
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
SetTitle(title string)
|
SetTitle(title string)
|
||||||
|
|
@ -43,6 +48,11 @@ type PlatformWindow interface {
|
||||||
SetBackgroundColour(r, g, b, a uint8)
|
SetBackgroundColour(r, g, b, a uint8)
|
||||||
SetVisibility(visible bool)
|
SetVisibility(visible bool)
|
||||||
SetAlwaysOnTop(alwaysOnTop bool)
|
SetAlwaysOnTop(alwaysOnTop bool)
|
||||||
|
SetBounds(x, y, width, height int)
|
||||||
|
SetURL(url string)
|
||||||
|
SetHTML(html string)
|
||||||
|
SetZoom(magnification float64)
|
||||||
|
SetContentProtection(protection bool)
|
||||||
|
|
||||||
// Window state
|
// Window state
|
||||||
Maximise()
|
Maximise()
|
||||||
|
|
@ -54,6 +64,15 @@ type PlatformWindow interface {
|
||||||
Hide()
|
Hide()
|
||||||
Fullscreen()
|
Fullscreen()
|
||||||
UnFullscreen()
|
UnFullscreen()
|
||||||
|
ToggleFullscreen()
|
||||||
|
ToggleMaximise()
|
||||||
|
|
||||||
|
// WebView
|
||||||
|
ExecJS(js string)
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
Flash(enabled bool)
|
||||||
|
Print() error
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
OnWindowEvent(handler func(event WindowEvent))
|
OnWindowEvent(handler func(event WindowEvent))
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register(p) binds the window service to a Core instance.
|
||||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
// core.WithService(window.Register(window.NewWailsPlatform(app)))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) core.Result {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) core.Result {
|
||||||
return &Service{
|
return core.Result{Value: &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
manager: NewManager(p),
|
manager: NewManager(p),
|
||||||
}, nil
|
}, OK: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,82 +2,77 @@ package window
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the window service.
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing window lifecycle via IPC.
|
|
||||||
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup queries config from the display orchestrator and registers IPC handlers.
|
func (s *Service) OnStartup(_ context.Context) core.Result {
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
|
||||||
// Query config — display registers its handler before us (registration order guarantee).
|
// Query config — display registers its handler before us (registration order guarantee).
|
||||||
// If display is not registered, handled=false and we skip config.
|
// If display is not registered, OK=false and we skip config.
|
||||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
r := s.Core().QUERY(QueryConfig{})
|
||||||
if handled {
|
if r.OK {
|
||||||
if wCfg, ok := cfg.(map[string]any); ok {
|
if windowConfig, ok := r.Value.(map[string]any); ok {
|
||||||
s.applyConfig(wCfg)
|
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().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.registerTaskActions()
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(cfg map[string]any) {
|
func (s *Service) applyConfig(configData map[string]any) {
|
||||||
if w, ok := cfg["default_width"]; ok {
|
if width, ok := configData["default_width"]; ok {
|
||||||
if _, ok := w.(int); ok {
|
if width, ok := width.(int); ok {
|
||||||
// TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended
|
s.manager.SetDefaultWidth(width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if h, ok := cfg["default_height"]; ok {
|
if height, ok := configData["default_height"]; ok {
|
||||||
if _, ok := h.(int); ok {
|
if height, ok := height.(int); ok {
|
||||||
// TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended
|
s.manager.SetDefaultHeight(height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sf, ok := cfg["state_file"]; ok {
|
if stateFile, ok := configData["state_file"]; ok {
|
||||||
if _, ok := sf.(string); ok {
|
if stateFile, ok := stateFile.(string); ok {
|
||||||
// TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended
|
s.manager.State().SetPath(stateFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result {
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
return core.Result{OK: true}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Query Handlers ---
|
func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result {
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|
||||||
switch q := q.(type) {
|
switch q := q.(type) {
|
||||||
case QueryWindowList:
|
case QueryWindowList:
|
||||||
return s.queryWindowList(), true, nil
|
return core.Result{Value: s.queryWindowList(), OK: true}
|
||||||
case QueryWindowByName:
|
case QueryWindowByName:
|
||||||
return s.queryWindowByName(q.Name), true, nil
|
return core.Result{Value: s.queryWindowByName(q.Name), OK: true}
|
||||||
case QueryLayoutList:
|
case QueryLayoutList:
|
||||||
return s.manager.Layout().ListLayouts(), true, nil
|
return core.Result{Value: s.manager.Layout().ListLayouts(), OK: true}
|
||||||
case QueryLayoutGet:
|
case QueryLayoutGet:
|
||||||
l, ok := s.manager.Layout().GetLayout(q.Name)
|
l, ok := s.manager.Layout().GetLayout(q.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return (*Layout)(nil), true, nil
|
return core.Result{Value: (*Layout)(nil), OK: true}
|
||||||
}
|
}
|
||||||
return &l, true, nil
|
return core.Result{Value: &l, OK: true}
|
||||||
|
case QueryWindowZoom:
|
||||||
|
return s.queryWindowZoom(q.Name)
|
||||||
|
case QueryWindowBounds:
|
||||||
|
return s.queryWindowBounds(q.Name)
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return core.Result{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,52 +107,191 @@ func (s *Service) queryWindowByName(name string) *WindowInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Task Handlers ---
|
// --- Action Registration ---
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
// registerTaskActions registers all window task handlers as named Core actions.
|
||||||
switch t := t.(type) {
|
func (s *Service) registerTaskActions() {
|
||||||
case TaskOpenWindow:
|
c := s.Core()
|
||||||
|
c.Action("window.open", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskOpenWindow)
|
||||||
return s.taskOpenWindow(t)
|
return s.taskOpenWindow(t)
|
||||||
case TaskCloseWindow:
|
})
|
||||||
return nil, true, s.taskCloseWindow(t.Name)
|
c.Action("window.close", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskSetPosition:
|
t, _ := opts.Get("task").Value.(TaskCloseWindow)
|
||||||
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
|
return core.Result{Value: nil, OK: true}.New(s.taskCloseWindow(t.Name))
|
||||||
case TaskSetSize:
|
})
|
||||||
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
|
c.Action("window.setPosition", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskMaximise:
|
t, _ := opts.Get("task").Value.(TaskSetPosition)
|
||||||
return nil, true, s.taskMaximise(t.Name)
|
return core.Result{Value: nil, OK: true}.New(s.taskSetPosition(t.Name, t.X, t.Y))
|
||||||
case TaskMinimise:
|
})
|
||||||
return nil, true, s.taskMinimise(t.Name)
|
c.Action("window.setSize", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskFocus:
|
t, _ := opts.Get("task").Value.(TaskSetSize)
|
||||||
return nil, true, s.taskFocus(t.Name)
|
return core.Result{Value: nil, OK: true}.New(s.taskSetSize(t.Name, t.Width, t.Height))
|
||||||
case TaskRestore:
|
})
|
||||||
return nil, true, s.taskRestore(t.Name)
|
c.Action("window.maximise", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskSetTitle:
|
t, _ := opts.Get("task").Value.(TaskMaximise)
|
||||||
return nil, true, s.taskSetTitle(t.Name, t.Title)
|
return core.Result{Value: nil, OK: true}.New(s.taskMaximise(t.Name))
|
||||||
case TaskSetVisibility:
|
})
|
||||||
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
|
c.Action("window.minimise", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskFullscreen:
|
t, _ := opts.Get("task").Value.(TaskMinimise)
|
||||||
return nil, true, s.taskFullscreen(t.Name, t.Fullscreen)
|
return core.Result{Value: nil, OK: true}.New(s.taskMinimise(t.Name))
|
||||||
case TaskSaveLayout:
|
})
|
||||||
return nil, true, s.taskSaveLayout(t.Name)
|
c.Action("window.focus", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskRestoreLayout:
|
t, _ := opts.Get("task").Value.(TaskFocus)
|
||||||
return nil, true, s.taskRestoreLayout(t.Name)
|
return core.Result{Value: nil, OK: true}.New(s.taskFocus(t.Name))
|
||||||
case TaskDeleteLayout:
|
})
|
||||||
|
c.Action("window.restore", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskRestore)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskRestore(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.setTitle", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetTitle)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetTitle(t.Name, t.Title))
|
||||||
|
})
|
||||||
|
c.Action("window.setAlwaysOnTop", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetAlwaysOnTop)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop))
|
||||||
|
})
|
||||||
|
c.Action("window.setBackgroundColour", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetBackgroundColour)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha))
|
||||||
|
})
|
||||||
|
c.Action("window.setVisibility", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetVisibility)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetVisibility(t.Name, t.Visible))
|
||||||
|
})
|
||||||
|
c.Action("window.fullscreen", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskFullscreen)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskFullscreen(t.Name, t.Fullscreen))
|
||||||
|
})
|
||||||
|
c.Action("window.saveLayout", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSaveLayout)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSaveLayout(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.restoreLayout", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskRestoreLayout)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskRestoreLayout(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.deleteLayout", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskDeleteLayout)
|
||||||
s.manager.Layout().DeleteLayout(t.Name)
|
s.manager.Layout().DeleteLayout(t.Name)
|
||||||
return nil, true, nil
|
return core.Result{OK: true}
|
||||||
case TaskTileWindows:
|
})
|
||||||
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
|
c.Action("window.tileWindows", func(_ context.Context, opts core.Options) core.Result {
|
||||||
case TaskSnapWindow:
|
t, _ := opts.Get("task").Value.(TaskTileWindows)
|
||||||
return nil, true, s.taskSnapWindow(t.Name, t.Position)
|
return core.Result{Value: nil, OK: true}.New(s.taskTileWindows(t.Mode, t.Windows))
|
||||||
default:
|
})
|
||||||
return nil, false, nil
|
c.Action("window.stackWindows", func(_ context.Context, opts core.Options) core.Result {
|
||||||
}
|
t, _ := opts.Get("task").Value.(TaskStackWindows)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY))
|
||||||
|
})
|
||||||
|
c.Action("window.snapWindow", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSnapWindow)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSnapWindow(t.Name, t.Position))
|
||||||
|
})
|
||||||
|
c.Action("window.applyWorkflow", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskApplyWorkflow)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskApplyWorkflow(t.Workflow, t.Windows))
|
||||||
|
})
|
||||||
|
c.Action("window.setZoom", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetZoom)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetZoom(t.Name, t.Magnification))
|
||||||
|
})
|
||||||
|
c.Action("window.zoomIn", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskZoomIn)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskZoomIn(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.zoomOut", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskZoomOut)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskZoomOut(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.zoomReset", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskZoomReset)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskZoomReset(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.setURL", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetURL)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetURL(t.Name, t.URL))
|
||||||
|
})
|
||||||
|
c.Action("window.setHTML", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetHTML)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetHTML(t.Name, t.HTML))
|
||||||
|
})
|
||||||
|
c.Action("window.execJS", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskExecJS)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskExecJS(t.Name, t.JS))
|
||||||
|
})
|
||||||
|
c.Action("window.toggleFullscreen", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskToggleFullscreen)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskToggleFullscreen(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.toggleMaximise", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskToggleMaximise)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskToggleMaximise(t.Name))
|
||||||
|
})
|
||||||
|
c.Action("window.setBounds", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetBounds)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetBounds(t.Name, t.X, t.Y, t.Width, t.Height))
|
||||||
|
})
|
||||||
|
c.Action("window.setContentProtection", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskSetContentProtection)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskSetContentProtection(t.Name, t.Protection))
|
||||||
|
})
|
||||||
|
c.Action("window.flash", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskFlash)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskFlash(t.Name, t.Enabled))
|
||||||
|
})
|
||||||
|
c.Action("window.print", func(_ context.Context, opts core.Options) core.Result {
|
||||||
|
t, _ := opts.Get("task").Value.(TaskPrint)
|
||||||
|
return core.Result{Value: nil, OK: true}.New(s.taskPrint(t.Name))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
func (s *Service) primaryScreenArea() (int, int, int, int) {
|
||||||
pw, err := s.manager.Open(t.Opts...)
|
const fallbackX = 0
|
||||||
|
const fallbackY = 0
|
||||||
|
const fallbackWidth = 1920
|
||||||
|
const fallbackHeight = 1080
|
||||||
|
|
||||||
|
r := s.Core().QUERY(screen.QueryPrimary{})
|
||||||
|
if !r.OK {
|
||||||
|
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, ok := r.Value.(*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) core.Result {
|
||||||
|
var (
|
||||||
|
pw PlatformWindow
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if t.Window != nil {
|
||||||
|
pw, err = s.manager.Create(t.Window)
|
||||||
|
} else {
|
||||||
|
pw, err = s.manager.Open(t.Options...)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return core.Result{Value: err, OK: false}
|
||||||
}
|
}
|
||||||
x, y := pw.Position()
|
x, y := pw.Position()
|
||||||
w, h := pw.Size()
|
w, h := pw.Size()
|
||||||
|
|
@ -168,7 +302,7 @@ func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
||||||
|
|
||||||
// Broadcast to all listeners
|
// Broadcast to all listeners
|
||||||
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
|
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
|
||||||
return info, true, nil
|
return core.Result{Value: info, OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackWindow attaches platform event listeners that emit IPC actions.
|
// trackWindow attaches platform event listeners that emit IPC actions.
|
||||||
|
|
@ -189,7 +323,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
|
||||||
if data := e.Data; data != nil {
|
if data := e.Data; data != nil {
|
||||||
w, _ := data["w"].(int)
|
w, _ := data["w"].(int)
|
||||||
h, _ := data["h"].(int)
|
h, _ := data["h"].(int)
|
||||||
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
|
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
|
||||||
}
|
}
|
||||||
case "close":
|
case "close":
|
||||||
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
|
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
|
||||||
|
|
@ -207,7 +341,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
|
||||||
func (s *Service) taskCloseWindow(name string) error {
|
func (s *Service) taskCloseWindow(name string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
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)
|
// Persist state BEFORE closing (spec requirement)
|
||||||
s.manager.State().CaptureState(pw)
|
s.manager.State().CaptureState(pw)
|
||||||
|
|
@ -220,27 +354,27 @@ func (s *Service) taskCloseWindow(name string) error {
|
||||||
func (s *Service) taskSetPosition(name string, x, y int) error {
|
func (s *Service) taskSetPosition(name string, x, y int) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskSetPosition", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.SetPosition(x, y)
|
pw.SetPosition(x, y)
|
||||||
s.manager.State().UpdatePosition(name, x, y)
|
s.manager.State().UpdatePosition(name, x, y)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetSize(name string, w, h int) error {
|
func (s *Service) taskSetSize(name string, width, height int) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskSetSize", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.SetSize(w, h)
|
pw.SetSize(width, height)
|
||||||
s.manager.State().UpdateSize(name, w, h)
|
s.manager.State().UpdateSize(name, width, height)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskMaximise(name string) error {
|
func (s *Service) taskMaximise(name string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskMaximise", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.Maximise()
|
pw.Maximise()
|
||||||
s.manager.State().UpdateMaximized(name, true)
|
s.manager.State().UpdateMaximized(name, true)
|
||||||
|
|
@ -250,7 +384,7 @@ func (s *Service) taskMaximise(name string) error {
|
||||||
func (s *Service) taskMinimise(name string) error {
|
func (s *Service) taskMinimise(name string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskMinimise", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.Minimise()
|
pw.Minimise()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -259,7 +393,7 @@ func (s *Service) taskMinimise(name string) error {
|
||||||
func (s *Service) taskFocus(name string) error {
|
func (s *Service) taskFocus(name string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskFocus", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.Focus()
|
pw.Focus()
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -268,7 +402,7 @@ func (s *Service) taskFocus(name string) error {
|
||||||
func (s *Service) taskRestore(name string) error {
|
func (s *Service) taskRestore(name string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskRestore", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.Restore()
|
pw.Restore()
|
||||||
s.manager.State().UpdateMaximized(name, false)
|
s.manager.State().UpdateMaximized(name, false)
|
||||||
|
|
@ -278,16 +412,34 @@ func (s *Service) taskRestore(name string) error {
|
||||||
func (s *Service) taskSetTitle(name, title string) error {
|
func (s *Service) taskSetTitle(name, title string) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskSetTitle", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.SetTitle(title)
|
pw.SetTitle(title)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetAlwaysOnTop", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetAlwaysOnTop(alwaysOnTop)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetBackgroundColour", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetBackgroundColour(red, green, blue, alpha)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetVisibility(name string, visible bool) error {
|
func (s *Service) taskSetVisibility(name string, visible bool) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskSetVisibility", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
pw.SetVisibility(visible)
|
pw.SetVisibility(visible)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -296,7 +448,7 @@ func (s *Service) taskSetVisibility(name string, visible bool) error {
|
||||||
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
||||||
pw, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("window not found: %s", name)
|
return coreerr.E("window.taskFullscreen", "window not found: "+name, nil)
|
||||||
}
|
}
|
||||||
if fullscreen {
|
if fullscreen {
|
||||||
pw.Fullscreen()
|
pw.Fullscreen()
|
||||||
|
|
@ -321,7 +473,7 @@ func (s *Service) taskSaveLayout(name string) error {
|
||||||
func (s *Service) taskRestoreLayout(name string) error {
|
func (s *Service) taskRestoreLayout(name string) error {
|
||||||
layout, ok := s.manager.Layout().GetLayout(name)
|
layout, ok := s.manager.Layout().GetLayout(name)
|
||||||
if !ok {
|
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 {
|
for winName, state := range layout.Windows {
|
||||||
pw, found := s.manager.Get(winName)
|
pw, found := s.manager.Get(winName)
|
||||||
|
|
@ -332,7 +484,10 @@ func (s *Service) taskRestoreLayout(name string) error {
|
||||||
pw.SetSize(state.Width, state.Height)
|
pw.SetSize(state.Width, state.Height)
|
||||||
if state.Maximized {
|
if state.Maximized {
|
||||||
pw.Maximise()
|
pw.Maximise()
|
||||||
|
} else {
|
||||||
|
pw.Restore()
|
||||||
}
|
}
|
||||||
|
s.manager.State().CaptureState(pw)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -348,13 +503,21 @@ var tileModeMap = map[string]TileMode{
|
||||||
func (s *Service) taskTileWindows(mode string, names []string) error {
|
func (s *Service) taskTileWindows(mode string, names []string) error {
|
||||||
tm, ok := tileModeMap[mode]
|
tm, ok := tileModeMap[mode]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown tile mode: %s", mode)
|
return coreerr.E("window.taskTileWindows", "unknown tile mode: "+mode, nil)
|
||||||
}
|
}
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
names = s.manager.List()
|
names = s.manager.List()
|
||||||
}
|
}
|
||||||
// Default screen size — callers can query screen_primary for actual values.
|
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||||
return s.manager.TileWindows(tm, names, 1920, 1080)
|
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{
|
var snapPosMap = map[string]SnapPosition{
|
||||||
|
|
@ -368,9 +531,184 @@ var snapPosMap = map[string]SnapPosition{
|
||||||
func (s *Service) taskSnapWindow(name, position string) error {
|
func (s *Service) taskSnapWindow(name, position string) error {
|
||||||
pos, ok := snapPosMap[position]
|
pos, ok := snapPosMap[position]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown snap position: %s", position)
|
return coreerr.E("window.taskSnapWindow", "unknown snap position: "+position, nil)
|
||||||
}
|
}
|
||||||
return s.manager.SnapWindow(name, pos, 1920, 1080)
|
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||||
|
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflowLayoutMap = map[string]WorkflowLayout{
|
||||||
|
"coding": WorkflowCoding,
|
||||||
|
"debugging": WorkflowDebugging,
|
||||||
|
"presenting": WorkflowPresenting,
|
||||||
|
"side-by-side": WorkflowSideBySide,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 len(names) == 0 {
|
||||||
|
names = s.manager.List()
|
||||||
|
}
|
||||||
|
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||||
|
return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zoom ---
|
||||||
|
|
||||||
|
func (s *Service) queryWindowZoom(name string) core.Result {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return core.Result{Value: coreerr.E("window.queryWindowZoom", "window not found: "+name, nil), OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{Value: pw.GetZoom(), OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetZoom(name string, magnification float64) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetZoom", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetZoom(magnification)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskZoomIn(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskZoomIn", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
current := pw.GetZoom()
|
||||||
|
pw.SetZoom(current + 0.1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskZoomOut(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskZoomOut", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
current := pw.GetZoom()
|
||||||
|
next := current - 0.1
|
||||||
|
if next < 0.1 {
|
||||||
|
next = 0.1
|
||||||
|
}
|
||||||
|
pw.SetZoom(next)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskZoomReset(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskZoomReset", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetZoom(1.0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content ---
|
||||||
|
|
||||||
|
func (s *Service) taskSetURL(name, url string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetURL", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetURL(url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetHTML(name, html string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetHTML", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetHTML(html)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskExecJS(name, js string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskExecJS", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.ExecJS(js)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State toggles ---
|
||||||
|
|
||||||
|
func (s *Service) taskToggleFullscreen(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskToggleFullscreen", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.ToggleFullscreen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskToggleMaximise(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskToggleMaximise", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.ToggleMaximise()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bounds ---
|
||||||
|
|
||||||
|
func (s *Service) queryWindowBounds(name string) core.Result {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return core.Result{Value: coreerr.E("window.queryWindowBounds", "window not found: "+name, nil), OK: false}
|
||||||
|
}
|
||||||
|
x, y, width, height := pw.GetBounds()
|
||||||
|
return core.Result{Value: WindowBounds{X: x, Y: y, Width: width, Height: height}, OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetBounds(name string, x, y, width, height int) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetBounds", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetBounds(x, y, width, height)
|
||||||
|
s.manager.State().UpdatePosition(name, x, y)
|
||||||
|
s.manager.State().UpdateSize(name, width, height)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content protection ---
|
||||||
|
|
||||||
|
func (s *Service) taskSetContentProtection(name string, protection bool) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskSetContentProtection", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.SetContentProtection(protection)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flash ---
|
||||||
|
|
||||||
|
func (s *Service) taskFlash(name string, enabled bool) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskFlash", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
pw.Flash(enabled)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print ---
|
||||||
|
|
||||||
|
func (s *Service) taskPrint(name string) error {
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return coreerr.E("window.taskPrint", "window not found: "+name, nil)
|
||||||
|
}
|
||||||
|
return pw.Print()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager returns the underlying window Manager for direct access.
|
// Manager returns the underlying window Manager for direct access.
|
||||||
|
|
|
||||||
130
pkg/window/service_screen_test.go
Normal file
130
pkg/window/service_screen_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package window
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "dappco.re/go/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 (m *mockScreenPlatform) GetCurrent() *screen.Screen {
|
||||||
|
return m.GetPrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
c := core.New(
|
||||||
|
core.WithService(screen.Register(&mockScreenPlatform{screens: screens})),
|
||||||
|
core.WithService(Register(newMockPlatform())),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
|
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}).OK)
|
||||||
|
require.True(t, taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}).OK)
|
||||||
|
|
||||||
|
r := taskRun(c, "window.tileWindows", TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
r2 := c.QUERY(QueryWindowByName{Name: "left"})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
left := r2.Value.(*WindowInfo)
|
||||||
|
assert.Equal(t, 0, left.X)
|
||||||
|
assert.Equal(t, 1000, left.Width)
|
||||||
|
assert.Equal(t, 1000, left.Height)
|
||||||
|
|
||||||
|
r3 := c.QUERY(QueryWindowByName{Name: "right"})
|
||||||
|
require.True(t, r3.OK)
|
||||||
|
right := r3.Value.(*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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("snap"), WithSize(400, 300)}}).OK)
|
||||||
|
|
||||||
|
r := taskRun(c, "window.snapWindow", TaskSnapWindow{Name: "snap", Position: "left"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
r2 := c.QUERY(QueryWindowByName{Name: "snap"})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
info := r2.Value.(*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},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.True(t, taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}).OK)
|
||||||
|
require.True(t, taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}).OK)
|
||||||
|
|
||||||
|
r := taskRun(c, "window.tileWindows", TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
r2 := c.QUERY(QueryWindowByName{Name: "left"})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
left := r2.Value.(*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)
|
||||||
|
|
||||||
|
r3 := c.QUERY(QueryWindowByName{Name: "right"})
|
||||||
|
require.True(t, r3.OK)
|
||||||
|
right := r3.Value.(*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)
|
||||||
|
}
|
||||||
|
|
@ -5,23 +5,28 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c := core.New(
|
||||||
core.WithService(Register(newMockPlatform())),
|
core.WithService(Register(newMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
|
||||||
svc := core.MustServiceFor[*Service](c, "window")
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskRun(c *core.Core, name string, task any) core.Result {
|
||||||
|
return c.Action(name).Run(context.Background(), core.NewOptions(
|
||||||
|
core.Option{Key: "task", Value: task},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
svc, _ := newTestWindowService(t)
|
svc, _ := newTestWindowService(t)
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
|
|
@ -30,112 +35,112 @@ func TestRegister_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestTaskOpenWindow_Good(t *testing.T) {
|
func TestTaskOpenWindow_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
r := taskRun(c, "window.open", TaskOpenWindow{
|
||||||
Opts: []WindowOption{WithName("test"), WithURL("/")},
|
Window: &Window{Name: "test", URL: "/"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
info := r.Value.(WindowInfo)
|
||||||
info := result.(WindowInfo)
|
|
||||||
assert.Equal(t, "test", info.Name)
|
assert.Equal(t, "test", info.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskOpenWindow_OptionsFallback_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.open", TaskOpenWindow{
|
||||||
|
Options: []WindowOption{WithName("test-fallback"), WithURL("/")},
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
info := r.Value.(WindowInfo)
|
||||||
|
assert.Equal(t, "test-fallback", info.Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskOpenWindow_Bad(t *testing.T) {
|
func TestTaskOpenWindow_Bad(t *testing.T) {
|
||||||
// No window service registered — PERFORM returns handled=false
|
// No window service registered — action is not registered
|
||||||
c, err := core.New(core.WithServiceLock())
|
c := core.New(core.WithServiceLock())
|
||||||
require.NoError(t, err)
|
r := c.Action("window.open").Run(context.Background(), core.NewOptions())
|
||||||
_, handled, _ := c.PERFORM(TaskOpenWindow{})
|
assert.False(t, r.OK)
|
||||||
assert.False(t, handled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWindowList_Good(t *testing.T) {
|
func TestQueryWindowList_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("a")}})
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("b")}})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryWindowList{})
|
r := c.QUERY(QueryWindowList{})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
list := r.Value.([]WindowInfo)
|
||||||
list := result.([]WindowInfo)
|
|
||||||
assert.Len(t, list, 2)
|
assert.Len(t, list, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWindowByName_Good(t *testing.T) {
|
func TestQueryWindowByName_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
|
r := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
info := r.Value.(*WindowInfo)
|
||||||
info := result.(*WindowInfo)
|
|
||||||
assert.Equal(t, "test", info.Name)
|
assert.Equal(t, "test", info.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWindowByName_Bad(t *testing.T) {
|
func TestQueryWindowByName_Bad(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
result, handled, err := c.QUERY(QueryWindowByName{Name: "nonexistent"})
|
r := c.QUERY(QueryWindowByName{Name: "nonexistent"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK) // handled=true, result is nil (not found)
|
||||||
assert.True(t, handled) // handled=true, result is nil (not found)
|
assert.Nil(t, r.Value)
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskCloseWindow_Good(t *testing.T) {
|
func TestTaskCloseWindow_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
|
r := taskRun(c, "window.close", TaskCloseWindow{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
// Verify window is removed
|
// Verify window is removed
|
||||||
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
r2 := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
assert.Nil(t, result)
|
assert.Nil(t, r2.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskCloseWindow_Bad(t *testing.T) {
|
func TestTaskCloseWindow_Bad(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "nonexistent"})
|
r := taskRun(c, "window.close", TaskCloseWindow{Name: "nonexistent"})
|
||||||
assert.True(t, handled)
|
assert.False(t, r.OK)
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetPosition_Good(t *testing.T) {
|
func TestTaskSetPosition_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
|
r := taskRun(c, "window.setPosition", TaskSetPosition{Name: "test", X: 100, Y: 200})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
r2 := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
info := result.(*WindowInfo)
|
info := r2.Value.(*WindowInfo)
|
||||||
assert.Equal(t, 100, info.X)
|
assert.Equal(t, 100, info.X)
|
||||||
assert.Equal(t, 200, info.Y)
|
assert.Equal(t, 200, info.Y)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetSize_Good(t *testing.T) {
|
func TestTaskSetSize_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
|
r := taskRun(c, "window.setSize", TaskSetSize{Name: "test", Width: 800, Height: 600})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
r2 := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
info := result.(*WindowInfo)
|
info := r2.Value.(*WindowInfo)
|
||||||
assert.Equal(t, 800, info.Width)
|
assert.Equal(t, 800, info.Width)
|
||||||
assert.Equal(t, 600, info.Height)
|
assert.Equal(t, 600, info.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskMaximise_Good(t *testing.T) {
|
func TestTaskMaximise_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
|
r := taskRun(c, "window.maximise", TaskMaximise{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.True(t, r.OK)
|
||||||
assert.True(t, handled)
|
|
||||||
|
|
||||||
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
r2 := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
info := result.(*WindowInfo)
|
info := r2.Value.(*WindowInfo)
|
||||||
assert.True(t, info.Maximized)
|
assert.True(t, info.Maximized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,22 +148,22 @@ func TestFileDrop_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
|
|
||||||
// Open a window
|
// Open a window
|
||||||
result, _, _ := c.PERFORM(TaskOpenWindow{
|
r := taskRun(c, "window.open", TaskOpenWindow{
|
||||||
Opts: []WindowOption{WithName("drop-test")},
|
Options: []WindowOption{WithName("drop-test")},
|
||||||
})
|
})
|
||||||
info := result.(WindowInfo)
|
info := r.Value.(WindowInfo)
|
||||||
assert.Equal(t, "drop-test", info.Name)
|
assert.Equal(t, "drop-test", info.Name)
|
||||||
|
|
||||||
// Capture broadcast actions
|
// Capture broadcast actions
|
||||||
var dropped ActionFilesDropped
|
var dropped ActionFilesDropped
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||||
if a, ok := msg.(ActionFilesDropped); ok {
|
if a, ok := msg.(ActionFilesDropped); ok {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
dropped = a
|
dropped = a
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return core.Result{OK: true}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get the mock window and simulate file drop
|
// Get the mock window and simulate file drop
|
||||||
|
|
@ -174,3 +179,640 @@ func TestFileDrop_Good(t *testing.T) {
|
||||||
assert.Equal(t, "upload-zone", dropped.TargetID)
|
assert.Equal(t, "upload-zone", dropped.TargetID)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TaskMinimise ---
|
||||||
|
|
||||||
|
func TestTaskMinimise_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.minimise", TaskMinimise{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.minimise", TaskMinimise{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskFocus ---
|
||||||
|
|
||||||
|
func TestTaskFocus_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.focus", TaskFocus{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.focus", TaskFocus{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskRestore ---
|
||||||
|
|
||||||
|
func TestTaskRestore_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
// First maximise, then restore
|
||||||
|
taskRun(c, "window.maximise", TaskMaximise{Name: "test"})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.restore", TaskRestore{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.restore", TaskRestore{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskSetTitle ---
|
||||||
|
|
||||||
|
func TestTaskSetTitle_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setTitle", TaskSetTitle{Name: "test", Title: "New Title"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.setTitle", TaskSetTitle{Name: "nonexistent", Title: "Nope"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskSetAlwaysOnTop ---
|
||||||
|
|
||||||
|
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setAlwaysOnTop", TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.setAlwaysOnTop", TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskSetBackgroundColour ---
|
||||||
|
|
||||||
|
func TestTaskSetBackgroundColour_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setBackgroundColour", TaskSetBackgroundColour{
|
||||||
|
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
|
||||||
|
})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
r := taskRun(c, "window.setBackgroundColour", TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskSetVisibility ---
|
||||||
|
|
||||||
|
func TestTaskSetVisibility_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setVisibility", TaskSetVisibility{Name: "test", Visible: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.visible)
|
||||||
|
|
||||||
|
// Now hide it
|
||||||
|
r2 := taskRun(c, "window.setVisibility", TaskSetVisibility{Name: "test", Visible: false})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
assert.False(t, mw.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetVisibility_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setVisibility", TaskSetVisibility{Name: "nonexistent", Visible: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskFullscreen ---
|
||||||
|
|
||||||
|
func TestTaskFullscreen_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
// Enter fullscreen
|
||||||
|
r := taskRun(c, "window.fullscreen", TaskFullscreen{Name: "test", Fullscreen: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.fullscreened)
|
||||||
|
|
||||||
|
// Exit fullscreen
|
||||||
|
r2 := taskRun(c, "window.fullscreen", TaskFullscreen{Name: "test", Fullscreen: false})
|
||||||
|
require.True(t, r2.OK)
|
||||||
|
assert.False(t, mw.fullscreened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskFullscreen_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.fullscreen", TaskFullscreen{Name: "nonexistent", Fullscreen: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskSaveLayout ---
|
||||||
|
|
||||||
|
func TestTaskSaveLayout_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}})
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.saveLayout", TaskSaveLayout{Name: "coding"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
r := taskRun(c, "window.saveLayout", TaskSaveLayout{Name: ""})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskRestoreLayout ---
|
||||||
|
|
||||||
|
func TestTaskRestoreLayout_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
// Open windows
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}})
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}})
|
||||||
|
|
||||||
|
// Save a layout with specific positions
|
||||||
|
taskRun(c, "window.saveLayout", TaskSaveLayout{Name: "coding"})
|
||||||
|
|
||||||
|
// Move the windows to different positions
|
||||||
|
taskRun(c, "window.setPosition", TaskSetPosition{Name: "editor", X: 500, Y: 500})
|
||||||
|
taskRun(c, "window.setPosition", TaskSetPosition{Name: "terminal", X: 600, Y: 600})
|
||||||
|
|
||||||
|
// Restore the layout
|
||||||
|
r := taskRun(c, "window.restoreLayout", TaskRestoreLayout{Name: "coding"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
r := taskRun(c, "window.restoreLayout", TaskRestoreLayout{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TaskStackWindows ---
|
||||||
|
|
||||||
|
func TestTaskStackWindows_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("s1"), WithSize(800, 600)}})
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("s2"), WithSize(800, 600)}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.stackWindows", TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600)}})
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600)}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.applyWorkflow", TaskApplyWorkflow{Workflow: "side-by-side"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zoom ---
|
||||||
|
|
||||||
|
func TestQueryWindowZoom_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := c.QUERY(QueryWindowZoom{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
assert.Equal(t, 1.0, r.Value.(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWindowZoom_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := c.QUERY(QueryWindowZoom{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setZoom", TaskSetZoom{Name: "test", Magnification: 1.5})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Equal(t, 1.5, mw.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetZoom_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setZoom", TaskSetZoom{Name: "nonexistent", Magnification: 1.5})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomIn_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.zoomIn", TaskZoomIn{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.InDelta(t, 1.1, mw.zoom, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomIn_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.zoomIn", TaskZoomIn{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomOut_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
// Set zoom to 1.5 first so we can decrease it
|
||||||
|
taskRun(c, "window.setZoom", TaskSetZoom{Name: "test", Magnification: 1.5})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.zoomOut", TaskZoomOut{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.InDelta(t, 1.4, mw.zoom, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomOut_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.zoomOut", TaskZoomOut{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomReset_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
taskRun(c, "window.setZoom", TaskSetZoom{Name: "test", Magnification: 2.0})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.zoomReset", TaskZoomReset{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Equal(t, 1.0, mw.zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskZoomReset_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.zoomReset", TaskZoomReset{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content ---
|
||||||
|
|
||||||
|
func TestTaskSetURL_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setURL", TaskSetURL{Name: "test", URL: "https://example.com"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Equal(t, "https://example.com", mw.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetURL_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setURL", TaskSetURL{Name: "nonexistent", URL: "https://example.com"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetHTML_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setHTML", TaskSetHTML{Name: "test", HTML: "<h1>Hello</h1>"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Equal(t, "<h1>Hello</h1>", mw.html)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetHTML_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setHTML", TaskSetHTML{Name: "nonexistent", HTML: "<h1>Hello</h1>"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskExecJS_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.execJS", TaskExecJS{Name: "test", JS: "document.title = 'Ready'"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Contains(t, mw.execJSCalls, "document.title = 'Ready'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskExecJS_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.execJS", TaskExecJS{Name: "nonexistent", JS: "alert(1)"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State toggles ---
|
||||||
|
|
||||||
|
func TestTaskToggleFullscreen_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
// Toggle on
|
||||||
|
r := taskRun(c, "window.toggleFullscreen", TaskToggleFullscreen{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.fullscreened)
|
||||||
|
|
||||||
|
// Toggle off
|
||||||
|
taskRun(c, "window.toggleFullscreen", TaskToggleFullscreen{Name: "test"})
|
||||||
|
assert.False(t, mw.fullscreened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskToggleFullscreen_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.toggleFullscreen", TaskToggleFullscreen{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskToggleMaximise_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
// Toggle on
|
||||||
|
r := taskRun(c, "window.toggleMaximise", TaskToggleMaximise{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.maximised)
|
||||||
|
|
||||||
|
// Toggle off
|
||||||
|
taskRun(c, "window.toggleMaximise", TaskToggleMaximise{Name: "test"})
|
||||||
|
assert.False(t, mw.maximised)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskToggleMaximise_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.toggleMaximise", TaskToggleMaximise{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bounds ---
|
||||||
|
|
||||||
|
func TestQueryWindowBounds_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{
|
||||||
|
WithName("test"), WithSize(800, 600), WithPosition(100, 200),
|
||||||
|
}})
|
||||||
|
|
||||||
|
r := c.QUERY(QueryWindowBounds{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
bounds := r.Value.(WindowBounds)
|
||||||
|
assert.Equal(t, 100, bounds.X)
|
||||||
|
assert.Equal(t, 200, bounds.Y)
|
||||||
|
assert.Equal(t, 800, bounds.Width)
|
||||||
|
assert.Equal(t, 600, bounds.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWindowBounds_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := c.QUERY(QueryWindowBounds{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetBounds_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setBounds", TaskSetBounds{Name: "test", X: 50, Y: 75, Width: 1024, Height: 768})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.Equal(t, 50, mw.x)
|
||||||
|
assert.Equal(t, 75, mw.y)
|
||||||
|
assert.Equal(t, 1024, mw.width)
|
||||||
|
assert.Equal(t, 768, mw.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetBounds_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setBounds", TaskSetBounds{Name: "nonexistent", X: 0, Y: 0, Width: 800, Height: 600})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content protection ---
|
||||||
|
|
||||||
|
func TestTaskSetContentProtection_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.setContentProtection", TaskSetContentProtection{Name: "test", Protection: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.contentProtection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetContentProtection_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.setContentProtection", TaskSetContentProtection{Name: "nonexistent", Protection: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flash ---
|
||||||
|
|
||||||
|
func TestTaskFlash_Good(t *testing.T) {
|
||||||
|
svc, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.flash", TaskFlash{Name: "test", Enabled: true})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
mw := pw.(*mockWindow)
|
||||||
|
assert.True(t, mw.flashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskFlash_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.flash", TaskFlash{Name: "nonexistent", Enabled: true})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print ---
|
||||||
|
|
||||||
|
func TestTaskPrint_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
r := taskRun(c, "window.print", TaskPrint{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskPrint_Bad(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
r := taskRun(c, "window.print", TaskPrint{Name: "nonexistent"})
|
||||||
|
assert.False(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State queries (IsVisible, IsFullscreen, IsMinimised) ---
|
||||||
|
|
||||||
|
func TestQueryWindowBounds_Ugly(t *testing.T) {
|
||||||
|
// Verify bounds reflect position and size changes
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
taskRun(c, "window.open", TaskOpenWindow{Options: []WindowOption{WithName("test"), WithSize(1280, 800)}})
|
||||||
|
taskRun(c, "window.setBounds", TaskSetBounds{Name: "test", X: 10, Y: 20, Width: 640, Height: 480})
|
||||||
|
|
||||||
|
r := c.QUERY(QueryWindowBounds{Name: "test"})
|
||||||
|
require.True(t, r.OK)
|
||||||
|
bounds := r.Value.(WindowBounds)
|
||||||
|
assert.Equal(t, 10, bounds.X)
|
||||||
|
assert.Equal(t, 20, bounds.Y)
|
||||||
|
assert.Equal(t, 640, bounds.Width)
|
||||||
|
assert.Equal(t, 480, bounds.Height)
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue