Skip to content

Commit a0cf305

Browse files
committed
feat: add native Wails desktop app
1 parent c52539d commit a0cf305

27 files changed

Lines changed: 627 additions & 84 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ codebuddy_usage.md
2424
.gemini/
2525
goose*
2626
node_modules
27+
cmd/cam-desktop/webui/dist/
28+
bin/
29+
*.syso

cmd/cam-desktop/assets.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"embed"
5+
"io/fs"
6+
)
7+
8+
//go:embed all:webui
9+
var embeddedWebUI embed.FS
10+
11+
func desktopAssets() (fs.FS, error) {
12+
if _, err := fs.Stat(embeddedWebUI, "webui/dist/index.html"); err == nil {
13+
return fs.Sub(embeddedWebUI, "webui/dist")
14+
}
15+
return fs.Sub(embeddedWebUI, "webui/fallback")
16+
}

cmd/cam-desktop/assets_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package main
2+
3+
import (
4+
"io/fs"
5+
"testing"
6+
)
7+
8+
func TestDesktopAssetsIncludesIndex(t *testing.T) {
9+
assets, err := desktopAssets()
10+
if err != nil {
11+
t.Fatalf("desktopAssets() error = %v", err)
12+
}
13+
14+
content, err := fs.ReadFile(assets, "index.html")
15+
if err != nil {
16+
t.Fatalf("read embedded index.html: %v", err)
17+
}
18+
if len(content) == 0 {
19+
t.Fatal("embedded index.html is empty")
20+
}
21+
}

cmd/cam-desktop/main.go

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,61 @@ package main
33
import (
44
"encoding/json"
55
"fmt"
6+
"log"
67
"os"
78

89
"github.com/chat2anyllm/code-agent-manager/internal/desktop"
10+
"github.com/wailsapp/wails/v2"
11+
"github.com/wailsapp/wails/v2/pkg/options"
12+
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
913
)
1014

1115
var version = "dev"
1216

