Files
parking_solution/wiki/decisions/desktop-shell-tauri.md
T
julian 7317042e8d
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
fix(desktop): WS live feed offline — native plugin sends no Origin header
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00

22 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
decision
parking
decisions
desktop
frontend
2026-09-03 settled

Desktop shell — Tauri v2 (chosen over Electron)

The operator UI (react-vite-spa) needs to ship as a desktop application on the appliance (kiosk-style), with a mobile app possible later but out of scope now. The choice was Tauri v2 vs. Electron. Decision: Tauri v2. (Settled with the user, 2026-06-21.)

The thin-shell architecture (why this choice is low-risk)

The desktop shell is a thin kiosk wrapper around the existing SPA, nothing more. All privileged logic — device drivers (device-adapter-pattern: reader/printer/relay/serial), local-jwt-auth, the append-only-event-chain, tariff/subscription pricing — stays in the fastify server (settled with the user, 2026-06-21). The shell only loads the SPA, which talks to the local Fastify server over localhost. Consequences:

  • No device/serial logic is ported into the shell (no Rust device code for Tauri; no Node main-process drivers for Electron). The "logic lives in the server" invariant holds.
  • If a WebView quirk ever bites, the blast radius is presentation only — the server and its signed ledger are untouched.

This is what neutralizes Tauri's main weakness (host-WebView fragmentation, below): the shell's job is fullscreen chrome, autostart, and kiosk lockdown — not correctness-critical rendering of financial truth.

Why Tauri v2 fits this project specifically

  • Threat-model alignment (threat-model). The primary adversary is the operator at the booth. Tauri's deny-by-default capability/permission model means the renderer literally cannot reach the filesystem, shell, or any native command unless we hand it a named, allowlisted command. That is defense-in-depth that matches "don't trust the booth." Electron's equivalent hardening (contextIsolation, nodeIntegration:false, sandbox:true, strict CSP) is opt-in and easy to misconfigure into giving the renderer Node access — exactly what this threat model can't afford.
  • Small footprint / smaller CVE surface. Tauri uses the OS WebView (WebKitGTK on Linux) — ~3–10 MB bundles, tens of MB RAM, and no bundled Chromium to patch. Electron ships and pins its own Chromium (100+ MB, hundreds of MB RAM) and makes us own Chromium's CVE treadmill on a long-lived appliance. On a disk-os-hardening single-purpose box maintained for years, less to patch is a real operational win.
  • License. Tauri is MIT / Apache-2.0 — clears the hard MIT/Apache/BSD constraint (technology-stack). (Electron is also MIT; not a differentiator.)
  • Rust core is available if device access ever did move shell-side — but per the decision above it does not, so this is latent upside, not a current cost.

What Electron would have bought (the rejected upside)

  • Version-pinned bundled Chromium → identical rendering everywhere regardless of host. The most predictable option on a locked-down appliance image, and the reason this isn't a slam-dunk.
  • Largest, most battle-tested kiosk/appliance ecosystem.
  • Node in the main process → trivial code-sharing with the Fastify/Node device drivers — but we explicitly keep drivers in the server, so this advantage doesn't apply here.

Rejected because the heavy footprint, the Chromium CVE-patching obligation, and the opt-in (easy to get wrong) security posture all cut against the appliance + threat-model constraints, while its one real advantage (bundled Chromium) is only conditionally needed — see the open question.

Target deployment — best case vs. worst case

