Compass Tauri desktop shell — design
Status: Superseded by compass-native-app/design.md
Design for the Compass desktop shell (SEA-1022): the thin native app that hosts
the UI webview and connects it to the Compass daemon. Companion to
compass.md §7.1–§7.3 and the transport spec
../../specs/product/compass.md.
Problem / Intent
Section titled “Problem / Intent”The SolidJS UI (apps/ui) today runs only in a dev browser against the daemon’s
opt-in dev-loopback TCP endpoint. The shipped path needs a native desktop shell
that hosts the webview and reaches the daemon over its owner-only Unix socket
— no browser, no daemon-exposed TCP port (§7.1). The shell is thin: window,
daemon launch/supervision, a webview↔daemon bridge, and loading the UI. It holds
no backend logic, so a compromised agent finds no privileged surface in it
(§7.1, §7.3).
This covers the v1 thin slice. Native affordances (tray, notifications, deep links) and packaging (signed installer, auto-update) are deferred to follow-ups.
Approach
Section titled “Approach”Webview → daemon transport
Section titled “Webview → daemon transport”A WebView’s fetch/XHR speaks only HTTP(S) and cannot dial AF_UNIX, so a shim
always bridges the webview to the daemon’s socket. The choice is load-bearing
because SubscribeEvents is server-streaming and is the daemon’s entire push
channel (§7.2), so the bridge must stream incrementally.
Chosen: reuse the gRPC-Web transport with a fetch that rides Tauri IPC.
@connectrpc/connect-web’s createGrpcWebTransport accepts a fetch
override. The shell passes a fetch that serializes the request through a
Tauri invoke and returns a Response whose body is a ReadableStream fed by
a Tauri Channel (tauri::ipc::Channel — ordered binary chunks to a JS
onmessage, purpose-built for Rust→JS streaming). A Rust command in the shell
dials the daemon’s UDS (native gRPC) and pumps the gRPC-Web response frames back
over the Channel. All gRPC-Web framing/parsing/streaming stays in the library —
the shell supplies only bytes-in/bytes-out plumbing.
@compass/clientis built for this: it exportscreateCompassClient(transport)and re-exportsTransportso a non-web consumer can supply a custom transport. The shell builds the same gRPC-Web transport the dev browser uses, swapping only itsfetch. Server-streaming flows incrementally because the Channel delivers chunks as they arrive and the customfetch’sReadableStreamyields them to the transport’s existing parser.- This uses Tauri’s core IPC (
invoke+Channel), which streams on WebKitGTK. It is not the custom-URI-scheme responder path, which cannot stream (see rejected alternatives). - It preserves the daemon’s owner-only socket end to end: only the shell process (running as the user) dials the UDS; the webview reaches the shell via in-process IPC that is not network-reachable. Zero TCP, honoring §7.1.
Rejected — custom URI scheme (compass://) proxying to the UDS. Tauri 2’s
register_asynchronous_uri_scheme_protocol resolves a response as a single
buffered Vec<u8> with no chunked/streaming path, and WebKitGTK (a priority
Linux target, §7.3) lacks the underlying streaming API. A streaming RPC would
deliver nothing until the stream closed — fatal for the event channel.
Rejected — token-guarded loopback TCP proxy. Binding 127.0.0.1:<ephemeral>
and byte-proxying the webview’s gRPC-Web to the UDS streams natively and reuses
the gRPC-Web stack with no adapter code, but it discards Tauri’s security model:
a loopback TCP port is network-reachable, has no filesystem ACL, and reintroduces
the surface the owner-only socket exists to remove. A per-launch bearer token
only approximates owner-only, and it generalizes the token-guarded-loopback
posture (blessed only for the unavoidable WSL2 VM boundary, §7.3) to every
platform. Kept as a fallback only if the fetch↔Channel adapter proves
unexpectedly costly.
Daemon lifecycle — detached spawn + attach
Section titled “Daemon lifecycle — detached spawn + attach”§7.1 requires the daemon to outlive any UI session, so the shell does not run
it as a Tauri sidecar (sidecars die with the app). On launch the shell resolves
the socket path ($XDG_RUNTIME_DIR/compass/compassd.sock, falling back to
$HOME/.compass/compassd.sock), then probes: if a live daemon answers, attach;
otherwise spawn compassd detached so it survives shell quit. The daemon’s
single-instance startup already distinguishes a live daemon from a stale socket,
so a double-spawn race resolves to “refuse + attach.” v1 supervision is minimal —
spawn-if-absent plus surfacing daemon liveness from the SubscribeEvents
DaemonStatus stream; auto-restart-on-crash is a follow-up.
Spawn/attach is the local-mode branch (co-located daemon). A future hosted mode (below) attaches to a remote daemon it does not manage and never spawns, so “spawn if absent” is the local-transport branch, not an unconditional startup step.
v1 scope boundary
Section titled “v1 scope boundary”- In v1: window; detached spawn/attach + liveness surfacing; the
webview↔daemon bridge; bundle + load the built UI (
apps/ui/dist); prove the full contract path by renderingGetDaemonInfoand a liveSubscribeEventsDaemonStatus. - Deferred (own issues): tray, OS notifications, deep links; a signed installer with auto-update (needs code-signing identities/secrets not yet provisioned); Windows/WSL2 (a later epic).
Shell crate location — crates/compass-shell/
Section titled “Shell crate location — crates/compass-shell/”A Rust Tauri crate under crates/ auto-joins the root Cargo workspace
(members = ["crates/*"]) with no root-workspace churn. moon
projects are an explicit map, not glob-discovered, so the crate must be
registered in .moon/workspace.yml (compass-shell: 'crates/compass-shell')
for its CI lane to appear. It is application-layer like compass-ui, and moon
forbids one application project dependsOn another, so the shell embeds the UI’s
built dist/ via a task-level deps: ['compass-ui:build'], not a project
dependsOn. Tauri’s frontendDist points at ../../apps/ui/dist.
Transport is a swappable seam (enables a future hosted/remote daemon)
Section titled “Transport is a swappable seam (enables a future hosted/remote daemon)”A hosted deployment — the daemon on a different machine than the client (a
managed runtime) — stays possible without changing this design. Transport is
chosen at client construction (createCompassClient(transport), §7.2/§7.5), so
topology is just which transport the client receives. A hosted mode is a
sibling transport: the same custom-fetch seam with the shell’s Rust command
dialing a TLS remote with real auth instead of the UDS (or a pure browser using a
gRPC-Web transport against the hosted URL, supported today). The hosted-mode work
is daemon-side and out of scope here — the daemon has no authenticated network
listener today (UDS + dev-loopback only), so hosted mode needs a TLS+auth server
transport on the daemon plus a client-side transport-mode selector, its own future
workstream. Invariant that keeps it open: the shell and UI must never assume
“local” beyond the transport boundary — no socket path or localhost leaks above
the fetch/command seam.
Global Constraints
Section titled “Global Constraints”- Tauri 2.x. Rust toolchain from
rust-toolchain.toml; no per-crate toolchain. - Linux system libs gate every build task:
webkit2gtk-4.1,libsoup-3,pkg-config(plus Tauri’s gtk build deps) must be present in bothdevenv.nix(the Linux-onlypackagesblock) andci/ci-toolchain.nix, ormoon run compass-shell:*fails to link in thesealed-ciimage. Land this first. - Thin shell invariant: window + spawn/supervise + bridge only. Any
compass.v1command logic in the shell is a bug (§7.1); contract access is only through@compass/client. - No daemon-exposed TCP on macOS/native Linux (§7.1). The bridge honors this (transport above).
- CI lane appears once the project is registered in
.moon/workspace.yml(moon uses an explicit project map, not glob discovery); exposebuild/test/clippy/citasks mirroringcompass-daemon’smoon.yml(Petrel fans out the affectedrunInCItasks — no pipeline edit needed once registered). Note nextest exits non-zero on an empty run, so a not-yet-tested crate’stesttask needs--no-tests=pass. - License
AGPL-3.0-only(application crate, likecompass-daemon).
Right-sized tasks, each carrying its own test/gate cycle, ordered by dependency.
Task 1 — Land Tauri system deps in devenv + CI image
Section titled “Task 1 — Land Tauri system deps in devenv + CI image”- Do: add
webkit2gtk-4.1,libsoup-3,pkg-config(plus any Tauri gtk build deps) todevenv.nix’s Linux-onlypackagesblock and toci/ci-toolchain.nix. Confirm a throwaway Tauri stubcargo buildlinks in the dev shell and the CI image derivation evaluates. - Interfaces: consumes
devenv.nix,ci/ci-toolchain.nix,rust-toolchain.toml. Produces a dev shell + CI image where a Tauri crate links. - Gate: the libs resolve via
pkg-config; a stub Tauricargo buildsucceeds underdirenv exec ..
Task 2 — Scaffold crates/compass-shell (window + static UI)
Section titled “Task 2 — Scaffold crates/compass-shell (window + static UI)”- Do: create the Cargo Tauri crate (joins the workspace glob),
tauri.conf.json(frontendDist: ../../apps/ui/dist, tray/updater off),main.rsopening one window that loads the built UI.moon.ymlwithbuild/test/clippy/fmt/ci,dependsOn: ['ui'],deps: ['ui:build']. AGPL manifest likecompass-daemon. - Interfaces: consumes
apps/uidist/(Vite build output). Produces acompass-shellbinary showing the UI statically (not yet daemon-connected). - Gate:
moon run compass-shell:buildgreen; launching shows the current UI.
Task 3 — Daemon spawn + attach (detached, outlives shell)
Section titled “Task 3 — Daemon spawn + attach (detached, outlives shell)”- Do: resolve the socket path (share/reuse the daemon’s default-path logic, or
mirror it with a test asserting parity); probe for a live daemon; attach if
present, else spawn
compassddetached. Surface a not-running/failed state to the UI. - Interfaces: consumes the
compassdbinary, its socket-path contract, and the daemon’s single-instance behavior. Produces a running/attached daemon on launch, surviving shell quit. - Gate: unit tests for probe→attach vs probe→spawn; a test that the daemon survives shell process exit.
Task 4 — Webview ↔ daemon bridge
Section titled “Task 4 — Webview ↔ daemon bridge”- Do: a Rust
invokecommand dials the daemon UDS (native gRPC) and pumps gRPC-Web response frames back over atauri::ipc::Channel; a JS customfetch(request →invoke, responsebody→ aReadableStreamfed by the Channel) passed tocreateGrpcWebTransport({ fetch }), with the UI building its client viacreateCompassClient(thatTransport). Handle stream cancellation (webview drops the reader) and gRPC trailer/error mapping. - Interfaces: consumes the daemon UDS (native gRPC),
@connectrpc/connect-web’screateGrpcWebTransport({ fetch }), and@compass/client’screateCompassClient/Transportseam. Produces a webview that completesGetDaemonInfoand a live streamingSubscribeEvents, zero TCP. - Gate: unit tests on the
fetch↔Channel adapter (unary + a multi-frame server stream + mid-stream cancellation); UI typechecks against the new client wiring.
Task 5 — End-to-end smoke + gate
Section titled “Task 5 — End-to-end smoke + gate”- Do: a launch-the-shell E2E that spawns/attaches the daemon and asserts the UI
renders
GetDaemonInfoand a streamedDaemonStatus{Ready}. - Interfaces: consumes the full stack (Tasks 1–4). Produces green
compass-shell:ci. - Gate:
moon run compass-shell:cigreen; manual QA: launch → window → UI shows live daemon status over the socket.
- T1 Tauri system deps in
devenv.nix+ci/ci-toolchain.nix(link-check). - T2 Scaffold
crates/compass-shell— window loadsapps/ui/dist; moonbuild/test/clippy/fmt/ci. - T3 Detached daemon spawn + attach + liveness surfacing; probe/spawn tests.
- T4 Bridge —
createGrpcWebTransport({ fetch })overinvoke+Channelto the UDS pump; wire the UI client. - T5 E2E smoke + green
compass-shell:ci.