Files
parking_solution/wiki/decisions/desktop-shell-tauri.md
T
julian faa3265e49
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
fix(desktop): restore VITE_API_BASE for the desktop build
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00

235 lines
17 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).
- **`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).