The decision's risk collapses to which OS the appliance actually runs (user, 2026-06-21):

  • Best case — Ubuntu 26.04 LTS desktop (the intended appliance). Ships a current, distro-maintained WebKitGTK (webkit2gtk-4.1 / GTK4), patched by Canonical for the LTS lifetime. This closes the WebView risk below — no ancient-WebView problem, no CVE-patching burden on us. A native, hardened, single-purpose box that matches the disk-os-hardening platform decision. Tauri belongs here; the decision is unconditional in this world.
  • Worst case — Windows 11 + WSL + Docker. This is not a "use Electron instead" fallback — it contradicts the standing-decisions (explicitly "a dedicated, hardened Linux appliance, not Windows/WSL") and undermines disk-os-hardening against the booth operator (threat-model). Moreover a desktop GUI shell does not naturally live inside WSL/Docker (both are headless Linux). The realistic shape there is no native shell at all: run fastify + the SPA in the WSL/Docker backend, and open the SPA in a kiosk browser on Windows (msedge/chrome --kiosk --app=http://localhost:PORT). Electron is warranted only if a self-contained installable Windows .exe (no system browser) is a hard requirement.

The thin-shell architecture makes the worst-case fallback cheap: because all logic lives in fastify, dropping the shell for a kiosk browser costs only the native window wrapper, not any functionality.

Deployment Desktop shell
Ubuntu 26.04 LTS (best, intended) Tauri v2 — current WebKitGTK, native, hardened. Decision stands unconditionally.
Windows 11 + WSL + Docker (worst, conflicts with platform decision) No native shell — kiosk browser at the local Fastify-served SPA. Electron only if a standalone Windows installer is required.

The one thing to verify (procurement / image gate)

Tauri's rendering correctness depends on the WebKitGTK version that ships on the target appliance OS image. On a hardened/pinned image this can be old and cause rendering quirks — pin it and test the built SPA against that exact WebView. On the intended Ubuntu 26.04 LTS this is effectively resolved (current distro-maintained WebKitGTK); the concern only bites on an unexpected image with an ancient/unavailable WebView, which would point to the kiosk-browser path (or Electron) above. Tracked as an open-questions.

Invariants this decision must preserve

  1. Server owns all privileged logic. The shell is presentation only; device/auth/ledger/pricing stay in fastify. Don't let "convenient native access" pull driver logic into the shell.
  2. Deny-by-default native surface. Expose Tauri commands one at a time, allowlisted; never open a broad filesystem/shell capability to the renderer (threat-model).
  3. offline-first. The shell, its updater, and any WebView must work air-gapped; no decision here may introduce a network dependency in core operation.
  4. Mobile later, not now. A future mobile app is a separate target; don't pre-build for it.

As-built (scaffolded 2026-06-21)

apps/desktop — a Tauri v2 shell, its own pnpm/Turbo package, wrapping the same apps/web SPA so the desktop and browser UIs cannot drift (one UI codebase; requirement from the user):

  • Dev: tauri dev loads http://localhost:5173 (the @parking/web Vite dev server) → editing a component in apps/web updates the desktop window via HMR live. beforeDevCommand starts the web dev server.
  • Prod: frontendDist: ../../web/dist bundles the built SPA into the binary; beforeBuildCommand rebuilds it first.
  • Backend origin: the SPA used relative /api + a window.location.host WS URL — fine in a browser, broken from tauri://localhost. Centralized into apps/web/src/lib/origin.ts (API_BASE/apiUrl/wsUrl), read from VITE_API_BASE (empty in the browser = unchanged; set to the Fastify origin for the desktop build). The tauri.conf.json CSP connect-src whitelists 127.0.0.1:3000/localhost:3000 http+ws; the backend's WS_ALLOWED_ORIGINS must include the Tauri origin.
  • Thin shell, enforced: the Rust crate (parking_desktop_lib::run) registers no commands; the capability set is core:default only — no fs/shell/device access to the renderer (invariants 1–2). All logic stays in fastify.
  • Turbo: build is a no-op (so turbo run build stays fast); the real bundle is a deliberate pnpm --filter @parking/desktop bundle (the vision-shim pattern).
  • Verified: cargo check + a full tauri build compiled the Rust/WebKitGTK/wry stack and produced working .deb/.rpm/.AppImage bundles; pnpm turbo run build lint → 14/14 green (was 12). All Linux prereqs present (Rust 1.93, WebKitGTK 4.1, libsoup-3, WSLg display).

Window / kiosk, auto-update, env (added 2026-06-21)

Per the user's choices — the operator keeps OS access (no fullscreen lockdown):

  • Window: starts maximized (maximized: true), not fullscreen, resizable. No OS-key blocking, no always-on-top — the booth PC stays usable as a PC.
  • Right-click: the context menu is blocked in prod only (apps/web/src/lib/kiosk.ts, guarded on import.meta.env.PROD); dev keeps right-click + devtools. Applies to both the browser prod build and the desktop build (same SPA).
  • Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03. Even with VITE_API_BASE correctly set (below), login still failed with WebKit's generic "Load failed". Root cause is a separate, deeper issue: WebKitGTK treats tauri://localhost as a secure origin, so a plain http://127.0.0.1:3000 fetch() — or a ws://127.0.0.1:3000 WebSocket — from inside it is blocked as mixed content, a long-standing WebKit limitation (bugs.webkit.org #171934). connect-src in the CSP does not override this — it's a different browser security layer entirely, so the request never even reaches the network layer to be diagnosable via server logs. Fix: two Tauri plugins route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own fetch/WebSocket, which sidesteps the check entirely:
    • tauri-plugin-http — apps/web/src/lib/origin.ts's platformFetch() dynamically imports @tauri-apps/plugin-http's fetch (a genuine drop-in for the standard Fetch API) inside Tauri, plain fetch in the browser. api.ts and logger.ts both call platformFetch instead of the global fetch now.
    • tauri-plugin-websocket — NOT a drop-in (async connect()/listener-callback API, not onopen/onmessage/sync send/close). apps/web/src/lib/platform-ws.ts adapts it behind the same native-WebSocket-shaped interface use-live-feed.ts already expects (hardened for reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
    • Capability grants: apps/desktop/src-tauri/capabilities/default.json adds websocket:default and a scoped http:default (allow: [{url: "http://127.0.0.1:3000"}, {url: "http://localhost:3000"}]) — deny-by-default, matching the CSP's existing allowlist.
    • logger.ts's flushBeacon() (page-hide navigator.sendBeacon) is a native browser API with no Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular 4s-interval flush (now fixed, routes through platformFetch) covers the common case.
    • Gotcha (found immediately after shipping the above): the native WS plugin sends no Origin header. tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the webview page — there's no page context to auto-attach Origin: tauri://localhost the way a real browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts, isAllowedOrigin) treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed showed "JASHTË LINJË" (offline) in the desktop app while the browser showed "LIVE", same server, same moment. Fix (two parts, both needed): platform-ws.ts's connect() call now passes { headers: { Origin: "tauri://localhost" } } explicitly; separately, komodo/ resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production (despite .env.example documenting tauri://localhost,http://tauri.localhost as required) — even a correct Origin header is useless if the server's allowlist doesn't include it. Both fixed together; a resources.toml change still needs a Komodo sync + Stack redeploy to take effect on a live booth, it isn't automatic from a git push alone.
  • VITE_API_BASE — desktop vs. browser (regression found + fixed 2026-09-03): apps/web/.env.production (committed, shared by both builds) sets VITE_API_BASE= (empty) — this is correct for the browser/booth build (Fastify same-origin, stays relative) since commit 96fd97e (2026-06-27), but that same change silently broke the desktop build, which was never given its own override. Result: the desktop shell's apiUrl() returned a bare relative path (/api/auth/login) to fetch() from a page loaded at tauri://localhost — WebKitGTK has no base to resolve a relative URL against from a non-http(s) origin, and threw DOMException: "The string did not match the expected pattern." on the first authenticated request (login). Login worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what made this easy to miss. Fix: tauri.conf.json's build.beforeBuildCommand now sets VITE_API_BASE=http://127.0.0.1:3000 inline (VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build) — process env vars override .env.production in Vite's load order, so this overrides the shared file for the desktop build only, without touching it (the browser/booth build still gets the empty value, unaffected). Verified: rebuilding with the override bakes 127.0.0.1:3000 into the bundle; rebuilding without it stays clean/relative.
  • Auto-update (prompt-on-update, self-hosted): tauri-plugin-updater + tauri-plugin-process. On launch the SPA checks the endpoint (apps/web/src/lib/desktop-updater.ts, no-op in browser / offline), prompts the operator (i18n update.prompt), then downloadAndInstall() + relaunch(). Accepts that the appliance may be offline day-to-day and brought online (phone hotspot) only when an update is wanted — consistent with offline-first (no network dependency in core operation; updates are out-of-band). WS origin: the desktop window's origin is tauri://localhost (Linux may also send http://tauri.localhost), so the backend's WS_ALLOWED_ORIGINS must include both or the live feed won't connect (documented in apps/server/.env.example).
  • Code-signing (updater): an Ed25519 updater keypair was generated. The public key is embedded in tauri.conf.json (plugins.updater.pubkey); the private key + password live OUTSIDE the repo at ~/.parking-updater-keys/ (0600) and as the build-time secrets TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD. Losing them means no future signed updates — back them up. Verified: a signed pnpm --filter @parking/desktop bundle produced .deb/.rpm/.AppImage plus their .sig updater signatures; full turbo run build lint 14/14 green. (This is the updater signing — distinct from OS-installer signing for Windows/macOS "unknown publisher", and from the atecc608/tpm event signing.)
  • Update-hosting endpoint (found broken, fixed 2026-09-03): the endpoint originally pointed at the source repo's own Gitea "latest release" redirect (.../mca/parking_solution/releases/latest/download/latest.json) — but mca/parking_solution is private, and the updater runs on offline-first field appliances with no Gitea credentials. Every deployed update check was silently failing (swallowed by a try/catch in desktop-updater.ts) — this was never field-verified, and it couldn't have worked as configured. Fix: signed installers are now mirrored to a separate public, releases-only repo, mca/public_releases (shared across apps in the org — see fleet-deployment-komodo sibling infra), holding only compiled installers, no source. tauri.conf.json's endpoint now points there at a fixed desktop-latest tag (NOT that repo's generic "latest release" redirect, since other apps publishing there would shadow ours — see the desktop-latest vs desktop-<TAG> split below). .gitea/workflows/release.yml pushes to both repos: the private source repo (own record) and the public mirror (what the updater and any human downloader actually use). Rejected alternative: embedding a read:repository Gitea token in tauri.conf.json's updater headers so it could read the private repo directly — ruled out because that token would ship inside every installed binary in the field, and this appliance's own threat model names the booth operator as the primary adversary (see root CLAUDE.md); a leaked token scoped to the whole private repo, with no cheap way to rotate it across appliances already in the field, was judged worse than publishing installers-only.
  • Still deferred: OS-level installer signing (Windows/macOS publisher trust) and the Windows kiosk-browser fallback path.

Desktop in CI — two workflows, two purposes (added 2026-06-24)

The desktop bundle now runs in CI under two distinct workflows — keep the split clear:

  • .gitea/workflows/release.yml (tag v*) — the signed, versioned release: builds .deb/.rpm/.AppImage + their .sig (updater key from secrets), assembles latest.json, and publishes a Gitea Release on mca/parking_solution (source, own record) AND mirrors it to mca/public_releases (public, installers-only — see the update-hosting-endpoint entry above for why). The mirror step uses a second token, RELEASES_MIRROR_TOKEN (write:repository, scoped for pushing into public_releases only — a CI-side secret, never shipped to any client, distinct from the embedded updater pubkey). It publishes two tags there: desktop-<TAG> (versioned, permanent, for audit/rollback) and desktop-latest (moving — existing assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this repo is shared across apps). latest.json's asset URL and tauri.conf.json's updater endpoint both point at desktop-latest. This is what the auto-updater actually consumes.
  • .gitea/workflows/build-desktop.yml (push to dev/main) — a per-commit test build: compiles .deb + .AppImage only (pnpm --filter @parking/desktop bundle --bundles deb,appimage) and publishes them to a rolling per-branch pre-release (tag desktop-<branch>). Unsigned — no TAURI_SIGNING_*, no latest.json — so it must NEVER be wired to the updater (an unsigned artifact would be rejected anyway). It exists so each branch push yields a downloadable installer for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same system-deps + cargo cache as release.yml. The container images (build-images.yml) and the desktop installers are deliberately separate pipelines — the desktop app is not containerized (container-deployment).
    • Delivery: a rolling pre-release, NOT actions/upload-artifact. That action's artifact backend isn't reliable on the Gitea runner (the Upload installers step failed). Instead the workflow mirrors release.yml's proven path — plain curl + the built-in GITHUB_TOKEN to the Releases API. It DELETEs any existing desktop-<branch> release + tag, recreates it against the new commit as a prerelease, and uploads the two installers (renamed space-free, parking-desktop-<branch>-<sha>.{deb,AppImage}). So desktop-dev always holds the newest dev build; v* tags remain the only signed releases.
    • Gotcha (the unsigned build still demands the key). tauri.conf.json sets bundle.createUpdaterArtifacts: true (so release.yml produces the .sig updater signatures). With that on, tauri build fails if TAURI_SIGNING_PRIVATE_KEY is absent — "A public key has been found, but no private key" — even though the .deb/.AppImage themselves built fine. The unsigned CI build therefore overrides it off with --config '{"bundle":{"createUpdaterArtifacts":false}}' (a JSON patch merged over the config), so no .sig is attempted and no key is required. release.yml keeps the config default (signs).
    • Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1). The git tag (v0.1.1) and tauri.conf.json's own "version" field are two independent values with nothing syncing them. Tauri bakes "version" into the bundle filename, the app's internal version, AND what the updater compares against — NOT the git tag. Bumping only the tag (as the release procedure implied) left the file at the prior 0.1.0: the signed binary was built and named as 0.1.0, latest.json (built from TAG) correctly claimed 0.1.1, and the updater found an "update," downloaded it, then failed signature verification against a manifest that didn't actually describe the file it pointed at. Compounded by a second bug (below) that made this failure completely invisible to the operator. Fix: release.yml now has a "Sync tauri.conf.json version to the git tag" step (sed-patches "version" from TAG right before tauri build) — the checked-in value is now only a placeholder for local dev builds; every real release derives its version from the tag automatically.
    • Gotcha (silent updater failure — found + fixed 2026-09-03). desktop-updater.ts's checkForDesktopUpdate wrapped the ENTIRE check-download-install-relaunch sequence in one catch {} that swallowed everything, by design, for the offline/no-server case. But that meant a REAL failure after the operator already accepted the prompt (bad signature, corrupted download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no error, no log, the app just silently reverted to the old version and re-showed the same "update available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug above (download traffic visible, then nothing). Fixed by nesting downloadAndInstall() in its own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch), but a failure after the operator accepted now logs to the console instead of vanishing.