SPEC-0010 Design: Desktop shell
Contextβ
The performance epic changed the calculus.
SPEC-0008 took the web UI from 1.8β2.9 s renders
to < 300 ms TTFB and 19 ms boosted navigations on the reference archive β
the app now feels native, so wrapping it in a native window stops being
lipstick and starts being honest. The owner asked for a real desktop app (and
about Proton Native specifically;
π ADR-0017 addresses it by name and
rejects it as unmaintained and wrong-stack). Today the closest thing is
serve --open, which launches a browser tab and leaves a terminal running.
The shell also gives the Connect story a home: SPEC-0011 (device sync) needs a place to show a pairing QR and MCP connection details, and that place should exist in browser mode too β it's a web page, not a desktop feature.
Goals / Non-Goalsβ
Goals
- A native desktop app (macOS first; Linux/Windows via the same code) hosting the existing UI unchanged.
- Core stays
CGO_ENABLED=0(π ADR-0013); cgo is quarantined incmd/msgbrowse-desktop. - A
/settingsConnect page served by the normal web app, with server-rendered QR (payload per SPEC-0011). - CI-built per-OS artifacts.
Non-Goals
- Mobile apps (SPEC-0011 pairs desktop/server nodes; no mobile shell).
- Auto-update.
- Code signing / macOS notarization in v1 (deferred per π ADR-0017).
- Wails JS bindings / a second frontend β the UI remains server-rendered HTMX over HTTP.
Decisionsβ
Wails v2 as the shellβ
- Choice: Wails v2 wrapping the system webview around the embedded server, per π ADR-0017.
- Rationale: Go-native, system webview (small artifacts), and batteries β menus, dock, packaging β that we'd otherwise hand-roll.
- Alternatives: Electron (Chromium+Node bundle β rejected), Tauri (Rust
toolchain + Go sidecar supervision β rejected), webview/webview_go (kept as
the documented minimal fallback; identical architecture, fewer batteries),
Proton Native (unmaintained, Reactβlibui, wrong stack β rejected),
serve --openstatus quo (rejected as the end state). Full analysis in π ADR-0017.
Menubar residency via a systray companion libraryβ
- Choice: implement the "Menubar residency" and "Menubar quick menu"
requirements with a maintained Go systray library (fyne-io/systray, the
maintained fork of getlantern/systray) running alongside Wails v2 in the
desktop process; window close is intercepted (Wails
OnBeforeClose) to hide instead of quit. - Rationale: Wails v2 has no first-class systray API (it arrives as a core feature in v3, still pre-stable). fyne-io/systray is cross-platform (macOS/Linux/Windows), cgo on macOS β acceptable, the desktop target is already cgo-isolated β and its menu-item model covers the quick menu (dynamic retitle for the "Copied" acknowledgment, enable/disable for degraded states). The MCP status line reads the embedded server's health directly in-process; clipboard writes use the shell's native clipboard API so they work with the window closed.
- Alternatives: waiting for Wails v3 systray (blocks a wanted feature on an alpha); a second helper process for the tray (IPC complexity for no gain); menubar-only via macOS-specific NSStatusItem bindings (loses Linux/Windows parity).
- Mechanics (as built, #118):
- Loop coexistence:
systray.RunWithExternalLoopβ the library's documented mode for living beside another toolkit's main loop. The returnedstart()runs on the main goroutine beforewails.Run(on macOS that is the main thread, where the NSStatusItem must be created; subsequent menu updates dispatch onto the NSApplication run loop Wails owns; on Linux the backend is pure-Go D-Bus StatusNotifierItem, fully independent of Wails' GTK loop),end()afterwails.Runreturns. - Close-to-tray: built on Wails' native
HideWindowOnClose, notOnBeforeCloseas this section originally sketched. Wails v2.12's platform code funnels the window-close button and every explicit quit path (Cmd+Q /applicationShouldTerminate,runtime.Quit, app-menu Quit) into the sameOnBeforeClosecallback, so hiding there would swallow Cmd+Q β quitting must stay explicit and working (SPEC-0010 "Menubar residency").HideWindowOnClosehides on the close button at the platform layer (hideOnClosein the mac WindowDelegate, GTK delete-event) without entering the quit flow, leaving every quit path an honest quit. - MCP endpoint: the MCP streamable-HTTP handler mounts at
/mcpon the embedded server's own loopback listener (aweb.ServeHandlerhook +mcp.HTTPHandleraccessor) β the SPEC-0010 bind surface allows no listener beyond the embedded server, so the desktop's MCP endpoint is a path, not a port. The status line pollsGET /status(as an HTMX partial) with a 2 s budget every 15 s for running/degraded; activation copies the endpoint URL, and Copy MCP Config copies the JSON block built byinternal/mcp.ClientConfigJSONβ the same builder the/settingspage consumes when #100 lands. The copied URL dies with the ephemeral port on relaunch (the "Ephemeral URLs" risk below, accepted). - Hidden launch: menubar-only launch is behind
-hidden(WailsStartHidden), not the default, until the status item is validated on macOS hardware β a default-hidden launch with a broken tray would strand the user with no window and no tray. The packaging story revisits the default. - Headless/Linux degrade: the tray needs a StatusNotifier host; without one fyne-io/systray logs the D-Bus failure and menu calls no-op, and the shell panic-guards tray start/stop so a missing tray can never take down the app or its graceful shutdown.
- Startup race (#114 review): quit requests (signals, server death)
arriving before Wails
OnStartupare latched in a pure state machine (internal/shellstate) and replayed once the runtime context exists, instead of being dropped.
- Loop coexistence:
Loopback HTTP on an ephemeral port, not the Wails asset handlerβ
- Choice: the desktop process starts
internal/web's realnet/httpserver on127.0.0.1:0, reads the bound port off the listener, and points the webview athttp://127.0.0.1:<port>/. - Rationale: zero divergence from the served app. The webview traverses
the same listener, middleware, gzip, ETags, security headers, and HTMX
behavior a browser does β every SPEC-0008 test and every future handler
works in both modes without a second code path. An ephemeral port also
can't collide with a concurrently running
msgbrowse serveon 8787. - Alternatives: Wails' AssetServer can dispatch requests to an
http.Handlerin-process with no TCP listener at all β attractive (no port, no socket), but it bypasses the real server path: middleware wiring,Accept-Encodingnegotiation, and absolute-URL generation (the Connect page must display real URLs a separate MCP client can reach) would all diverge from browser mode. Rejected for v1; revisit only if the loopback listener itself becomes a problem. - Mechanics (as built): Wails v2 has no first-class "open this external
URL" option, so the asset handler serves exactly one thing: a static
bootstrap trampoline (
<meta http-equiv="refresh">to the discoveredhttp://127.0.0.1:<port>/, no scripts, no styles,default-src 'none'CSP). The webview loads it once from the Wails scheme and immediately navigates to the embedded server; every request after that is ordinary loopback HTTP throughinternal/web. On the server side,web.(*Server).Runis split intoListen(bind, discover port) andServe(block until context cancel, thenhttp.Server.Shutdown) soserveand the shell share one lifecycle path; the shell wraps them incmd/msgbrowse-desktop/internal/embedded, a pure-Go package that is unit-tested headless withCGO_ENABLED=0.
Server-side QR renderingβ
- Choice: render the pairing QR on the server as a PNG
data:URI embedded in the/settingstemplate, using a pure-Go QR library (skip2/go-qrcodeis the expected pick β a new dependency, noted and accepted; it must not drag in cgo). - Rationale: the strict CSP (π ADR-0010:
default-src 'none',img-src 'self' data:) already permits data-URI images and forbids inline script; a client-side QR library would add vendored JS for something the server can do in one function call, and server-side rendering works identically in browser mode. The QR payload is opaque to this spec β SPEC-0011 defines it;/settingsjust encodes the bytes it's handed. - Alternatives: client-side JS QR generation (more moving parts, worse
CSP story β rejected); a separate
/settings/qr.pngroute (works, but a data URI keeps the page self-contained and adds no route β not needed unless payload size ever makes inline data URIs unwieldy).
Build isolation: nested module + tags + CI matrixβ
- Choice: all Wails/cgo code sits in
cmd/msgbrowse-desktop, which is its own Go module (cmd/msgbrowse-desktop/go.mod, with areplace github.com/joestump/msgbrowse => ../..directive) and whose files carry thedesktopbuild tag; desktop binaries are built only by the desktop Makefile targets and the GitHub Actions matrix (macOS β.app/dmg, Ubuntu + WebKit2GTK β Linux, Windows β.exe). - Rationale: webview shells cannot cross-compile, and π ADR-0013's
CGO_ENABLED=0core is sacred. Build tags alone gate compilation but not dependency resolution:go mod tidyis build-tag-agnostic, so Wails' large dependency tree would have landed in the rootgo.mod/go.sumeven though no core build ever compiles it. The nested module keeps the core module file byte-identical to a desktop-free repo; the tag gate additionally guarantees no defaultgo buildinside the shell directory ever attempts cgo../...at the repository root does not descend into a nested module, soCGO_ENABLED=0 go build ./...andmake checkstay pure and fast with no webview toolchain installed. - Costs accepted: the
replacedirective means the shell always builds against the sibling checkout (fine β it is never published separately), and the shell module pins its own dependency versions (kept aligned viago mod tidyin each module). - Alternatives: tags-only in the root module β rejected because of the go.mod bloat above; building desktop artifacts locally only (no reproducible releases) β rejected.
- Build plumbing (as built):
make desktop-linuxbuilds the shell intobin/msgbrowse-desktopwith-tags desktop,production,webkit2_41;make desktop-testruns the shell module's pure-Go headless tests withCGO_ENABLED=0. Neither is a prerequisite of any core target. On Ubuntu 24.04 (ie01) Wails v2.12 linkslibwebkit2gtk-4.1.so.0via thewebkit2_41tag afterapt-get install libgtk-3-dev libwebkit2gtk-4.1-dev pkg-config; distros still shipping webkit2gtk-4.0 drop that tag (make desktop-linux DESKTOP_TAGS=desktop,production). macOS and Windows targets arrive with the CI-matrix story.
Architectureβ
One store, one web package, two front doors. The desktop process is the Wails
runtime plus the same server serve runs; the webview is just a browser we
control the lifecycle of. /settings is ordinary internal/web surface area
β template, handler, QR helper β with nothing desktop-conditional in it.
Shutdown path: window close (or menu quit) triggers the Wails lifecycle hook,
which cancels the server context β http.Server.Shutdown drains in-flight
requests β store closes β process exits. The same context wiring serve uses
for SIGINT applies, so there is one shutdown code path, not two.
Risks / Trade-offsβ
- Webview version drift across OSes. WKWebView (macOS), WebView2
(Chromium, Windows), and WebKit2GTK (Linux) advance on different cadences;
features the UI leans on (e.g. SPEC-0008's
content-visibilitycontainment) may lag on one engine. Mitigation: the UI already targets evergreen browsers and degrades gracefully; the desktop matrix becomes the test matrix. - cgo CI complexity. Three OS runners, platform toolchains (Xcode CLT,
webkit2gtk dev packages, WebView2 SDK), slower release builds, and cache
management β all new surface compared to the single
CGO_ENABLED=0build. Mitigation: matrix jobs are release-scoped and never gate the core check. - WebKit2GTK availability on Linux. Distros are split across webkit2gtk 4.0/4.1 sonames and Wails v2 pins against a specific one; some minimal installs ship neither. Mitigation: document the package prerequisite; browser mode remains the universal fallback on Linux.
- Ephemeral URLs. The desktop app's origin changes every launch, so a URL copied out of the desktop window dies on relaunch. Acceptable: sharing URLs is a browser-mode workflow; the Connect page displays the durable endpoints.
- Two instances. Nothing prevents launching the desktop app twice (two servers, two ephemeral ports, one SQLite file); SQLite locking arbitrates. Single-instance enforcement is deliberately not in v1.
Migration Planβ
No schema, config, or data migrations β the store, config keys, and archives are untouched, and browser mode behaves identically before and after.
/settingslands first, desktop-free: template + handler + QR helper ininternal/web, the pure-Go QR dependency, and tests (CSP header assertions, data-URI rendering, copy-block content). Ships and is useful in browser mode immediately; blocks only on SPEC-0011 for the real QR payload (a placeholder/absent-state renders until pairing config exists).cmd/msgbrowse-desktop: tag-gated Wails v2 shell β embedded server on127.0.0.1:0, window pointed at it, menu/quit/dock wiring, graceful shutdown through the shared context path.- Build plumbing: Makefile targets for the desktop build (names TBD β
see Open Questions) and GitHub Actions matrix jobs (macOS/Ubuntu/Windows)
producing release artifacts; core
checkjob untouched. - Docs: install instructions per OS, the WebKit2GTK prerequisite on Linux, and the unsigned-artifact caveat on macOS (right-click-Open) until signing lands.
Rollback at any step is deletion: nothing in the core depends on the shell.
Open Questionsβ
- Flag and target names. Partially resolved by the shell story: the
desktop command takes a single
-configflag (same default search path as the CLI), and the Makefile targets aredesktop-linux/desktop-test(per-OS build targets for macOS/Windows land with the CI-matrix story). The menubar story added-hidden(start menubar-only, window on demand); whether hidden becomes the default launch mode is deferred to the packaging story after macOS validation. Further flags (--open-style conventions) remain TBD. - macOS signing/notarization timeline. Deferred per π ADR-0017; needs an Apple Developer ID and CI notary step. When does unsigned stop being acceptable β first external user?
- Wails v3. Currently pre-stable; it reworks the application/window API. Adopt on stable release or skip v2 hardening work that v3 would discard?
- Open-at-login. Still MAY; menubar residency (now a MUST, owner request 2026-07-03) makes it the natural companion. The menubar story (#118) deliberately did not take it β login-item registration is per-OS packaging surface (launchd plist / XDG autostart / registry), which belongs with the CI-matrix packaging story.
- Settings page scope creep.
/settingsstarts as Connect (MCP + QR); whether runtime-editable settings (theme, roots) ever live there is a separate future spec.
Related Artifactsβ
Direct relationships declared in YAML frontmatter (per the SDD plugin's ADR-0023 / SPEC-0018 frontmatter-graph conventions). Run /sdd:graph chain SPEC-0010 for the transitive view.