Files
parking_solution/wiki/decisions/desktop-shell-tauri.md
T
julian 7317042e8d
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
fix(desktop): WS live feed offline — native plugin sends no Origin header
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00

294 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: decision
tags: [parking, decisions, desktop, frontend]
sources: []
updated: 2026-09-03
status: settled
---
# Desktop shell — Tauri v2 (chosen over Electron)
The operator UI ([[react-vite-spa]]) needs to ship as a **desktop application** on the
appliance (kiosk-style), with a **mobile app possible later** but out of scope now. The choice
was **Tauri v2 vs. Electron**. **Decision: Tauri v2.** _(Settled with the user, 2026-06-21.)_
## The thin-shell architecture (why this choice is low-risk)
The desktop shell is a **thin kiosk wrapper around the existing SPA**, nothing more. All
privileged logic — device drivers ([[device-adapter-pattern]]: reader/printer/relay/serial),
[[local-jwt-auth|auth]], the [[append-only-event-chain|signed ledger]], [[tariff]]/[[subscription]]
pricing — **stays in the [[fastify]] server** (settled with the user, 2026-06-21). The shell only
loads the SPA, which talks to the local Fastify server over localhost. Consequences:
- **No device/serial logic is ported into the shell** (no Rust device code for Tauri; no Node
main-process drivers for Electron). The "logic lives in the server" invariant holds.
- If a WebView quirk ever bites, the **blast radius is presentation only** — the server and its
signed ledger are untouched.
This is what neutralizes Tauri's main weakness (host-WebView fragmentation, below): the shell's
job is fullscreen chrome, autostart, and kiosk lockdown — not correctness-critical rendering of
financial truth.
## Why Tauri v2 fits *this* project specifically
- **Threat-model alignment ([[threat-model]]).** The primary adversary is the operator at the
booth. Tauri's **deny-by-default capability/permission model** means the renderer literally
cannot reach the filesystem, shell, or any native command unless we hand it a named, allowlisted
command. That is defense-in-depth that matches "don't trust the booth." Electron's equivalent
hardening (`contextIsolation`, `nodeIntegration:false`, `sandbox:true`, strict CSP) is **opt-in
and easy to misconfigure** into giving the renderer Node access — exactly what this threat model
can't afford.
- **Small footprint / smaller CVE surface.** Tauri uses the **OS WebView** (WebKitGTK on Linux) —
~3–10 MB bundles, tens of MB RAM, and **no bundled Chromium** to patch. Electron ships and pins
its own Chromium (100+ MB, hundreds of MB RAM) and makes us **own Chromium's CVE treadmill** on a
long-lived appliance. On a [[disk-os-hardening|hardened]] single-purpose box maintained for
years, less to patch is a real operational win.
- **License.** Tauri is **MIT / Apache-2.0** — clears the hard MIT/Apache/BSD constraint
([[technology-stack]]). (Electron is also MIT; not a differentiator.)
- **Rust core** is available if device access ever *did* move shell-side — but per the decision
above it does not, so this is latent upside, not a current cost.
## What Electron would have bought (the rejected upside)
- **Version-pinned bundled Chromium** → identical rendering everywhere regardless of host. The most
predictable option on a locked-down appliance image, and the reason this isn't a slam-dunk.
- Largest, most battle-tested kiosk/appliance ecosystem.
- Node in the main process → trivial code-sharing with the Fastify/Node device drivers — but we
explicitly **keep drivers in the server**, so this advantage doesn't apply here.
Rejected because the heavy footprint, the Chromium CVE-patching obligation, and the opt-in (easy
to get wrong) security posture all cut against the appliance + threat-model constraints, while its
one real advantage (bundled Chromium) is only conditionally needed — see the open question.
## Target deployment — best case vs. worst case
The decision's risk collapses to **which OS the appliance actually runs** (user, 2026-06-21):
- **Best case — Ubuntu 26.04 LTS desktop (the intended appliance).** Ships a **current,
distro-maintained WebKitGTK** (`webkit2gtk-4.1` / GTK4), patched by Canonical for the LTS
lifetime. This **closes** the WebView risk below — no ancient-WebView problem, no CVE-patching
burden on us. A native, hardened, single-purpose box that matches the [[disk-os-hardening]]
platform decision. **Tauri belongs here; the decision is unconditional in this world.**
- **Worst case — Windows 11 + WSL + Docker.** This is **not** a "use Electron instead" fallback —
it **contradicts the [[standing-decisions|standing platform decision]]** (explicitly *"a
dedicated, hardened Linux appliance, **not Windows/WSL**"*) and undermines
[[disk-os-hardening|Secure Boot / LUKS / tamper resistance]] against the booth operator
([[threat-model]]). Moreover a **desktop GUI shell does not naturally live inside WSL/Docker**
(both are headless Linux). The realistic shape there is **no native shell at all**: run
[[fastify]] + the SPA in the WSL/Docker backend, and open the SPA in a **kiosk browser** on
Windows (`msedge`/`chrome --kiosk --app=http://localhost:PORT`). Electron is warranted **only**
if a self-contained installable Windows `.exe` (no system browser) is a hard requirement.
The **thin-shell architecture makes the worst-case fallback cheap**: because all logic lives in
[[fastify]], dropping the shell for a kiosk browser costs only the native window wrapper, not any
functionality.
| Deployment | Desktop shell |
| --- | --- |
| **Ubuntu 26.04 LTS** (best, intended) | **Tauri v2** — current WebKitGTK, native, hardened. Decision stands unconditionally. |
| **Windows 11 + WSL + Docker** (worst, conflicts with platform decision) | **No native shell — kiosk browser** at the local Fastify-served SPA. Electron only if a standalone Windows installer is required. |
## The one thing to verify (procurement / image gate)
Tauri's rendering correctness depends on the **WebKitGTK version that ships on the target
appliance OS image**. On a hardened/pinned image this can be old and cause rendering quirks — pin
it and test the built SPA against that **exact** WebView. **On the intended Ubuntu 26.04 LTS this
is effectively resolved** (current distro-maintained WebKitGTK); the concern only bites on an
unexpected image with an ancient/unavailable WebView, which would point to the kiosk-browser path
(or Electron) above. Tracked as an [[open-questions|open question]].
## Invariants this decision must preserve
1. **Server owns all privileged logic.** The shell is presentation only; device/auth/ledger/pricing
stay in [[fastify]]. Don't let "convenient native access" pull driver logic into the shell.
2. **Deny-by-default native surface.** Expose Tauri commands one at a time, allowlisted; never open
a broad filesystem/shell capability to the renderer ([[threat-model]]).
3. **[[offline-first]].** The shell, its updater, and any WebView must work air-gapped; no decision
here may introduce a network dependency in core operation.
4. **Mobile later, not now.** A future mobile app is a separate target; don't pre-build for it.
## As-built (scaffolded 2026-06-21)
`apps/desktop` — a Tauri v2 shell, its own pnpm/Turbo package, wrapping the **same** `apps/web`
SPA so the desktop and browser UIs **cannot drift** (one UI codebase; requirement from the user):
- **Dev:** `tauri dev` loads `http://localhost:5173` (the `@parking/web` Vite dev server) →
editing a component in `apps/web` updates the desktop window via HMR live. `beforeDevCommand`
starts the web dev server.
- **Prod:** `frontendDist: ../../web/dist` bundles the built SPA into the binary;
`beforeBuildCommand` rebuilds it first.
- **Backend origin:** the SPA used relative `/api` + a `window.location.host` WS URL — fine in a
browser, broken from `tauri://localhost`. Centralized into `apps/web/src/lib/origin.ts`
(`API_BASE`/`apiUrl`/`wsUrl`), read from **`VITE_API_BASE`** (empty in the browser = unchanged;
set to the Fastify origin for the desktop build). The `tauri.conf.json` CSP `connect-src`
whitelists `127.0.0.1:3000`/`localhost:3000` http+ws; the backend's `WS_ALLOWED_ORIGINS` must
include the Tauri origin.
- **Thin shell, enforced:** the Rust crate (`parking_desktop_lib::run`) registers **no commands**;
the capability set is `core:default` only — no fs/shell/device access to the renderer
(invariants 1–2). All logic stays in [[fastify]].
- **Turbo:** `build` is a **no-op** (so `turbo run build` stays fast); the real bundle is a
deliberate `pnpm --filter @parking/desktop bundle` (the vision-shim pattern).
- **Verified:** `cargo check` + a full `tauri build` compiled the Rust/WebKitGTK/wry stack and
produced working `.deb`/`.rpm`/`.AppImage` bundles; `pnpm turbo run build lint` → 14/14 green
(was 12). All Linux prereqs present (Rust 1.93, WebKitGTK 4.1, libsoup-3, WSLg display).
### Window / kiosk, auto-update, env (added 2026-06-21)
Per the user's choices — the operator **keeps OS access** (no fullscreen lockdown):
- **Window:** starts **maximized** (`maximized: true`), not fullscreen, resizable. No OS-key
blocking, no always-on-top — the booth PC stays usable as a PC.
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
prod build and the desktop build (same SPA).
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
from inside it is blocked as **mixed content**, a long-standing WebKit limitation
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the
CSP does **not** override this — it's a different browser security layer entirely, so the request
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
fetch/WebSocket, which sidesteps the check entirely:
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
global `fetch` now.
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
browser `WebSocket` would. The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
passes `{ headers: { Origin: "tauri://localhost" } }` explicitly; separately, `komodo/
resources.toml`'s booth Stacks had `WS_ALLOWED_ORIGINS=` **empty** in production (despite
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
a live booth, it isn't automatic from a git push alone.
- **`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.