1317
func main() {
1418
services := desktop.NewServices(version, "")
15-
if len(os.Args) > 1 && os.Args[1] == "--services" {
16-
_ = json.NewEncoder(os.Stdout).Encode(map[string][]string{
17-
"services": {"app", "providers", "mcp", "entities", "tools", "doctor", "config", "launch"},
18-
})
19-
return
19+
if len(os.Args) > 1 {
20+
switch os.Args[1] {
21+
case "--services":
22+
_ = json.NewEncoder(os.Stdout).Encode(map[string][]string{
23+
"services": {"app", "providers", "mcp", "entities", "tools", "doctor", "config", "launch"},
24+
})
25+
return
26+
case "--version", "version":
27+
fmt.Printf("cam-desktop %s\n", services.App.Version())
28+
return
29+
}
2030
}
21-
fmt.Printf("cam-desktop %s\n", services.App.Version())
22-
fmt.Println("Desktop services are available for Wails registration.")
31+
32+
if err := runDesktopApp(services); err != nil {
33+
log.Fatal(err)
34+
}
35+
}
36+
37+
func runDesktopApp(services desktop.Services) error {
38+
assets, err := desktopAssets()
39+
if err != nil {
40+
return fmt.Errorf("load desktop assets: %w", err)
41+
}
42+
43+
return wails.Run(&options.App{
44+
Title: "Code Agent Manager",
45+
Width: 1180,
46+
Height: 780,
47+
MinWidth: 960,
48+
MinHeight: 640,
49+
AssetServer: &assetserver.Options{
50+
Assets: assets,
51+
},
52+
Bind: []interface{}{
53+
services.App,
54+
services.Providers,
55+
services.MCP,
56+
services.Entities,
57+
services.Tools,
58+
services.Doctor,
59+
services.Config,
60+
services.Launch,
61+
},
62+
})
2363
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Code Agent Manager Desktop</title>
7+
<style>
8+
:root {
9+
color-scheme: light dark;
10+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
11+
background: #0f172a;
12+
color: #e2e8f0;
13+
}
14+
body {
15+
margin: 0;
16+
min-height: 100vh;
17+
display: grid;
18+
place-items: center;
19+
}
20+
main {
21+
width: min(680px, calc(100vw - 48px));
22+
border: 1px solid rgba(148, 163, 184, 0.35);
23+
border-radius: 20px;
24+
padding: 32px;
25+
background: rgba(15, 23, 42, 0.92);
26+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
27+
}
28+
h1 {
29+
margin: 0 0 12px;
30+
font-size: 28px;
31+
}
32+
p {
33+
line-height: 1.6;
34+
color: #cbd5e1;
35+
}
36+
code {
37+
border-radius: 8px;
38+
padding: 3px 7px;
39+
background: rgba(148, 163, 184, 0.16);
40+
color: #f8fafc;
41+
}
42+
.status {
43+
display: inline-flex;
44+
align-items: center;
45+
gap: 8px;
46+
margin-bottom: 20px;
47+
color: #86efac;
48+
font-weight: 700;
49+
}
50+
.dot {
51+
width: 10px;
52+
height: 10px;
53+
border-radius: 999px;
54+
background: #22c55e;
55+
box-shadow: 0 0 16px #22c55e;
56+
}
57+
</style>
58+
</head>
59+
<body>
60+
<main>
61+
<div class="status"><span class="dot"></span>Native shell running</div>
62+
<h1>Code Agent Manager Desktop</h1>
63+
<p>
64+
The Wails native desktop runtime started successfully, but the React UI bundle has not been built into the embedded asset directory yet.
65+
</p>
66+
<p>
67+
Run <code>npm --prefix frontend run build</code>, then rebuild or rerun <code>go run ./cmd/cam-desktop</code> to embed and display the full desktop UI.
68+
</p>
69+
</main>
70+
</body>
71+
</html>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Wails Native Desktop Runtime Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Turn the existing browser-only React desktop UI into a native Wails desktop app, add tests for the native runtime wiring, and verify backend/frontend tests and builds pass.
6+
7+
**Architecture:** Keep the existing CLI and `internal/desktop` services intact. Add a Wails v2 native entrypoint in `cmd/cam-desktop` that binds the existing service structs, embeds the Vite production bundle when available, and falls back to a small embedded diagnostic page when the bundle has not been built. Configure Vite to emit the desktop bundle into the Go entrypoint's embedded asset tree, and configure `wails.json`/`install.sh` to prefer Wails native builds when the Wails CLI is installed while preserving the existing lightweight Go fallback.
8+
9+
**Tech Stack:** Go, Wails v2, React, TypeScript, Vite, Vitest, Go `testing`, PowerShell validation commands.
10+
11+
---
12+
13+
## Files
14+
15+
### Create
16+
- `cmd/cam-desktop/assets.go` — embeds desktop web assets and selects built UI or fallback UI.
17+
- `cmd/cam-desktop/assets_test.go` — verifies the fallback asset filesystem works without a frontend build.
18+
- `cmd/cam-desktop/webui/fallback/index.html` — diagnostic fallback page embedded in the native binary.
19+
- `wails.json` — Wails project configuration for native dev/build commands.
20+
21+
### Modify
22+
- `cmd/cam-desktop/main.go` — run a native Wails app by default, keep `--services` smoke mode.
23+
- `cmd/cam-desktop/main_test.go` — add service-list tests if needed.
24+
- `frontend/vite.config.ts` — emit production assets into `cmd/cam-desktop/webui/dist` for embedding.
25+
- `.gitignore` — ignore generated Wails/Vite asset output, keep fallback assets trackable.
26+
- `go.mod` / `go.sum` — add Wails v2 dependency.
27+
- `install.sh` — prefer `wails build` when available, then `wails3 build`, then Go fallback.
28+
29+
---
30+
31+
## Task 1: Add Wails dependency and native app entrypoint
32+
33+
**Files:**
34+
- Modify: `go.mod`
35+
- Modify: `go.sum`
36+
- Modify: `cmd/cam-desktop/main.go`
37+
38+
- [ ] Add Wails v2 with `go get github.com/wailsapp/wails/v2@latest`.
39+
- [ ] Replace the print-only default path in `cmd/cam-desktop/main.go` with a `runDesktopApp()` function using `app.Run(&options.App{...})`.
40+
- [ ] Bind the existing services: `services.App`, `services.Providers`, `services.MCP`, `services.Entities`, `services.Tools`, `services.Doctor`, `services.Config`, and `services.Launch`.
41+
- [ ] Keep `--services` as a non-GUI smoke command that prints the service names as JSON.
42+
- [ ] Keep `--version` as a non-GUI smoke command that prints the desktop version.
43+
- [ ] Verify compilation with `go test ./cmd/cam-desktop`.
44+
45+
## Task 2: Embed frontend assets with fallback
46+
47+
**Files:**
48+
- Create: `cmd/cam-desktop/assets.go`
49+
- Create: `cmd/cam-desktop/assets_test.go`
50+
- Create: `cmd/cam-desktop/webui/fallback/index.html`
51+
- Modify: `.gitignore`
52+
53+
- [ ] Add `//go:embed all:webui` to embed everything under `cmd/cam-desktop/webui`.
54+
- [ ] Implement `desktopAssets()` so it returns `webui/dist` when `index.html` exists, otherwise `webui/fallback`.
55+
- [ ] Add a fallback page that clearly says the native shell is running and instructs developers to run `npm --prefix frontend run build` for the full React UI.
56+
- [ ] Ignore generated `cmd/cam-desktop/webui/dist/` and Wails build output.
57+
- [ ] Test that `desktopAssets()` can open `index.html` without a frontend build.
58+
- [ ] Verify with `go test ./cmd/cam-desktop -run TestDesktopAssets`.
59+
60+
## Task 3: Connect Vite build output to Wails embed tree
61+
62+
**Files:**
63+
- Modify: `frontend/vite.config.ts`
64+
65+
- [ ] Add `build.outDir = '../cmd/cam-desktop/webui/dist'`.
66+
- [ ] Add `build.emptyOutDir = true`.
67+
- [ ] Keep the existing Vitest settings unchanged.
68+
- [ ] Verify with `npm --prefix frontend run build`.
69+
- [ ] Verify the generated file exists at `cmd/cam-desktop/webui/dist/index.html`.
70+
71+
## Task 4: Add Wails project config and installer support
72+
73+
**Files:**
74+
- Create: `wails.json`
75+
- Modify: `install.sh`
76+
77+
- [ ] Add Wails v2 config with project name `cam-desktop`, output filename `cam-desktop`, frontend build command `npm --prefix frontend run build`, and dev server URL `http://127.0.0.1:5173`.
78+
- [ ] Update `install.sh` desktop build logic to run `wails build` when available and `wails3 build` as a fallback.
79+
- [ ] Preserve the existing `go build ./cmd/cam-desktop` fallback when no Wails CLI exists.
80+
- [ ] Verify Go-side behavior with `go test ./cmd/cam-desktop ./internal/desktop`.
81+
82+
## Task 5: Frontend binding compatibility tests
83+
84+
**Files:**
85+
- Modify: `frontend/src/services/api.test.ts`
86+
- Modify: `frontend/src/services/api.ts` only if tests reveal binding-name incompatibility.
87+
88+
- [ ] Add tests proving the adapter uses Wails-style service bindings when `window.go.desktop` is present.
89+
- [ ] Keep mock fallback behavior for browser-only dev mode.
90+
- [ ] If Wails exposes `AppService`/`ProviderService` names, support both the existing short aliases and service-struct names.
91+
- [ ] Verify with `npm --prefix frontend test -- --run src/services/api.test.ts`.
92+
93+
## Task 6: Full verification
94+
95+
**Files:** all changed files.
96+
97+
- [ ] Run `go test ./cmd/cam-desktop ./internal/desktop`.
98+
- [ ] Run `go test ./...`.
99+
- [ ] Run `npm --prefix frontend test -- --run`.
100+
- [ ] Run `npm --prefix frontend run build`.
101+
- [ ] Run `go build ./cmd/cam-desktop` after the frontend build so embedded React assets are compiled into the native app.
102+
- [ ] Run `go run ./cmd/cam-desktop --services` and verify the JSON service list.
103+
- [ ] If Wails CLI is installed, run `wails build`; if it is not installed, record that native packaging could not be executed in this environment while the Wails app source compiles.
104+
105+
## Self-Review
106+
107+
- Spec coverage: native Wails runtime, frontend embedding, Wails config, installer support, binding compatibility, backend/frontend verification are covered.
108+
- Placeholder scan: no TBD/TODO placeholders remain.
109+
- Type consistency: service names match the existing `internal/desktop.Services` fields and frontend adapter methods.

frontend/src/services/api.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
22
import { api } from './api'
33

4+
const resetWails = () => {
5+
delete window.go
6+
}
7+
48
describe('api mock fallback', () => {
9+
afterEach(resetWails)
10+
511
it('lists tools and providers', async () => {
612
await expect(api.listTools()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ command: 'claude' })]))
713
await expect(api.listProviders()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'local' })]))
@@ -14,3 +20,33 @@ describe('api mock fallback', () => {
1420
expect(plan.model).toBe('model')
1521
})
1622
})
23+
24+
describe('api Wails bindings', () => {
25+
afterEach(resetWails)
26+
27+
it('uses short service aliases when Wails bindings exist', async () => {
28+
const tools = [{ name: 'claude-code', command: 'claude', description: 'Claude Code', enabled: true, installed: true }]
29+
const providers = [{ name: 'anthropic', endpoint: 'https://api.anthropic.com', apiKeyEnv: 'ANTHROPIC_API_KEY', supportedClient: 'claude', clients: ['claude'], models: ['claude-opus-4-8'], keepProxyConfig: false, useProxy: false, enabled: true }]
30+
const listTools = vi.fn().mockResolvedValue(tools)
31+
const listProviders = vi.fn().mockResolvedValue(providers)
32+
window.go = { desktop: { Tools: { List: listTools }, Providers: { List: listProviders } } }
33+
34+
await expect(api.listTools()).resolves.toBe(tools)
35+
await expect(api.listProviders()).resolves.toBe(providers)
36+
expect(listTools).toHaveBeenCalledOnce()
37+
expect(listProviders).toHaveBeenCalledOnce()
38+
})
39+
40+
it('uses Wails struct service names when generated bindings include them', async () => {
41+
const tools = [{ name: 'codex', command: 'codex', description: 'Codex', enabled: true, installed: false }]
42+
const providers = [{ name: 'local', endpoint: 'http://localhost:4000/v1', apiKeyEnv: 'LOCAL_KEY', supportedClient: 'codex', clients: ['codex'], models: ['gpt-4.1'], keepProxyConfig: false, useProxy: false, enabled: true }]
43+
const listTools = vi.fn().mockResolvedValue(tools)
44+
const listProviders = vi.fn().mockResolvedValue(providers)
45+
window.go = { desktop: { ToolService: { List: listTools }, ProviderService: { List: listProviders } } }
46+
47+
await expect(api.listTools()).resolves.toBe(tools)
48+
await expect(api.listProviders()).resolves.toBe(providers)
49+
expect(listTools).toHaveBeenCalledOnce()
50+
expect(listProviders).toHaveBeenCalledOnce()
51+
})
52+
})

0 commit comments

Comments
 (0)