tauri-plugin-updater resolves the download target as {os}-{arch}-{installer}
first (linux-x86_64-deb — the bundler stamps the installer type into the
binary, verified with `strings` on a local .deb) and only then bare
linux-x86_64. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.
latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
36 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).