Files
julian 29594f8bad
Build desktop / desktop (push) Successful in 5m19s
CI / check (push) Successful in 55s
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 5m21s
chore(desktop): bump version to 0.2.0 — first feature release of the bundle (Car Wash, tills, roles jobs, vision category, review outbox)
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:33 +02:00

40 KiB
Raw Permalink Blame History

type, tags, sources, updated, status
type tags sources updated status
decision
parking
decisions
desktop
frontend
2026-09-04 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).
  • 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 — 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 <branch>-<sha> (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-<TAG> split below). .gitea/workflows/release.yml pushes to both repos: the private source repo (own record) and the public mirror (what the updater and any human downloader actually use). Rejected alternative: embedding a read:repository Gitea token in tauri.conf.json's updater headers so it could read the private repo directly — ruled out because that token would ship inside every installed binary in the field, and this appliance's own threat model names the booth operator as the primary adversary (see root CLAUDE.md); a leaked token scoped to the whole private repo, with no cheap way to rotate it across appliances already in the field, was judged worse than publishing installers-only.
  • Still deferred: OS-level installer signing (Windows/macOS publisher trust) and the Windows kiosk-browser fallback path.

Desktop in CI — two workflows, two purposes (added 2026-06-24)

The desktop bundle now runs in CI under two distinct workflows — keep the split clear:

  • .gitea/workflows/release.yml (tag v*) — the signed, versioned release: builds .deb/.rpm/.AppImage + their .sig (updater key from secrets), assembles latest.json, and publishes a Gitea Release on mca/parking_solution (source, own record) AND mirrors it to mca/public_releases (public, installers-only — see the update-hosting-endpoint entry above for why). The mirror step uses a second token, RELEASES_MIRROR_TOKEN (write:repository, scoped for pushing into public_releases only — a CI-side secret, never shipped to any client, distinct from the embedded updater pubkey). It publishes two tags there: desktop-<TAG> (versioned, permanent, for audit/rollback) and desktop-latest (moving — existing assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this repo is shared across apps). latest.json's asset URL and tauri.conf.json's updater endpoint both point at desktop-latest. This is what the auto-updater actually consumes.
  • .gitea/workflows/build-desktop.yml (push to dev/main) — a per-commit test build: compiles .deb + .AppImage only (pnpm --filter @parking/desktop bundle --bundles deb,appimage) and publishes them to a rolling per-branch pre-release (tag desktop-<branch>). Unsigned — no TAURI_SIGNING_*, no latest.json — so it must NEVER be wired to the updater (an unsigned artifact would be rejected anyway). It exists so each branch push yields a downloadable installer for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same system-deps + cargo cache as release.yml. The container images (build-images.yml) and the desktop installers are deliberately separate pipelines — the desktop app is not containerized (container-deployment).
    • Delivery: a rolling pre-release, NOT actions/upload-artifact. That action's artifact backend isn't reliable on the Gitea runner (the Upload installers step failed). Instead the workflow mirrors release.yml's proven path — plain curl + the built-in GITHUB_TOKEN to the Releases API. It DELETEs any existing desktop-<branch> release + tag, recreates it against the new commit as a prerelease, and uploads the two installers (renamed space-free, parking-desktop-<branch>-<sha>.{deb,AppImage}). So desktop-dev always holds the newest dev build; v* tags remain the only signed releases.
    • Gotcha (the unsigned build still demands the key). tauri.conf.json sets bundle.createUpdaterArtifacts: true (so release.yml produces the .sig updater signatures). With that on, tauri build fails if TAURI_SIGNING_PRIVATE_KEY is absent — "A public key has been found, but no private key" — even though the .deb/.AppImage themselves built fine. The unsigned CI build therefore overrides it off with --config '{"bundle":{"createUpdaterArtifacts":false}}' (a JSON patch merged over the config), so no .sig is attempted and no key is required. release.yml keeps the config default (signs).
    • 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 — 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 (open — asks for exactly this jar→webview sync) and #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.

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 POSTs /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 <anything> — 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 -is 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.