40 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| decision |
|
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
- 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.
- 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).
- offline-first. The shell, its updater, and any WebView must work air-gapped; no decision here may introduce a network dependency in core operation.
- 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 devloadshttp://localhost:5173(the@parking/webVite dev server) → editing a component inapps/webupdates the desktop window via HMR live.beforeDevCommandstarts the web dev server. - Prod:
frontendDist: ../../web/distbundles the built SPA into the binary;beforeBuildCommandrebuilds it first. - Backend origin: the SPA used relative
/api+ awindow.location.hostWS URL — fine in a browser, broken fromtauri://localhost. Centralized intoapps/web/src/lib/origin.ts(API_BASE/apiUrl/wsUrl), read fromVITE_API_BASE(empty in the browser = unchanged; set to the Fastify origin for the desktop build). Thetauri.conf.jsonCSPconnect-srcwhitelists127.0.0.1:3000/localhost:3000http+ws; the backend'sWS_ALLOWED_ORIGINSmust include the Tauri origin. - Thin shell, enforced: the Rust crate (
parking_desktop_lib::run) registers no commands; the capability set iscore:defaultonly — no fs/shell/device access to the renderer (invariants 1–2). All logic stays in fastify. - Turbo:
buildis a no-op (soturbo run buildstays fast); the real bundle is a deliberatepnpm --filter @parking/desktop bundle(the vision-shim pattern). - Verified:
cargo check+ a fulltauri buildcompiled the Rust/WebKitGTK/wry stack and produced working.deb/.rpm/.AppImagebundles;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 onimport.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_BASEcorrectly set (below), login still failed with WebKit's generic"Load failed". Root cause is a separate, deeper issue: WebKitGTK treatstauri://localhostas a secure origin, so a plainhttp://127.0.0.1:3000fetch()— or aws://127.0.0.1:3000WebSocket — 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 as192.168.1.50:3000would 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-srcin 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'splatformFetch()dynamically imports@tauri-apps/plugin-http'sfetch(a genuine drop-in for the standard Fetch API) inside Tauri, plainfetchin the browser.api.tsandlogger.tsboth callplatformFetchinstead of the globalfetchnow.tauri-plugin-websocket— NOT a drop-in (asyncconnect()/listener-callback API, notonopen/onmessage/syncsend/close).apps/web/src/lib/platform-ws.tsadapts it behind the same native-WebSocket-shaped interfaceuse-live-feed.tsalready expects (hardened for reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.- Capability grants:
apps/desktop/src-tauri/capabilities/default.jsonaddswebsocket:defaultand a scopedhttp:default(allow: [{url: "http://127.0.0.1:3000"}, {url: "http://localhost:3000"}]) — deny-by-default, matching the CSP's existing allowlist. logger.ts'sflushBeacon()(page-hidenavigator.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 throughplatformFetch) covers the common case.- Gotcha (found immediately after shipping the above): the native WS plugin sends no
Originheader.tauri-plugin-websocket'sconnect()runs on Tauri's Rust side, not inside the webview page — there's no page context to auto-attachOrigin: tauri://localhostthe way a real browserWebSocketwould. (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'sconnect()call now passes{ headers: { Origin: "tauri://localhost" } }explicitly; separately,komodo/ resources.toml's booth Stacks hadWS_ALLOWED_ORIGINS=empty in production (despite.env.exampledocumentingtauri://localhost,http://tauri.localhostas required) — even a correct Origin header is useless if the server's allowlist doesn't include it. Both fixed together; aresources.tomlchange 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).
VersionBadgeinrouter.tsxshows the server's<branch>-<sha>(from/api/version, gatedsite: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 withDesktopVersionBadge, next toVersionBadge: calls@tauri-apps/api/app'sgetVersion()(the real running app's version, baked in fromtauri.conf.json— synced to the git tag byrelease.yml, see the version-drift gotcha above), no-ops/renders nothing in a browser (inTauri()guard, now exported fromorigin.tsinstead of redefined a 4th time).@tauri-apps/apiadded 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) setsVITE_API_BASE=(empty) — this is correct for the browser/booth build (Fastify same-origin, stays relative) since commit96fd97e(2026-06-27), but that same change silently broke the desktop build, which was never given its own override. Result: the desktop shell'sapiUrl()returned a bare relative path (/api/auth/login) tofetch()from a page loaded attauri://localhost— WebKitGTK has no base to resolve a relative URL against from a non-http(s)origin, and threwDOMException: "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'sbuild.beforeBuildCommandnow setsVITE_API_BASE=http://127.0.0.1:3000inline (VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build) — process env vars override.env.productionin 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 bakes127.0.0.1:3000into 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 (i18nupdate.prompt), thendownloadAndInstall()+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 istauri://localhost(Linux may also sendhttp://tauri.localhost), so the backend'sWS_ALLOWED_ORIGINSmust include both or the live feed won't connect (documented inapps/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 secretsTAURI_SIGNING_PRIVATE_KEY/TAURI_SIGNING_PRIVATE_KEY_PASSWORD. Losing them means no future signed updates — back them up. Verified: a signedpnpm --filter @parking/desktop bundleproduced.deb/.rpm/.AppImageplus their.sigupdater signatures; fullturbo run build lint14/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) — butmca/parking_solutionis private, and the updater runs on offline-first field appliances with no Gitea credentials. Every deployed update check was silently failing (swallowed by atry/catchindesktop-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 fixeddesktop-latesttag (NOT that repo's generic "latest release" redirect, since other apps publishing there would shadow ours — see thedesktop-latestvsdesktop-<TAG>split below)..gitea/workflows/release.ymlpushes 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 aread:repositoryGitea token intauri.conf.json's updaterheadersso 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 rootCLAUDE.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(tagv*) — the signed, versioned release: builds.deb/.rpm/.AppImage+ their.sig(updater key from secrets), assembleslatest.json, and publishes a Gitea Release onmca/parking_solution(source, own record) AND mirrors it tomca/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 intopublic_releasesonly — 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) anddesktop-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 andtauri.conf.json's updater endpoint both point atdesktop-latest. This is what the auto-updater actually consumes..gitea/workflows/build-desktop.yml(push todev/main) — a per-commit test build: compiles.deb+.AppImageonly (pnpm --filter @parking/desktop bundle --bundles deb,appimage) and publishes them to a rolling per-branch pre-release (tagdesktop-<branch>). Unsigned — noTAURI_SIGNING_*, nolatest.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 asrelease.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 mirrorsrelease.yml's proven path — plaincurl+ the built-inGITHUB_TOKENto the Releases API. It DELETEs any existingdesktop-<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}). Sodesktop-devalways holds the newest dev build;v*tags remain the only signed releases. - Gotcha (the unsigned build still demands the key).
tauri.conf.jsonsetsbundle.createUpdaterArtifacts: true(sorelease.ymlproduces the.sigupdater signatures). With that on,tauri buildfails ifTAURI_SIGNING_PRIVATE_KEYis absent — "A public key has been found, but no private key" — even though the.deb/.AppImagethemselves built fine. The unsigned CI build therefore overrides it off with--config '{"bundle":{"createUpdaterArtifacts":false}}'(a JSON patch merged over the config), so no.sigis attempted and no key is required.release.ymlkeeps the config default (signs). - Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1). The git tag
(
v0.1.1) andtauri.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 prior0.1.0: the signed binary was built and named as0.1.0,latest.json(built fromTAG) correctly claimed0.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.ymlnow has a "Sync tauri.conf.json version to the git tag" step (sed-patches"version"fromTAGright beforetauri 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'scheckForDesktopUpdatewrapped the ENTIRE check-download-install-relaunch sequence in onecatch {}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 nestingdownloadAndInstall()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.
- Delivery: a rolling pre-release, NOT
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 byApp.tsxBEFOREfetchMe()/Loginwhenever 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'stestBackendUrl, an unauthenticated- from-the-client's-perspectiveGET /api/versionprobe — see the CSRF gotcha below for why that route isn't actually public), then Save & continue.tauri-plugin-storepersists the value (backend-config.jsonin the OS config dir,autoSave: true) — survives restarts, is NOTlocalStorage(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'sAPI_BASEchanged from aconstto alet, set once viainitApiBase()(called byApp.tsxbefore mount) and again viasetApiBase()after the ConnectScreen saves — no restart required to start using it.- CSP had to loosen, deliberately, to a narrower real boundary.
connect-srcwas'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 rawfetch()/WebSocketfrom the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a regression: all backend traffic already went throughtauri-plugin-http/tauri-plugin-websocketanyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, outsideconnect-src's jurisdiction entirely. The real access boundary moved tocapabilities/default.json'shttp:defaultscope, 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, sohttp://*covers:80(Caddy) whilehttp://*:*is what covers:3000).websocket: defaultalready 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'sDesktopServerButton, in the Setup nav bar next toDesktopVersionBadge(bothinTauri()-gated, invisible in the browser). Confirm-modal (reuses the sharedModal, 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.cookieon desktop.tauri-plugin-http'sfetch()doesn't run through the webview — it's dispatched to Tauri's Rust side and executed byreqwest, which keeps its own cookie jar, entirely separate from WebKitGTK's.Set-Cookieon atauri-plugin-httpresponse 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, sodocument.cookieon thetauri://localhostpage 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 (.cookiesin the app cache dir), so a desktop session survives an app restart just like the browser's 30-day cookie does. Sinceapi.ts'sapiFetchreads the readableparking_csrfcookie viadocument.cookieto echo it inX-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 hardcoded127.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 touchingassertCsrf()'s verification logic at all: the server'ssessionView()(routes/auth.ts, shared byloginandme) 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 readingdocument.cookiewheninTauri(). The actual cookie is still whatassertCsrf()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 HttpOnlyparking_tokencookie — which the WebSocket plugin cannot send.tauri-plugin-websocketis a bare tokio-tungstenite client with no cookie jar at all (its source has no cookie handling); the cookie lives intauri-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 clientPOSTs/api/ws/ticketover 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 anx-ws-ticketheader on the handshake (platform-ws.ts), and the preHandler accepts ticket-or-cookie after the Origin check, then does the samereport:readrole 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 asAuthorization: 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 imagestage-8fa66c9shows LIVE — the first desktop build to do so. - Desktop client logs had never reached
app_logs.logger.ts's flush read the CSRF token fromdocument.cookie(null on desktop — the same jar split as above), so everyPOST /api/logsfrom the desktop 403'd underrequireAuth→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 patchedapi.tsbut notlogger.ts. Fix: the stash moved to a dependency-freelib/desktop-csrf.ts(sologger.tscan read it without importingapi.ts, which importslogger.ts), and the flush uses it wheninTauri().platform-ws.ts's connect failure now goes throughlogClienttoo (rate-limited to one row/min — reconnects are every ≤10 s), instead ofconsole.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 (/healthhas been there all along)./healthnow also returnsapp: "parking-system", and the probe requires both a 2xx and that value. - Why every one of these was found in the field:
tauri devloadshttp://localhost:5173, nottauri://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.jsonnow carries one signed entry per installer —linux-x86_64-deb,linux-x86_64-rpm(when built), and barelinux-x86_64for the AppImage — assembled by a small Node script in the workflow (the.sigfiles for.deb/.rpmwere 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 logsdesktop_update_install_failedtoapp_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 byCould 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=1may paper over the EGL abort; they don't fix the shape. The.debis the right artifact; only its install step needs root. - Passwordless polkit/sudoers for
dpkg -irejected: any rule that lets the operator account pass that prompt silently lets them runpkexec 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 debfiles+ postinstall) that fetcheslatest.jsonfrompublic_releases, verifies the.debwithminisignagainst the same embedded pubkey, anddpkg -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.