Files
parking_solution/wiki/decisions/desktop-shell-tauri.md
T
julian d0536da3d7 feat(desktop): Tauri v2 kiosk shell — maximized window, prod right-click block, auto-update + code-signing
Add apps/desktop, a thin Tauri v2 shell wrapping the SAME @parking/web SPA so
the desktop and browser UIs never drift: dev loads the Vite dev server (HMR),
prod bundles the web app's dist/. No business logic in the shell (device/auth/
ledger stay in @parking/server); deny-by-default capabilities.

apps/web (single UI source of truth):
- lib/origin.ts: centralize the backend origin (API_BASE/apiUrl/wsUrl from
  VITE_API_BASE); no-op in the browser, lets the desktop build target Fastify.
- lib/kiosk.ts: block the right-click context menu in PROD only (dev keeps it +
  devtools).
- lib/desktop-updater.ts: prompt-on-update auto-update (no-op in browser/offline)
  → downloadAndInstall + relaunch; i18n update.* keys (sq+en).
- .env.production: VITE_API_BASE wired to the Fastify origin for the bundle.

Desktop:
- window starts maximized (not fullscreen — operator keeps OS access).
- auto-update via tauri-plugin-updater + -process; self-hosted endpoint is a
  PLACEHOLDER to fill in. Updater keypair: pubkey embedded in tauri.conf.json;
  private key + password kept OUTSIDE the repo (~/.parking-updater-keys) and as
  TAURI_SIGNING_* build secrets.
- Turbo build is a no-op; the real signed bundle is `pnpm --filter
  @parking/desktop bundle` (verified → .deb/.rpm/.AppImage + .sig signatures).

Verified: cargo check clean; turbo run build lint 14/14 green; i18n parity holds;
no key/sig/bundle artifacts in the repo.

Wiki (security + desktop analysis recorded alongside):
- new concepts/tpm.md (TPM 2.0: how it works, sealed-LUKS auto-unlock + non-
  extractable signing key, limits — live-root, bus-sniff — TPM-vs-ATECC608 by
  platform).
- new decisions/desktop-shell-tauri.md (Tauri v2 over Electron; best-case Ubuntu
  26.04 LTS, worst-case Windows+WSL → kiosk browser; full as-built).
- pull-the-disk attack trace on append-only-event-chain; ATECC608 not-in-a-PC
  caveat; cross-links from disk-os-hardening / threat-model.
- open-questions #11 (appliance WebKitGTK), #12 (TPM hardening impl), #13
  (startup verifyChain self-check); index/overview/log/standing-decisions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:21:49 +02:00

11 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
decision
parking
decisions
desktop
frontend
2026-06-21 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 wired to the environment: apps/web/.env.production (committed, non-secret, allow-listed in .gitignore) sets VITE_API_BASE=http://127.0.0.1:3000, auto-loaded by vite build (which the desktop bundle runs). So the desktop build targets Fastify with no manual export; the browser-served-by-Fastify build should override to "".
  • 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). Endpoint in tauri.conf.json is a placeholder (https://UPDATES.EXAMPLE.invalid/...) to fill in once the self-hosted update URL exists; the server must serve latest.json + the signed installer + its .sig.
  • 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.)
  • Still deferred: the actual update-hosting URL, OS-level installer signing (Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.