The desktop build is a thin native shell wrapped around the existing
pkg/server HTTP server and embedded SPA. It adds multi-project state,
OS-keychain credentials, signed auto-update, and native UX (menus, window
state, single-instance, dialogs).
Iterion.app / iterion-desktop.exe / Iterion.AppImage
└─ cmd/iterion-desktop (Wails v2 host, build tag `desktop`)
├─ Wails Window — webview pinned to AssetServer start URL
│ wails://wails/ (Mac/Linux) or http://wails.localhost/ (Win)
├─ Wails AssetServer (asset_proxy.go)
│ ├─ /wails/runtime.js, /wails/ipc.js (Wails-injected, gives
│ │ the SPA window.go.main.App.* and window.runtime.*)
│ ├─ /, /assets/* SPA/static from the GUI binary's embedded
│ │ pkg/server.StaticFS
│ └─ /api/* reverse-proxy → selected loopback server
├─ Selected loopback server (default: per-project headless daemon;
│ fallback/opt-out: in-process ServerHost via cli.RunStudio)
│ ├─ /api/* REST (proxied via AssetServer)
│ └─ /api/ws/runs/* WebSocket runs (DIRECT — Wails AssetServer
│ returns 501 on WS upgrade)
├─ Wails Bindings (Go ↔ JS — reachable because SPA loads on
│ AssetServer origin where /wails/runtime.js was injected)
│ ListProjects / SwitchProject / AddProject / RemoveProject
│ GetSecret… / SetSecret / DeleteSecret
│ DetectExternalCLIs / IsFirstRunPending / MarkFirstRunDone
│ CheckForUpdate / DownloadAndApplyUpdate
│ OpenExternal / RevealInFinder
│ GetServerURL / GetSessionToken (SPA WS helper; local desktop
│ returns an empty token and dials ws://127.0.0.1:<port>/api/ws...)
├─ Native menus + window state persistence
└─ Single-instance lock + IPC for "focus existing"
The desktop is a workspace, not a single-connection window: several connections — local projects and remote cloud instances — are open and LIVE at once, each in its own pane, shown as tabs or split side by side. Connecting to the cloud no longer hides the local runs.
The constraint this lifts: the studio SPA is a single-origin app that
assumes ONE backend (module-level /api base, one WebSocket target, one
wouter route tree off window.location). Rather than thread a
connection id through every data hook, each pane is an iframe — its
own JS realm, so its /api base, react-query cache, WS client and router
are naturally independent with almost no change to the data layer.
Wails main frame = WorkspaceShell (owns ALL native window.go bindings)
├─ tab bar / split (OpenConnection / CloseConnection / GetOpenConnections)
└─ one <iframe src="/x/<connID>/"> per open connection
└─ the SAME studio bundle, scoped by an injected
window.__ITERION_SCOPE__ = "/x/<connID>"
Two rules make it work:
A pane never calls window.go. Wails evaluates a binding’s result
callback into the MAIN frame, so an IPC call from an iframe would hang.
isDesktop() therefore returns false inside a scoped pane; the pane
behaves like browser-mode against its demuxed /x/<id>/api, and reaches
everything it needs over HTTP through the demux proxy.
The asset proxy demultiplexes /x/<connID>/* (asset_proxy.go
serveScoped) against a registry of open connections
(connections.go):
/x/<id>/api/… → the connection’s backend (loopback daemon, or
cloud with an injected Bearer + refresh-on-401), path rewritten to
/api/…./x/<id>/_ws/info → { ws_base, needs_ticket } so the pane can dial
its WebSocket directly at the backend (WS can’t cross the Wails
asset origin, which 501s upgrades)./x/<id>/_ws/ticket→ a single-use cloud WS ticket (empty for local)./x/<id>/… (else) → the SPA index.html with the scope injected.
Shared /assets/* are referenced at the root and served once, unscoped.The registry (App.conns) holds one activeConn per open connection —
local daemons are shared and kept alive across close (in-flight runs
survive); each cloud connection carries its own token jar + background
refresh loop. The legacy single-connection fields (serverURL,
cloudJar) still drive the unscoped /api/* path for plain browser mode.
Client-side: lib/scope.ts reads the injected
scope; apiBase() prefixes it onto /api (one choke point in
api/client.ts apiRequest also scopes any
literal /api/… path); resolveScopedWsUrl() resolves the WS base +
ticket over the _ws/* endpoints; and main.tsx
branches on isWailsHosted() && !isScopedPane() (synchronous, stable at
first paint) to render the WorkspaceShell
in the main frame vs. the normal App (under a wouter base of the
scope) in a pane.
Auth in a cloud pane is desktop-owned: the demux strips the request Cookie
and injects a Bearer refreshed off the OS-held jar, so a pane never runs its
own /auth/refresh (it can’t — no cookie); a 401 that reaches the pane means
the desktop’s refresh already failed, and recovery is driven from the shell
(cloud:auth-expired → re-login → reload). The Browser-Live (CDP) pane
resolves its WS through the same _ws/* path as every other pane, so it
works for a local pane; a cloud CDP pane additionally needs the CDP endpoint
to accept the ?ticket= WS credential (follow-up).
127.0.0.1:<random> server PLUS Wails AssetServer reverse-proxyThe naive design — load the SPA directly on http://127.0.0.1:<port>/ —
fails because Wails injects /wails/runtime.js and /wails/ipc.js ONLY
into HTML responses served by its AssetServer. A page on a different
origin gets no runtime, so window.go.main.App.* is undefined,
isDesktop() returns false, and every desktop view silently un-mounts
into “browser mode”. (See ADR-002
for the full investigation that surfaced this in iteration 10.)
The current design solves this by hosting the studio SPA via the Wails
AssetServer’s Handler (cmd/iterion-desktop/asset_proxy.go), so the
WebView’s main origin stays on the AssetServer URL for the lifetime of
the app. The handler serves every non-/api SPA/static request from the
GUI binary’s own pkg/server.StaticFS embed; Wails injects the runtime
into the handler’s HTML response; bindings remain reachable; and only
/api/* HTTP requests are reverse-proxied to the selected loopback
server.
WebSocket upgrades (/api/ws, /api/ws/runs/*) cannot flow through
the AssetServer handler — Wails’ AssetServer rejects WS with 501 by
design — so the SPA dials them directly at
ws://127.0.0.1:<port>/api/ws.... In current local desktop builds
GetSessionToken returns an empty string for SPA compatibility, so the
WS dialer omits any token query parameter. This is the only surface where
the SPA touches the loopback origin directly; normal page/static traffic
is served by the AssetServer handler from the GUI embed, and REST traffic
stays on the AssetServer origin while being proxied to /api/*. We keep
a real loopback server (rather than running everything inside the
AssetServer) because:
pkg/server stays 1:1 between CLI (iterion studio) and desktop;DisableAuth=true path as iterion studio, while Origin
checks still protect browser-initiated writes and WS upgrades.By default the desktop host uses per-project headless daemons. On startup
and project switch it finds or launches iterion-desktop --server-only
--project=<dir> and points the AssetServer handler’s /api/* proxy and
direct WS URL at that daemon. Multiple project daemons may coexist; when
the GUI switches projects it attaches to or spawns the new project’s
daemon and leaves the previous daemon alive so background runs can
continue.
The in-process ServerHost path in cmd/iterion-desktop/server_host.go
is now an escape hatch: set ITERION_DESKTOP_ATTACH_DAEMON=0 (also
false, no, or off) to opt out, or rely on it as the fallback when
daemon attach/spawn fails. That path starts cli.RunStudio on
127.0.0.1 with Port=-1 and NoBrowser=true.
Both the default headless daemon and the in-process fallback use
cli.RunStudio, which constructs pkg/server.Config with
DisableAuth=true. Local desktop mode therefore does not mint a
per-launch session token and does not install cookie/query-token
middleware. The AssetServer handler proxies only /api/* HTTP requests to
the loopback server, forces Host to the loopback target, and rewrites
Origin to that loopback origin; it does not attach or strip desktop
session cookies. SPA/static requests never reach the loopback server in
current builds; they are served from the GUI embed.
GetSessionToken remains exposed on the Wails binding because the SPA’s
desktop WS helper calls it together with GetServerURL. Today it returns
"", and the SPA skips adding a token parameter when the value is empty.
If a future hosted-auth desktop flow wires a real token into this binding,
that token handling should be documented with the auth implementation that
consumes it.
System-tray icons require platform-specific code Wails v2 doesn’t supply out-of-the-box. Closing the window leaves the app in the dock/taskbar (macOS standard) or terminates the process (Win/Linux). Tray is on the v2 backlog.
V1 ships unsigned binaries (Gatekeeper / SmartScreen will warn the first time). The auto-updater ALWAYS verifies an embedded Ed25519 public key against signed manifests + artefacts, regardless of OS code-signing. This is the trust anchor for v1 and remains valid when OS signing is added later.
The Wails-importing files carry //go:build desktop. go test ./...
exercises the desktop package’s testable subset (config, external_cli,
path_fix non-darwin) without requiring the Wails CLI or its CGO deps.
Producing the real binary uses wails build -tags desktop (which also
flips the right CGO/cross flags).
| Path | Purpose |
|---|---|
cmd/iterion-desktop/main.go |
Wails entry (desktop tag) |
cmd/iterion-desktop/main_stub.go |
Non-desktop fallback (prints usage) |
cmd/iterion-desktop/app.go |
Lifecycle (OnStartup/OnShutdown) |
cmd/iterion-desktop/bindings.go |
JS-callable methods |
cmd/iterion-desktop/menu.go |
Native menus |
cmd/iterion-desktop/window_state.go |
Persisted geometry |
cmd/iterion-desktop/single_instance.go + _unix.go + _windows.go |
Single-instance + IPC |
cmd/iterion-desktop/server_host.go |
In-process cli.RunStudio fallback when daemon attach is disabled or fails |
cmd/iterion-desktop/config.go |
User config (no build tag — testable) |
cmd/iterion-desktop/keychain.go |
go-keyring wrapper |
cmd/iterion-desktop/external_cli.go |
claude/codex/git detection |
cmd/iterion-desktop/path_fix_darwin.go |
macOS PATH fix |
cmd/iterion-desktop/path_fix_other.go |
No-op on non-darwin |
cmd/iterion-desktop/updater*.go |
Ed25519-signed auto-update |
cmd/iterion-desktop/asset_proxy.go |
Wails AssetServer handler: GUI-embedded SPA/static plus /api/* reverse-proxy to the selected loopback server (see ADR-002) |
studio/src/lib/desktopBridge.ts |
Typed window.go.main.App.* wrappers |
studio/src/hooks/useDesktop.ts |
React hook for desktop state |
studio/src/views/Welcome/ |
First-run wizard |
studio/src/views/Settings/ |
API keys / projects / updates / about |
studio/src/views/ProjectSwitcher/ |
Cmd+P modal |
The iterion CLI is unchanged. iterion studio still listens on 4891
with the same local DisableAuth=true behaviour — the CLI flag flows are untouched. Two binaries
ship from the same module: iterion (Cobra CLI) and iterion-desktop
(Wails app). Both share pkg/cli, pkg/server, the SPA, and the run
store on disk.