--- 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**, a long-standing WebKit limitation ([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=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 — 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, a known Tauri scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `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. This is an open, unresolved upstream Tauri bug ([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045), [#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by changing how/when we read the cookie. 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.