createUpdaterArtifacts:true (for release.yml's .sig signing) makes `tauri build`
demand TAURI_SIGNING_PRIVATE_KEY and fail without it — even though the .deb/.AppImage
built fine. Override it off for the unsigned per-commit build via
--config '{"bundle":{"createUpdaterArtifacts":false}}'. release.yml keeps signing.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
13 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| decision |
|
2026-06-21 | 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). VITE_API_BASEwired to the environment:apps/web/.env.production(committed, non-secret, allow-listed in.gitignore) setsVITE_API_BASE=http://127.0.0.1:3000, auto-loaded byvite build(which the desktop bundle runs). So the desktop build targets Fastify with no manual export; the browser-served-by-Fastify build should override to"".- 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). Endpoint is the self-hosted Gitea "latest release" path —https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json— which redirects to the newest tag'slatest.json(published by.gitea/workflows/release.yml). The updater GETs it (200 + manifest, or 204 = up-to-date), readsplatforms.linux-x86_64. {signature,url}, and downloads the signed installer. 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.) - Still deferred: the actual update-hosting URL, 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. This is what the auto-updater consumes. Unchanged..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 uploads them as workflow artifacts (14-day retention). Unsigned — noTAURI_SIGNING_*, no Release, 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).- 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 (the unsigned build still demands the key).