iterion

ADR-002: Desktop studio hosted via Wails AssetServer handler

Context

The desktop binary (iterion-desktop) is a thin native shell around the existing CLI studio server (pkg/server). Its value proposition lives in the Wails IPC bindings (window.go.main.App.*): project switcher, secrets/keychain, CLI detection, auto-update — none of which the studio SPA can do on its own.

The original design (Phase 1 of the desktop plan) loaded a tiny embedded “bootstrap stub” in the Wails AssetServer, called GetServerURL / GetSessionToken on window.go.main.App, and navigated the WebView to the embedded loopback HTTP server so the studio SPA loaded from the server directly.

That design has a fatal architectural flaw discovered in iteration 10 of the desktop feature review:

  1. Wails injects /wails/runtime.js and /wails/ipc.js — the scripts that define window.go, window.runtime, window.WailsInvokeonly into HTML responses served by the AssetServer itself (vendor/.../assetserver.go:200-220).
  2. Custom URL schemes (wails://) and the wails.localhost host are the only origins the AssetServer intercepts; everything else, including http://127.0.0.1:<random>, is passed through to the WebView’s default network stack (vendor/.../windows/frontend.go:652-658).
  3. BindingsAllowedOrigins was unset, so the message dispatcher (vendor/.../originvalidator/originValidator.go:46-58) would have blocked any binding call from the localhost origin even if the runtime had somehow loaded.
  4. Concretely: after the bootstrap-stub redirected the window to http://127.0.0.1:<port>/, window.go was undefined, isDesktop() returned false, and the SPA quietly fell back to browser mode — every desktop-only view (<Welcome>, <Settings>, <ProjectSwitcher>, <MissingCLIBanner>) was un-mounted, every native-menu emit silently discarded, every desktop.* call rejected with “Not available in browser mode”.

Tests missed this because studio/src/__tests__/desktopBridge.test.ts stubs window.go directly, and bindings_project_test.go uses a fake recordingServer plus a stubbed windowReloader. Neither exercises the real cross-origin runtime injection that Wails actually performs at runtime.

Decision

The Wails AssetServer hosts the studio SPA through a custom http.Handler (cmd/iterion-desktop/asset_proxy.go). The WebView’s main origin stays on the AssetServer URL (wails://wails/ on Mac/Linux, http://wails.localhost/ on Windows) for the lifetime of the app, and:

The bootstrap-stub directory (cmd/iterion-desktop/frontend-stub/) and the embed.go that wired it up are deleted — they’re obsolete with the proxy approach.

Alternatives considered

1. Inject Wails runtime + IPC scripts via the local server

Embed runtime_prod_desktop.js and ipc.js from vendor/.../runtime/ into the desktop binary, serve them at /wails/runtime.js + /wails/ipc.js from pkg/server, and add <script> tags to studio/index.html.

Rejected: the runtime needs the window.wailsbindings JSON to populate window.go.<package>.<class>.<method>. That JSON is generated by Wails internals during reflection on the Bind list; it’s not exposed through any public API. Without it, window.go would be {} even with the runtime loaded. Reverse-engineering the bindings emitter into the desktop binary would re-implement substantial Wails machinery and have to track every Wails bump.

2. Move all desktop functionality to same-origin /api/* endpoints

Refactor every Wails binding (ListProjects, SetSecret, DetectExternalCLIs, CheckForUpdate, …) into HTTP handlers on pkg/server, drop Bind: []interface{}{app}, and remove the dependency on Wails IPC entirely.

Rejected (for v1): large blast radius — every binding, every desktop test, every frontend caller would change. This is a viable v2 if Wails IPC ever proves limiting (single-fenced WebView, reflection startup cost), but the proxy approach lets v1 keep the existing binding surface intact.

3. Allow-list the localhost origin via BindingsAllowedOrigins, keep

the redirect

Set BindingsAllowedOrigins: "http://127.0.0.1:*" and have the studio’s index.html fetch + execute /wails/runtime.js + /wails/ipc.js cross-origin from the AssetServer.

Rejected: the wails:// scheme on Mac/Linux is custom and WebKit disallows cross-scheme <script> loads from http://. Even on Windows (http://wails.localhost), the runtime.js served by the AssetServer includes the dynamically-generated window.wailsbindings = '...' prefix with the bindings JSON; embedding the static vendor/ JS file alone wouldn’t include that prefix, so window.go would still be empty.

Consequences

Positive

Negative

Operational