diff --git a/docs/desktop-shell/design.md b/docs/desktop-shell/design.md new file mode 100644 index 000000000..50f01ffdd --- /dev/null +++ b/docs/desktop-shell/design.md @@ -0,0 +1,443 @@ +# add-desktop-shell — Design + +> **Reads**: `proposal.md` (this change), `openspec/changes/add-web-daemon/`, +> `openspec/changes/add-web-chat-ui/`. +> +> **Touches**: new `src/desktop/`, modifies `src/cli.cpp`, `CMakeLists.txt`, +> `src/utils/paths.{hpp,cpp}` (no new path roots — reuses `User` mode), and +> a small extension to `src/web/auth.cpp` to support per-launch loopback +> tokens. + +--- + +## 1. Architecture overview + +``` + ┌──────────────────────────────────┐ + │ acecode-desktop (native shell) │ + │ │ + │ ┌────────────┐ ┌────────────┐ │ + │ │ DaemonSup │ │ WebView │ │ + │ │ (spawn, │ │ (loads │ │ + │ │ watch, │ │ http:// …│ │ + │ │ stop) │ │ /?t=…) │ │ + │ └─────┬──────┘ └─────┬──────┘ │ + │ │ stdout/IPC │ JS↔C++ │ + │ │ │ bridge │ + └────────┼───────────────┼────────┘ + ▼ ▼ + ┌────────────────────────────┐ + │ acecode daemon (subprocess)│ + │ Crow on 127.0.0.1: │ + │ serves /, /api, /ws/... │ + └────────────────────────────┘ +``` + +The desktop binary is the **parent** process and the daemon is its child. +This keeps the well-tested `worker.cpp::run_worker` path untouched and lets +the desktop process die without leaking the daemon (we set +`PROC_THREAD_ATTRIBUTE_PARENT_PROCESS` + Job Object on Windows, `prctl +PR_SET_PDEATHSIG` on Linux, and `posix_spawn` + signal-on-exit cleanup on +macOS — see §4 below). + +--- + +## 2. Decision: native shell library + +**Decision**: use [`webview/webview`](https://github.com/webview/webview) +(MIT, single-header-style C/C++). + +**Rationale**: +- Maps directly onto OS-native WebViews: + - Windows: WebView2 (Edge Chromium) + - macOS: WKWebView + - Linux: WebKitGTK +- C++ API surface fits this codebase: `webview::webview w(...); w.navigate(...); + w.bind("name", handler); w.run();` +- Single dependency, ships as a vcpkg port (`webview2` on Windows is a NuGet + package — vcpkg has overlay support). +- No new toolchain. + +**Alternatives considered**: +- **Direct WebView2 SDK on Windows** — too much per-platform glue. +- **wxWebView** — pulls all of wxWidgets for one widget. +- **Sciter** — license complexity (commercial licensing for some uses). + +**Risk**: `webview/webview` is small and could go unmaintained. Mitigation: +the API surface we use is tiny (~6 entry points), and we wrap it behind +`src/desktop/web_host.hpp` so the implementation can be swapped without +touching the supervision layer. + +--- + +## 3. Decision: daemon as child process (not in-process) + +**Decision**: the desktop binary spawns `acecode daemon --foreground` and +treats it as a managed subprocess. + +**Rationale**: +- Crash isolation. Agent loops do streaming HTTP, run user `bash` + commands, and call MCP servers — any of which can wedge a process. We + don't want a wedged tool call to also wedge the WebView and prevent a + user from cancelling. +- Reuses `daemon/worker.cpp::run_worker` verbatim. Zero new code paths in + the daemon. +- Makes "attach to an already running daemon" trivial — see §5. +- The TUI / browser / desktop all become symmetric clients of one daemon. + +**Tradeoff**: one extra process and a small startup latency (~150 ms on +warm SSD, dominated by Crow init). Acceptable. + +**Alternative**: link the agent loop directly into the GUI process. Saves +one process but loses crash isolation and forces us to fork the +`run_worker` startup sequence. + +--- + +## 4. Process supervision details + +`src/desktop/daemon_supervisor.{hpp,cpp}` owns the daemon child. Lifecycle: + +### 4.1 Spawn +- Compute daemon binary path: same directory as the desktop binary, named + `acecode` (POSIX) / `acecode.exe` (Windows). If absent, fall back to + whatever `acecode` resolves to via `$PATH`. +- Build argv: + ``` + acecode daemon --foreground --port 0 --emit-runtime-json + ``` + - `--port 0` is **new** (today's daemon hard-defaults to `web.port`). We + extend `daemon/cli.cpp` to accept `--port ` and treat `0` as + "ephemeral, OS picks". See §7. + - `--emit-runtime-json` is **new**: instead of the current human banner + "Daemon listening on …", emit a single JSON line on stdout with + `{"port": …, "token": …, "guid": …, "data_dir": …}` once the server is + bound. The desktop reads this line, then continues to consume stdout + for log capture. See §7. + +### 4.2 Watch +- Read stdout line-by-line on a worker thread. The first line that parses + as `{"port": …}` unblocks the WebView load. +- Subsequent lines are appended to a ring buffer (1024 entries) exposed via + `Help → Show Daemon Logs` in the menu. We do not aggregate to a file from + the desktop side — the daemon already logs to `/logs/`. +- Heartbeat: existing `/run/heartbeat` JSON, polled every 5 s. If + `now - timestamp_ms > 15 s` and the child PID is still alive, surface a + yellow banner "Daemon unresponsive" but don't kill — the user may be in + the middle of a long bash run. + +### 4.3 Cleanup +- On clean quit (window close + tray off, `cmd+q`, `Quit` menu), send + SIGTERM (POSIX) or `CTRL_BREAK_EVENT` to the process group (Windows). + Wait up to 5 s, then SIGKILL. +- On crash of the desktop process: rely on OS-level parent-death tracking + so the daemon dies with us: + - **Linux**: child calls `prctl(PR_SET_PDEATHSIG, SIGTERM)` immediately + after fork, before exec. Implementation note: this requires the daemon + to opt in via a flag, since `prctl` must run in the child between fork + and exec — we add `--die-with-parent` to the daemon CLI. + - **macOS**: no clean equivalent. Use a `kqueue` `EVFILT_PROC` watcher in + the daemon (gated by `--die-with-parent`) that exits when the parent + PID disappears. Fallback: heartbeat from desktop → daemon every 2 s on + a unix socket; daemon exits if 3 missed. + - **Windows**: assign the daemon to a Job Object with + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` from the desktop side; no daemon + cooperation needed. + +### 4.4 Restart policy +- v1: no auto-restart. If the daemon dies, the WebView shows a connection + banner ("Daemon stopped") with two buttons: `Restart` and `View logs`. +- Rationale: an auto-restart loop on a misconfigured daemon (bad provider, + port bind failure with `--port 0` should not happen but safety) creates + noise. + +--- + +## 5. Decision: reuse-running-daemon mode + +**Decision**: `acecode desktop --reuse-running-daemon` skips spawn and +discovers via the existing `/run/` files (`port`, `token`, `guid`, +`heartbeat`). + +**Rationale**: +- Power users running `acecode daemon start` (Service mode) will want the + GUI to attach instead of spawning a second daemon. +- Existing single-instance protections (GUID mutex in + `validate_can_start`) already prevent two daemons on the same data dir. + +**Implementation**: +- Read `heartbeat`. If `timestamp_ms` is fresh (< 15 s), reuse. +- Else, treat as stale and spawn our own (but don't delete the files — + `validate_can_start` will GC them). + +**Default behavior**: without `--reuse-running-daemon`, the desktop always +spawns its own ephemeral child to keep the lifecycle simple. + +--- + +## 6. Decision: per-launch loopback token + +**Decision**: even on loopback, the desktop launch generates a fresh token +and passes it to the WebView via the initial URL `?token=…`. The token is +stored in `/run/token` (mode 0600) for the daemon to validate. + +**Rationale**: today the daemon trusts loopback unconditionally +(`auth.cpp::require_auth`). That's fine when the user explicitly browses +to `localhost:28080`, but for desktop the WebView ends up on a port that +any other local process can also probe. The cost of always requiring a +token on the desktop launch path is one extra header — negligible — +versus the cost of a curious local process hitting `/api/sessions`. + +**Implementation**: +- Add `web.require_token_on_loopback: bool` (default `false` to preserve + existing browser-based UX). +- The daemon's `--token-only-mode` CLI flag forces this on for the launch, + irrespective of config. +- Desktop always passes `--token-only-mode`. +- WebView injects `X-ACECode-Token` for `fetch()` calls via a small + `web/api.js` change reading `window.__ACECODE_TOKEN__`, set in the + `index.html` template substitution. Today the token is appended only as + a query string, which works for WS but isn't sent on every fetch — we + formalize the injection. + +**Not in scope**: TLS for loopback. We continue to rely on loopback being +a trust boundary for transport; the token covers same-host process +isolation. + +--- + +## 7. Daemon CLI additions + +| Flag | Effect | Where | +|---|---|---| +| `--port ` | Override `cfg.web.port`. `0` = OS picks. | `daemon/cli.cpp` | +| `--emit-runtime-json` | Emit a single JSON line on stdout once bound, then continue normal stdout. | `daemon/worker.cpp` | +| `--die-with-parent` | POSIX: `prctl(PR_SET_PDEATHSIG, SIGTERM)` (Linux only); macOS `kqueue EVFILT_PROC`; Windows: no-op (Job Object handles it from parent side). | `daemon/worker.cpp` | +| `--token-only-mode` | Equivalent to `web.require_token_on_loopback = true` for this launch. | `daemon/cli.cpp` | + +All four are additive; no existing daemon invocations change behavior. + +--- + +## 8. Native integrations + +### 8.1 System tray + +- `src/desktop/tray.{hpp,cpp}` — uses platform APIs: + - Windows: `Shell_NotifyIcon` with custom HWND + - macOS: `NSStatusItem` + - Linux: `libappindicator` (Ubuntu) with fallback to no-op (KDE/GNOME + parity is famously bad — we don't promise it) +- Menu: `Show Window`, `New Session`, `Pause All Tools`, `Quit`. +- "Close to tray" preference (default ON on Windows/Linux, OFF on macOS + matching platform conventions). + +### 8.2 Native menu + +- macOS: full menu bar (`File`, `Edit`, `View`, `Session`, `Window`, + `Help`). +- Windows: optional menu bar (default off — we let the WebView own the + chrome) with the same actions exposed via tray + keyboard. +- Linux: same as Windows. + +### 8.3 Notifications + +- `src/desktop/notify.hpp` — single function `notify(title, body)`. + - Windows: `ToastNotificationManager` (WinRT) + - macOS: `NSUserNotificationCenter` (or `UNUserNotificationCenter` if we + drop 10.13) + - Linux: `libnotify` +- Triggered from JS via `window.aceDesktop.notify(title, body)` bound by + `webview::bind`. + +### 8.4 File pickers & drag-drop + +- File picker (`window.aceDesktop.pickFile({mode:'open'|'save'|'dir'})`) + exposes platform native dialogs. The chat UI uses this to populate + arguments for `file_read_tool` etc. +- Drag-drop: WebView already accepts HTML5 dragenter/drop events. The C++ + side intercepts the OS-level drop on Windows (necessary because WebView2 + can't get the file path on its own — only a virtual stream) and binds + `window.aceDesktop.onFileDrop = (paths) => …`. macOS WKWebView and + WebKitGTK both expose paths natively. + +### 8.5 Single-instance lock + +- A separate GUID from the daemon's. Stored at `/run/desktop.lock`. +- Second launch: send a `--show-window` IPC to the running instance + (Windows: named pipe, POSIX: unix socket at `/run/desktop.sock`) + and exit 0. + +### 8.6 Deep links (`acecode://`) + +- Out of scope for v1. Stub the protocol handler registration so we can + ship it later without re-touching installer logic. + +--- + +## 9. Build / packaging + +### 9.1 CMake + +Add target in top-level `CMakeLists.txt`: + +```cmake +if(ACECODE_BUILD_DESKTOP) + add_executable(acecode-desktop + src/desktop/main.cpp + src/desktop/daemon_supervisor.cpp + src/desktop/web_host.cpp + src/desktop/tray_${PLATFORM}.cpp + src/desktop/notify_${PLATFORM}.cpp + src/desktop/single_instance_${PLATFORM}.cpp + ) + target_link_libraries(acecode-desktop PRIVATE + acecode_testable # path utilities, runtime files, logger + webview::webview + ) +endif() +``` + +`ACECODE_BUILD_DESKTOP` defaults `OFF` so the existing CI matrix (TUI + +daemon) is unchanged. Desktop CI is a separate workflow added in step 9 +of `tasks.md`. + +### 9.2 vcpkg + +- Add `webview` to `vcpkg.json` under a feature `desktop`. +- Windows: WebView2 runtime SDK is downloaded by the `webview` port + itself. The end-user *runtime* is bootstrapped — see §9.4. +- Linux: depend on system `webkit2gtk-4.1-dev`. Document in README. +- macOS: zero extra deps (WKWebView is in the SDK). + +### 9.3 Bundle layout + +``` +acecode-desktop ← the GUI binary +acecode (or acecode.exe) ← the same daemon binary as today +share/acecode/models_dev/api.json +share/acecode/web/ ← embedded; daemon already serves it + (no need to copy unless web.static_dir is used) +``` + +### 9.4 Per-OS packaging + +- **Windows**: standalone `.exe` (acecode-desktop) + colocated `acecode.exe` + + WebView2 Evergreen Bootstrapper (auto-installs runtime if missing). + Future: MSIX. NSIS for v1. +- **macOS**: `.app` bundle, codesigned + notarized in CI. Universal + binary (x86_64 + arm64). +- **Linux**: AppImage embedding `webkit2gtk-4.1` is too large; we ship a + `.tar.gz` + `.deb` listing `libwebkit2gtk-4.1-0` as a runtime dep. Flatpak + is a follow-up. + +### 9.5 Dev iteration + +While developing the front-end, devs run `acecode daemon --foreground` in +one terminal and open the URL in a regular browser — same as today. The +desktop binary is only needed to test desktop-specific behaviors (tray, +notifications, drag-drop). Document this in `docs/desktop-dev.md`. + +--- + +## 10. Front-end deltas + +Goal: zero breaking changes to existing `web/` components. The browser UI +and the desktop UI render the same DOM. + +Additive only: + +- `web/api.js` — thin platform-detection helper: + ```js + export const isDesktop = !!window.aceDesktop; + ``` +- `window.aceDesktop` (only present in desktop builds) exposes: + - `notify(title, body)` + - `pickFile(opts)` + - `openExternal(url)` — open links in OS browser instead of inside + WebView (critical so HTTP links from chat output don't replace the SPA) + - `setBadge(n)` — Dock/taskbar badge (macOS + Windows) + - `onFileDrop` — assign a callback +- `index.html` template adds: + ```html + + ``` + populated by the daemon when serving `/index.html` to a request that has + `web.require_token_on_loopback` enabled. +- `connection.js` — read `window.__ACECODE_TOKEN__` and inject as + `X-ACECode-Token` on every `fetch`. + +The components themselves (`ace-app`, `ace-chat`, …) are untouched. + +--- + +## 11. Testing strategy + +Unit-testable in `acecode_testable` (no FTXUI, no WebView): + +- `src/desktop/runtime_files.{hpp,cpp}` — wrappers around + `/run/` reads. Already exists for the daemon side; add a + symmetric `read_runtime_state()` returning a parsed struct. + → `tests/desktop/runtime_files_test.cpp` +- `src/desktop/runtime_json_parser.{hpp,cpp}` — parses a single + `{"port":…,"token":…}` line. Pure function. + → `tests/desktop/runtime_json_parser_test.cpp` +- `src/desktop/url_builder.{hpp,cpp}` — composes + `http://127.0.0.1:/?t=` with proper escaping. + → `tests/desktop/url_builder_test.cpp` + +Not unit-testable (manual / integration matrix): + +- Spawn / SIGTERM / job-object — manual smoke per OS. +- Tray / notifications — manual smoke per OS. +- Drag-drop — manual smoke per OS. + +CI matrix additions: macOS ARM, Windows x64 — both build the desktop +target and run the unit tests but do not exercise the GUI. + +--- + +## 12. Open questions + +These are deferred but worth flagging: + +1. **Multi-window**. Per-session windows or single-window-with-sidebar? + v1 picks single-window; if users push for multi-window, the bridge in + §10 already supports it via `window.aceDesktop.openWindow(sessionId)`. +2. **Settings UI**. Today config is JSON-edited. Should desktop expose a + GUI? Recommend deferring to a follow-up change `add-settings-ui`, + shared across browser and desktop. +3. **Plugin / extension API**. WebView's `bind` mechanism could host + third-party extensions. Out of scope; flag the surface so we don't + paint into a corner. +4. **Auto-update**. Sparkle (macOS), Squirrel (Windows), AppImage update. + Separate change. +5. **Telemetry**. None today. If we add any, the desktop is a natural + surface for opt-in. Out of scope. + +--- + +## 13. Migration / rollback + +- **Migration**: none. New target, opt-in via `ACECODE_BUILD_DESKTOP`. No + config schema changes (only additive: `web.require_token_on_loopback`). +- **Rollback**: drop the `acecode-desktop` binary. The daemon CLI flags + added in §7 are additive; leaving them in is harmless. Revert + `web.require_token_on_loopback` flag if undesired. + +--- + +## 14. Summary of decisions + +| # | Decision | Why | +|---|---|---| +| D1 | `webview/webview` library | C++-native, no Rust/Node toolchain, system WebView. | +| D2 | Daemon as child process | Crash isolation, reuses `run_worker`, symmetric with browser/TUI. | +| D3 | `--reuse-running-daemon` opt-in | Power users with system-installed services. | +| D4 | Per-launch loopback token | Defense against same-host snooping; cheap. | +| D5 | `--port 0` ephemeral by default | Avoid 28080 collision in desktop deployment. | +| D6 | Single-window v1 | Avoid window-management bugs; multi-window later. | +| D7 | Native menus + tray + notifications | The whole point of "desktop" vs "tab". | +| D8 | Drop daemon when desktop crashes (OS-level) | No zombie daemons. | +| D9 | Additive front-end only | Browser UI must keep working. | +| D10 | `ACECODE_BUILD_DESKTOP=OFF` by default | Keep current CI green from day one. | diff --git a/docs/desktop-shell/proposal.md b/docs/desktop-shell/proposal.md new file mode 100644 index 000000000..2ffefe15d --- /dev/null +++ b/docs/desktop-shell/proposal.md @@ -0,0 +1,114 @@ +# add-desktop-shell — Proposal + +> **Status**: Draft +> **Owner**: TBD +> **Related changes**: `add-web-daemon` (HTTP/WS surface), `add-web-chat-ui` (front-end components) + +## What + +Ship ACECode as a native desktop application (Windows / macOS / Linux) that +embeds a system-provided WebView and reuses the existing `acecode daemon` + +`web/` front-end. The desktop binary is a thin native shell — the agent loop, +sessions, tools, and UI all stay where they are today. + +The new CLI entry point is: + +``` +acecode desktop # foreground GUI +acecode desktop --no-tray # disable system tray +acecode desktop --reuse-running-daemon +``` + +Plus a "double-click the app icon" launch path on each OS. + +## Why + +The TUI is reported as hard to operate, especially for users unfamiliar with +keyboard-driven editors: + +- Mouse interactions in FTXUI are limited (wheel scroll, shift-drag select); + click-to-position cursor, native text-selection, and copy-paste are + inconsistent across terminals (cmd.exe, Terminal.app, ConEmu, tmux). +- The tool-confirmation modal, AskUserQuestion modal, slash dropdown, and + resume picker all share the same input surface, which creates a learning + curve. +- Native affordances users expect are missing: drag-drop a file into chat, + OS file picker for `file_read_tool` arguments, OS notification when a long + bash run finishes, "always on top" / multi-monitor behavior, system tray + for background sessions. + +A web UI already exists (`web/` — 11 Custom Elements served by the daemon), +but launching it requires the user to (a) start the daemon manually and (b) +remember `http://localhost:28080`. A desktop wrapper makes the same UI a +first-class app, not a browser tab competing with bookmarks. + +## Why not Tauri / Electron + +| Option | Why it's wrong for this repo | +|---|---| +| **Electron** | Bundles Chromium (~120 MB per install) and forces an npm/Node toolchain. The current `web/` README explicitly chose hand-vendored Bootstrap to avoid npm — Electron would invalidate that decision and double the supply chain. | +| **Tauri** | Requires a Rust toolchain alongside CMake/vcpkg. Two-toolchain projects are a known maintenance tax (corrosion-rs partly fixes it but adds CMake complexity). The main payoff is a small bundle, but the bottleneck on bundle size is the daemon binary, not the shell. | +| **Wails** | Same toolchain-split problem with Go. | +| **CEF** | Heavy (Chromium copy) without Electron's ergonomics. | + +We pick **`webview/webview`** (https://github.com/webview/webview): a small +C++ header-style library that wraps the OS-native WebView component +(WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). It plugs into +the existing CMake/vcpkg flow with zero new toolchains, and the entire +desktop shell is a few hundred lines of C++. + +## Non-goals (v1) + +- **No front-end rewrite.** Components in `web/components/` stay as-is. We + do not introduce React/Vue/Svelte. Markdown rendering follows the same v1 + decision (`textContent + pre-wrap`) until the web change adopts it. +- **No daemon-less mode.** The desktop binary always speaks to a daemon + process; we do not embed the agent loop directly into the GUI process. + Crash isolation matters more than saving one process. +- **No per-session native windows.** v1 is single-window with the existing + sidebar UI for switching sessions. Multi-window is a follow-up. +- **No auto-update.** OS package managers / store / manual download for v1. + Auto-update is a separate change. +- **No custom title bar / frameless window.** We use the OS-default chrome + for v1 to avoid platform-specific HiDPI / drag-region bugs. + +## Scope summary + +What we _are_ building: + +1. New target `acecode-desktop` (C++17, links `webview` + the existing + `acecode_testable` OBJECT library for shared utilities). +2. New subcommand `acecode desktop` and a double-click bootstrapper. +3. Daemon supervision: spawn `acecode daemon --foreground --port 0 + --token-only-mode`, read the runtime files, hand the URL+token to the + WebView. +4. Native menu, system tray, OS notifications, file/folder pickers, drag & + drop, single-instance lock, deep-link handler (`acecode://`). +5. CMake packaging: Windows MSIX + standalone `.exe`, macOS `.app` + (universal binary), Linux AppImage. Each shipped with the daemon binary + colocated. + +## Risks (covered in `design.md`) + +- WebView2 Runtime not installed on older Windows 10 — bootstrapper. +- WebKitGTK 4.1 vs 6.0 ABI mismatch on Linux distros — pick 4.1 for now. +- Loopback bind being shared with other local processes — switch to + per-launch token even on loopback. +- Port collision on the fixed `28080` — switch to `--port 0` for desktop + launches and discover via runtime files. + +## Acceptance + +- `acecode desktop` opens a window showing the existing chat UI, signed in + with the same daemon backend. +- Closing the window with system tray enabled keeps the daemon and any + running agent turn alive; reopening reattaches. +- File dragged from Finder/Explorer onto the chat input inserts an absolute + path string (or attaches it via a future protocol — see `design.md` §6). +- macOS: `cmd+,` opens settings; `cmd+q` quits cleanly (daemon SIGTERM). +- Windows: tray icon present; `--no-tray` disables it; closing the only + window with tray off quits the process. +- Linux: AppImage launches on Ubuntu 22.04 / Fedora 39 with system-installed + WebKitGTK 4.1. +- Three new test files under `tests/desktop/` (pure-logic ports of process + supervision and runtime-file parsing) pass under `acecode_unit_tests`. diff --git a/docs/desktop-shell/tasks.md b/docs/desktop-shell/tasks.md new file mode 100644 index 000000000..f673edaa9 --- /dev/null +++ b/docs/desktop-shell/tasks.md @@ -0,0 +1,150 @@ +# add-desktop-shell — Tasks + +> Implementation checklist. Order matters: each phase unblocks the next. +> See `design.md` for the why behind each step. + +## Phase 1 — Daemon CLI extensions (no GUI yet) + +- [ ] **T1.1** Add `--port ` to `daemon/cli.cpp`. Treat `0` as ephemeral. + Plumb through to `Crow::App::bindaddr().port(N)`. Verify Crow reports the + bound port (`server.port()`) for `--port 0`. +- [ ] **T1.2** Add `--emit-runtime-json` to `daemon/cli.cpp`. After Crow is + bound, emit one line of JSON to stdout + (`{"port":N,"token":"…","guid":"…","data_dir":"…"}`) before any other + logs. +- [ ] **T1.3** Add `--die-with-parent` to `daemon/cli.cpp`. POSIX impl: Linux + `prctl(PR_SET_PDEATHSIG, SIGTERM)` in the child between fork/exec + paths; macOS `kqueue EVFILT_PROC` watcher thread. Windows: no-op + (Job Object handles it from the desktop side). +- [ ] **T1.4** Add `--token-only-mode` to `daemon/cli.cpp`. Sets the new + `web.require_token_on_loopback = true` for this launch only. +- [ ] **T1.5** Extend `src/web/auth.cpp::require_auth` to honor + `cfg.web.require_token_on_loopback`. Default `false` preserves current + behavior. +- [ ] **T1.6** Update `daemon/runtime_files.cpp` to write the same payload + that `--emit-runtime-json` emits (no behavior change for service mode). +- [ ] **T1.7** Tests for `--port 0`, `--emit-runtime-json`, and the new + `require_auth` branch in `tests/daemon/`. + +## Phase 2 — Desktop bridge to existing front-end (browser-testable) + +- [ ] **T2.1** Add `window.__ACECODE_TOKEN__` injection in + `web/index.html` template. Daemon substitutes the token at serve time + when `require_token_on_loopback` is true. +- [ ] **T2.2** Update `web/connection.js` and `web/api.js` to inject the + `X-ACECode-Token` header from `window.__ACECODE_TOKEN__` on every + `fetch` and to append `?token=` for WS connects. +- [ ] **T2.3** Add `web/desktop.js` (loaded only when + `window.aceDesktop` exists) with `notify`, `pickFile`, `openExternal`, + `setBadge`, `onFileDrop` callable from components. +- [ ] **T2.4** Wire `ace-chat` to call `window.aceDesktop.openExternal(url)` + for external links instead of the default in-WebView navigation. +- [ ] **T2.5** Smoke test: load the page in a regular browser with + `window.aceDesktop` undefined → all features still work, no JS errors. + +## Phase 3 — Desktop binary skeleton + +- [ ] **T3.1** Add `vcpkg.json` feature `desktop` with `webview` dependency. +- [ ] **T3.2** Add `cmake/acecode_desktop.cmake` defining the + `acecode-desktop` target. Gate behind `ACECODE_BUILD_DESKTOP` (default + OFF). +- [ ] **T3.3** Create `src/desktop/main.cpp` — minimal: parse argv, log to + `/logs/desktop.log`, exit. +- [ ] **T3.4** Create `src/desktop/runtime_json_parser.{hpp,cpp}` — pure + function: parse `{"port":…,"token":…}` line, return struct or error. +- [ ] **T3.5** Create `src/desktop/url_builder.{hpp,cpp}` — pure function: + compose URL with proper percent-encoding of token. +- [ ] **T3.6** Tests for T3.4 and T3.5 in `tests/desktop/`. + +## Phase 4 — Daemon supervision + +- [ ] **T4.1** `src/desktop/daemon_supervisor.{hpp,cpp}` — spawn child + process with stdout pipe. Per-OS process API (`posix_spawn` on POSIX, + `CreateProcess` on Windows). +- [ ] **T4.2** Reader thread: line-buffer stdout, parse first JSON line + via T3.4, then push subsequent lines into a 1024-entry ring buffer. +- [ ] **T4.3** Windows-only: assign child to a Job Object with + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. +- [ ] **T4.4** Clean shutdown: SIGTERM (POSIX) or `CTRL_BREAK_EVENT` + (Windows), 5s grace, then SIGKILL. +- [ ] **T4.5** Heartbeat poller: every 5s read `/run/heartbeat`, + surface "Daemon unresponsive" if `now - timestamp_ms > 15s`. +- [ ] **T4.6** Implement `--reuse-running-daemon`: skip spawn if heartbeat + is fresh. Fall through to spawn if stale. + +## Phase 5 — WebView host + +- [ ] **T5.1** `src/desktop/web_host.{hpp,cpp}` — wrap `webview::webview`. + API: `set_url`, `bind(name, fn)`, `run`, `terminate`. +- [ ] **T5.2** On startup: wait for daemon JSON line → compose URL → + `set_url` → `run`. +- [ ] **T5.3** Bind native bridges: `notify`, `pickFile`, `openExternal`, + `setBadge`, `onFileDrop`. +- [ ] **T5.4** Window state persistence: remember position, size, + maximized state in `/desktop_state.json`. Restore on launch. + +## Phase 6 — Native integrations (per-OS) + +- [ ] **T6.1** Tray icon abstraction `src/desktop/tray.hpp` + per-OS + implementations (`tray_win.cpp`, `tray_mac.mm`, `tray_linux.cpp`). +- [ ] **T6.2** Native notification abstraction + `src/desktop/notify.hpp` + per-OS implementations. +- [ ] **T6.3** File picker abstraction `src/desktop/file_picker.hpp` + + per-OS implementations. +- [ ] **T6.4** Single-instance lock `src/desktop/single_instance.hpp` + + per-OS implementations (named pipe / unix socket). +- [ ] **T6.5** Drag-drop: WebView's HTML5 drop fires for content; on + Windows additionally hook `WM_DROPFILES` on the host HWND to recover + file paths and forward to `window.aceDesktop.onFileDrop`. + +## Phase 7 — CLI integration + +- [ ] **T7.1** Add `desktop` subcommand to top-level CLI + (`main.cpp` / `cli.cpp`). Flags: `--no-tray`, `--reuse-running-daemon`, + `--debug` (open WebView devtools). +- [ ] **T7.2** Default subcommand on double-click: detect "no TTY + no + args" via `isatty(stdout)` and re-exec as `acecode desktop` only if + `acecode-desktop` is colocated. (Optional v1; otherwise rely on the + packaged shortcut launching `acecode-desktop` directly.) + +## Phase 8 — Docs + +- [ ] **T8.1** New `docs/desktop.md` — user manual: install, launch, + tray behavior, troubleshooting. +- [ ] **T8.2** New `docs/desktop-dev.md` — how to develop without + rebuilding the desktop binary every iteration. +- [ ] **T8.3** Update `CLAUDE.md` "Architecture" section: add + `src/desktop/` description, new daemon CLI flags, the bridge surface. +- [ ] **T8.4** Update `README.md` and `README_CN.md` with desktop + install instructions. + +## Phase 9 — Packaging & CI + +- [ ] **T9.1** Windows: NSIS installer script bundling + `acecode-desktop.exe` + `acecode.exe` + WebView2 bootstrapper. +- [ ] **T9.2** macOS: `.app` bundle layout, codesign + notarize steps in + CI. Universal binary via `lipo`. +- [ ] **T9.3** Linux: `.deb` package + AppImage build. Document + `libwebkit2gtk-4.1-0` runtime dep. +- [ ] **T9.4** Extend `.github/workflows/package.yml`: add + `acecode-desktop` build matrix entries (Windows x64, macOS x64+arm64, + Linux x64+arm64). +- [ ] **T9.5** Smoke job: launch `acecode-desktop --headless-smoke` (a new + mode that boots, waits for daemon JSON line, navigates, and exits 0) + to verify end-to-end on each OS. + +## Phase 10 — Polish + +- [ ] **T10.1** Dock/taskbar badge for unread tool-completion events. +- [ ] **T10.2** Global "Pause All Tools" tray action sends + `POST /api/sessions/:id/abort` for every active session. +- [ ] **T10.3** Light/dark mode follows OS theme via `prefers-color-scheme` + + a desktop-side override in `desktop_state.json`. +- [ ] **T10.4** First-run onboarding: if `~/.acecode/config.json` is + missing, route to the existing `acecode configure` wizard but rendered + in the WebView. + +--- + +**Apply order**: Phase 1 → 2 in parallel (independent), then 3 → 4 → 5 +sequential, then 6/7/8 in parallel, then 9, then 10 (post-launch).