--- type: decision tags: [parking, decisions, desktop, frontend] sources: [] updated: 2026-09-04 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). - **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**. (Nearest upstream ticket: [bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000` would stay mixed content even if it were fixed, so the plugin route below is the right architecture for a remote booth regardless, not a stopgap.) `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 **HTTP** plugin, by contrast, *does* attach that Origin itself — `tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS path needs the explicit header.) 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 — and see [[fleet-deployment-komodo]] for a real ResourceSync-branch gotcha this exact fix ran into. - **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in `router.tsx` shows the *server's* `-` (from `/api/version`, gated `site:read`) — but nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way to confirm which build was actually installed short of reading the update-available prompt's target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`: calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from `tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above), no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive via the plugins). - **`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). - **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. ### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04) **Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s `beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is **not** built per-booth — so a build-time backend address meant the installer could only ever talk to a server on the same machine, and pointing an install at any other host (a remote appliance, a different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered, runtime-persisted** value. - **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server"). Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated- from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that route isn't actually public), then **Save & continue**. - **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir, `autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/ back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via `initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the ConnectScreen saves — no restart required to start using it. - **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was `'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket` from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket` anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside** `connect-src`'s jurisdiction entirely. The real access boundary moved to `capabilities/default.json`'s `http:default` scope, which is now wildcarded (`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*` covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket: default` already had no scope restriction. Net effect: **the app can now reach any host the operator types in, and nothing else** — same shape of guarantee as before, just operator-directed instead of build-directed. - **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to `DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate admin action, not a frequent setting — same reasoning as why logout is a plain action button with no separate "are you sure" for THAT (this one gets a confirm because it also blows away the session, unlike a normal logout-then-relogin against the same server). - **Gotcha (found via research before shipping, not in the field — worth recording anyway): the CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s `fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by `reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never** synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045) (open — asks for exactly this jar→webview sync) and [#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not something fixable on our side by changing how/when we read the cookie. The reqwest jar itself IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf` cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see [[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the occasion to actually trace the desktop auth path end-to-end. **Fix, without touching `assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`, shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body (`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`, never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what `assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only fixes how the desktop *client* learns what value to put in the header, so browser behavior and server verification are both completely unchanged. ### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6) A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first** of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected. - **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no cookie jar at all** (its source has no cookie handling); the cookie lives in `tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either. Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both. A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS, which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin → 403; cookie path unchanged. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so. - **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token from `document.cookie` (null on desktop — the same jar split as above), so every `POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not `logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts` can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when `inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at debug/trace. - **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and the comment claiming no unauthenticated route existed was wrong (`/health` has been there all along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx and that value. - **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`, not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation, and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`). ### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04) Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings` on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried **only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update, downloaded the AppImage, verified its signature (which was correct — for the AppImage), then handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop log channel was itself broken — see the previous section), so it looked like a silent no-op. Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`, `install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`). - **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`, `linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being produced and uploaded, just never referenced). - **What a booth update now looks like:** prompt → download → **polkit password dialog** (`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be able to replace the app silently; whoever brings the box online for an update is the admin. Cancelling the dialog leaves the old version running and logs `desktop_update_install_failed` to `app_logs`. - **Rejected:** switching booths to the AppImage so updates need no privilege. It would work (the updater rewrites the AppImage in place), but the binary would then be operator-writable, it needs FUSE on the appliance image, and launcher/autostart integration becomes manual — three regressions to avoid one password prompt. - **Judgment note for the retrospective:** three fixes were shipped against this symptom without reading the updater's install path once. The whole chain is ~60 lines of vendored Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`). ### Decision: desktop updates are an admin-only action — the polkit prompt stays (2026-09-04) Settled with the user after the first successful self-update (v0.1.6 → v0.1.7 on the park-2 booth, `pkexec dpkg -i`, polkit dialog, relaunch, badge shows 0.1.7). The prompt asks for an **admin** password the operator does not have — and that is now the intended gate, not a defect. - **AppImage was tried and rejected on evidence, not theory.** The v0.1.6 AppImage fails to start on the Ubuntu 26.04 booth: `libgvfscommon.so: undefined symbol: g_variant_builder_init_static` (the host's newer gvfs modules loading into the *bundled* older glib) followed by `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...` (the bundled WebKitGTK vs. the host's Mesa). Tauri's AppImage freezes the CI runner's (24.04) GTK/WebKitGTK/glib into the bundle, which throws away the one property this platform decision rests on — the **distro-maintained, Canonical-patched WebKitGTK** — and replaces it with a host-mismatch hazard at every OS update. `WEBKIT_DISABLE_DMABUF_RENDERER=1` / `WEBKIT_DISABLE_COMPOSITING_MODE=1` may paper over the EGL abort; they don't fix the shape. **The `.deb` is the right artifact; only its install step needs root.** - **Passwordless polkit/sudoers for `dpkg -i` rejected:** any rule that lets the operator account pass that prompt silently lets them run `pkexec dpkg -i ` — root — which the [[threat-model]] forbids outright. - **Deferred, not rejected — the fleet-grade answer:** a root systemd timer shipped inside the `.deb` (via Tauri's deb `files` + postinstall) that fetches `latest.json` from `public_releases`, verifies the `.deb` with `minisign` against the same embedded pubkey, and `dpkg -i`s it when the box is online; the in-app updater then only *notifies*. No prompt, no privileged code in the shell, standard appliance practice. Revisit when more than one booth needs keeping current, or when someone other than the admin has to bring a box online. - **Operator-facing consequence:** the in-app prompt now says the install needs the administrator password (i18n `update.prompt`, en + sq). An operator who accepts and can't authenticate simply stays on the current version; nothing breaks, and the failure is logged. ### v0.2.0 — the first feature release of the desktop bundle (2026-09-07) Every tag from v0.1.0 to v0.1.7 was a desktop-shell fix (origins, cookies, WS tickets, the updater manifest). Since v0.1.7 the SPA the bundle carries (`frontendDist: ../../web/dist`) gained the venue-module registry, the Car Wash module with per-till shifts and the wash-desk printer role, roles that remember their jobs with signed edits, the advisory vehicle category from the entry camera, the review outbox status in Setup, and the two-column Car Wash setup — 31 commits, none of them shell fixes. Under 0.x that is a **minor** bump, not a patch: **v0.2.0**. `tauri.conf.json` now says 0.2.0 too (the release workflow still rewrites it from the tag, so the file only matters for local bundles). The README's release gate — run the real bundle, LIVE, one mutation, a frontend log row — is still the step between the tag and the push of the tag.