--- type: decision tags: [parking, decisions, desktop, frontend] sources: [] updated: 2026-09-03 status: 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|auth]], the [[append-only-event-chain|signed ledger]], [[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|hardened]] 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|standing platform decision]]** (explicitly *"a dedicated, hardened Linux appliance, **not Windows/WSL**"*) and undermines [[disk-os-hardening|Secure Boot / LUKS / tamper resistance]] 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|open question]]. ## 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-` 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-` (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-`). **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-` release + tag, recreates it against the new commit as a **prerelease**, and uploads the two installers (renamed space-free, `parking-desktop--.{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).