Files
parking_solution/wiki/decisions/desktop-shell-tauri.md
T
julian faa3265e49
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
fix(desktop): restore VITE_API_BASE for the desktop build
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00

17 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).
  • 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).