feat(desktop): runtime-configurable backend server address

The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
This commit is contained in:
2026-09-04 10:32:03 +02:00
parent 969bf2b191
commit 80618ba028
21 changed files with 575 additions and 47 deletions
+29
View File
@@ -2305,6 +2305,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-websocket",
]
@@ -3793,6 +3794,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -4301,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
+4
View File
@@ -33,6 +33,10 @@ tauri-plugin-http = "2"
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
# are separate browser checks, so this needs its own plugin.
tauri-plugin-websocket = "2"
# Persists the operator-configured backend URL (host:port of the Fastify
# server this install talks to) across restarts. Read before any API call —
# see apps/web/src/lib/backend-config.ts.
tauri-plugin-store = "2"
[features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
@@ -8,11 +8,15 @@
"updater:default",
"process:default",
"websocket:default",
"store:default",
{
"identifier": "http:default",
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
"allow": [
{ "url": "http://127.0.0.1:3000" },
{ "url": "http://localhost:3000" }
{ "url": "http://*" },
{ "url": "https://*" },
{ "url": "http://*:*" },
{ "url": "https://*:*" }
]
}
]
+10 -6
View File
@@ -3,9 +3,10 @@
// Intentionally minimal: build the default Tauri app and run it. The window
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
// keeps the shell a thin presentation wrapper with a deny-by-default native
// surface (see wiki/decisions/desktop-shell-tauri.md).
// the backend over HTTP to a Fastify server (address operator-configured at
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -16,13 +17,16 @@ pub fn run() {
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the local Fastify server through
// Tauri's native HTTP client — see the Cargo.toml comment on why the
// webview's own fetch() can't reach http://127.0.0.1:3000 directly.
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
// server through Tauri's native HTTP client — see the Cargo.toml
// comment on why the webview's own fetch() can't reach it directly.
.plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check).
.plugin(tauri_plugin_websocket::init())
// Persists the operator-configured backend URL across restarts (JSON
// file in the app's config dir) — see backend-config.ts.
.plugin(tauri_plugin_store::Builder::new().build())
.run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell");
}
+2 -2
View File
@@ -7,7 +7,7 @@
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
"beforeBuildCommand": "pnpm --filter @parking/web build"
},
"app": {
"windows": [
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
}
},
"bundle": {
+21 -3
View File
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
/** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth. */
* permissions are the source of truth.
*
* `csrf`, when passed, echoes the SAME value already sent as the readable
* parking_csrf cookie — not a new secret, just a second channel to learn it.
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
* so document.cookie on the tauri://localhost page never sees a cookie set
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
* The cookie itself IS still sent back to the server by reqwest on
* subsequent requests — only the *client-side read* is broken — so
* api.ts's desktop path stashes this body value in memory instead of
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
* ../auth.ts (unchanged — this never touches verification, only how the
* desktop client learns what to send). */
function sessionView(
db: Db,
user: {
@@ -78,6 +92,7 @@ function sessionView(
fullName?: string | null;
email?: string | null;
},
csrf?: string,
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
@@ -92,6 +107,7 @@ function sessionView(
fontScale: user.fontScale,
fullName: user.fullName ?? null,
email: user.email ?? null,
...(csrf ? { csrfToken: csrf } : {}),
};
}
@@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
setAuthCookies(reply, token, csrf);
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
// read from the DB, so changing it needs no token refresh.
return sessionView(db, user);
return sessionView(db, user, csrf);
});
app.post("/api/auth/logout", async (_req, reply) => {
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
return sessionView(db, row);
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
return sessionView(db, row, req.user.csrf);
},
);
+1
View File
@@ -21,6 +21,7 @@
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-websocket": "^2.3.0",
"i18next": "^26.3.1",
+33 -3
View File
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js";
import { ConnectScreen } from "./ConnectScreen.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { router } from "./router.js";
import { initApiBase, inTauri } from "./lib/origin.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
// off to TanStack Router inside the QueryClient provider. The router renders the
// terminal chrome + screens; auth gating stays here (Login until signed in), and
// the signed-in user flows into the router context for role-based route guards.
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
//
// Desktop shell only: BEFORE any of that, the backend origin itself must be
// known — the same installer is used at every booth (see lib/origin.ts /
// backend-config.ts), so on first launch (or after the operator clears it)
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
// a browser build always has a same-origin backend, so `needsConnect` is
// always false there and this is skipped entirely.
export function App() {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
const [needsConnect, setNeedsConnect] = useState(false);
useEffect(() => {
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
initApiBase().then((saved) => {
if (inTauri() && !saved) {
setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
});
}, []);
// Apply the signed-in user's preferred language + theme + font scale whenever they
@@ -41,6 +58,19 @@ export function App() {
if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
}
if (needsConnect) {
return (
<ConnectScreen
onConnected={() => {
setNeedsConnect(false);
setLoading(true);
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
}}
/>
);
}
if (!user) {
return (
<QueryClientProvider client={queryClient}>
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { setApiBase } from "./lib/origin.js";
// Desktop-only gate shown BEFORE Login whenever no backend has been
// configured yet (first launch of a generic .deb/.AppImage install, or after
// the operator clears it from Settings). Same installer works at any booth —
// see backend-config.ts for why this can't be a build-time value.
//
// backend-config.ts is imported dynamically (not at module top-level) purely
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
// imports — this component itself only ever renders inside Tauri anyway, so
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
// warning from Vite (a static import here would defeat those other dynamic
// imports' chunk-splitting intent).
function normalizeHost(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
}
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
const [detail, setDetail] = useState<string | undefined>(undefined);
const url = normalizeHost(host);
const canSubmit = url.length > 0 && !testing && !saving;
async function handleTest(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setTesting(true);
setResult(null);
setDetail(undefined);
try {
const { testBackendUrl } = await import("./lib/backend-config.js");
const check = await testBackendUrl(url);
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
setDetail(check.detail);
} finally {
setTesting(false);
}
}
async function handleSave() {
setSaving(true);
try {
const { saveBackendUrl } = await import("./lib/backend-config.js");
await saveBackendUrl(url);
setApiBase(url);
onConnected();
} finally {
setSaving(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
{t("connect.title")}
</h1>
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
<div className="field mb-3">
<label className="label">{t("connect.serverAddress")}</label>
<input
className="input"
value={host}
onChange={(e) => {
setHost(e.target.value);
setResult(null);
}}
placeholder="192.168.1.50:3000"
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{result === "ok" && (
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
)}
{result === "unreachable" && (
<p className="mb-3 text-[0.75rem] text-term-red">
{t("connect.testUnreachable")}
{detail ? ` (${detail})` : ""}
</p>
)}
{result === "bad_response" && (
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
{testing ? t("connect.testing") : t("connect.test")}
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={!canSubmit || result !== "ok"}
onClick={handleSave}
>
{saving ? t("connect.saving") : t("connect.save")}
</button>
</div>
</form>
</main>
);
}
+44 -9
View File
@@ -1,12 +1,25 @@
// Thin API client for the operator/admin UI.
//
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
// automatically (credentials: 'include'). For mutations we echo the readable
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
// back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
//
// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's
// reqwest, which keeps its OWN cookie jar separate from the webview —
// document.cookie on the tauri://localhost page never sees a cookie set on a
// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The
// cookie itself IS still sent back to the server by reqwest on later
// requests (only the client-side *read* is broken), so the server also
// echoes the token in the login/me response BODY (sessionView's csrfToken —
// see routes/auth.ts) purely as a second channel for the desktop client to
// learn the value; desktopCsrfToken below stashes it in memory and
// setSessionUser() (called wherever a SessionUser is received) keeps it
// current. The browser path is untouched — it still reads document.cookie.
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js";
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
@@ -17,6 +30,18 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : null;
}
/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a
* fresh app launch always logs in again, or bootstraps via /api/auth/me
* which re-supplies it). */
let desktopCsrfToken: string | null = null;
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
* (login, fetchMe). No-op / cheap in the browser (the value just goes
* unused there — reads still come from document.cookie). */
function setSessionUser(user: SessionUser): void {
if (user.csrfToken) desktopCsrfToken = user.csrfToken;
}
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
@@ -25,7 +50,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE);
const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
@@ -82,6 +107,10 @@ export interface SessionUser {
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
* browser build, which reads the cookie directly instead. */
csrfToken?: string;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -89,15 +118,19 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
return !!user && user.permissions.includes(perm);
}
export function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", {
export async function login(username: string, password: string): Promise<SessionUser> {
const user = await apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setSessionUser(user);
return user;
}
export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" });
export async function logout(): Promise<{ ok: boolean }> {
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
desktopCsrfToken = null;
return res;
}
/** Persist the current user's UI language preference (restored on next login). */
@@ -146,7 +179,9 @@ export function changeMyPassword(
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
return await apiFetch<SessionUser>("/api/auth/me");
const user = await apiFetch<SessionUser>("/api/auth/me");
setSessionUser(user);
return user;
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e;
+88
View File
@@ -0,0 +1,88 @@
// Desktop-only: the operator-configured backend origin (host:port of the
// Fastify server this install talks to), persisted across restarts.
//
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
// specific booth, so the backend address can't be baked in at build time
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
// same installer at a different appliance). Instead the operator enters it
// once in the ConnectScreen (shown before login whenever nothing usable is
// stored yet) and it's saved to a JSON file in the OS config dir via
// tauri-plugin-store, read back on every launch before any API call.
//
// Browser build: this module is never reached (inTauri() gates every call
// site — see origin.ts), so there is no browser equivalent or fallback here.
import type { Store } from "@tauri-apps/plugin-store";
const STORE_FILE = "backend-config.json";
const KEY = "backendUrl";
let storeHandle: Store | null = null;
async function getStore(): Promise<Store> {
if (!storeHandle) {
const { load } = await import("@tauri-apps/plugin-store");
storeHandle = await load(STORE_FILE, { autoSave: true });
}
return storeHandle;
}
/** The saved backend origin (no trailing slash), or null if never configured.
* Desktop only — throws if called from a browser build. */
export async function loadBackendUrl(): Promise<string | null> {
const store = await getStore();
const v = await store.get<string>(KEY);
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
}
/** Persist a new backend origin (validated + reachable — call testBackendUrl
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
export async function saveBackendUrl(url: string): Promise<void> {
const store = await getStore();
await store.set(KEY, url.replace(/\/$/, ""));
await store.save();
}
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
export async function clearBackendUrl(): Promise<void> {
const store = await getStore();
await store.delete(KEY);
await store.save();
}
export interface BackendCheck {
ok: boolean;
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
reason?: "unreachable" | "bad_response";
detail?: string;
}
/** Probe a candidate origin by hitting /api/version. That route is behind
* requirePermission("site:read") (session cookie + site:read — see
* apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx;
* we're not checking "is this reachable and mine to use", only "is something
* that speaks our Fastify auth protocol listening here" — a 401 (missing/bad
* JWT) or 403 (valid session, wrong permission) from THIS specific route is
* as strong a signal of that as a 200 would be, and both are expected outcomes
* pre-login. Uses the same tauri-plugin-http path platformFetch does (raw
* fetch from the webview can't reach an arbitrary LAN host — mixed content,
* see origin.ts). */
export async function testBackendUrl(url: string): Promise<BackendCheck> {
const origin = url.replace(/\/$/, "");
try {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const res = await tauriFetch(`${origin}/api/version`, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!res.ok && res.status !== 401 && res.status !== 403) {
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
}
return { ok: true };
} catch (err) {
return {
ok: false,
reason: "unreachable",
detail: err instanceof Error ? err.message : String(err),
};
}
}
+1 -5
View File
@@ -20,11 +20,7 @@
// LogsViewer.tsx without needing a terminal or devtools at all.
import { logClient } from "./logger.js";
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
import { inTauri } from "./tauri-env.js";
export interface UpdatePrompt {
/** Newer version string offered by the server. */
+14
View File
@@ -42,6 +42,20 @@ export const en: Catalog = {
signIn: "Sign in",
signingIn: "Signing in…",
},
connect: {
title: "Connect to server",
hint: "Enter the address of the parking system server for this booth.",
serverAddress: "Server address",
test: "Test",
testing: "Testing…",
save: "Save & continue",
saving: "Saving…",
testOk: "Reachable — this looks like a Parking System server.",
testUnreachable: "Could not reach this address.",
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
changeServer: "Change server",
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart?",
+14
View File
@@ -45,6 +45,20 @@ export const sq = {
signIn: "Hyr",
signingIn: "Duke hyrë…",
},
connect: {
title: "Lidhu me serverin",
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
serverAddress: "Adresa e serverit",
test: "Testo",
testing: "Duke testuar…",
save: "Ruaj & vazhdo",
saving: "Duke ruajtur…",
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
testUnreachable: "Nuk u arrit kjo adresë.",
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
changeServer: "Ndrysho serverin",
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
},
update: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
+32 -11
View File
@@ -3,16 +3,19 @@
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
// at build time so /api and the live WS feed resolve to the real server.
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
// installer is used across every booth and the backend can move (new box, new
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
//
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
// WebSocket both read it, so the web app and the desktop shell stay identical
// except for this one build-time value.
// except for this one runtime value.
//
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed
// content (a WebKit limitation — CSP's connect-src does NOT override this;
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
@@ -20,8 +23,29 @@
// the check entirely. Browser build never imports the plugin (dynamic import,
// same pattern as desktop-updater.ts).
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
import { inTauri } from "./tauri-env.js";
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
export let API_BASE: string = "";
/** Desktop only: load the persisted backend URL (if any) before the app
* mounts, so the very first fetchMe() call already has the right origin.
* No-op in the browser. Returns the loaded value (null = not configured yet,
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
export async function initApiBase(): Promise<string | null> {
if (!inTauri()) return null;
const { loadBackendUrl } = await import("./backend-config.js");
const saved = await loadBackendUrl();
if (saved) API_BASE = saved;
return saved;
}
/** Desktop only: change the backend origin at runtime (after the operator
* saves a new one in Settings) without requiring a full app restart. */
export function setApiBase(url: string): void {
API_BASE = url.replace(/\/$/, "");
}
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
export function apiUrl(path: string): string {
@@ -38,10 +62,7 @@ export function wsUrl(path: string): string {
return `${proto}//${window.location.host}${path}`;
}
/** True when running inside the Tauri webview (not a normal browser). */
export function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export { inTauri };
/**
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
+2 -5
View File
@@ -17,6 +17,8 @@
// Browser build: plain pass-through to the real WebSocket (this file's
// createPlatformSocket is only called from inside inTauri() callers).
import { inTauri } from "./tauri-env.js";
export interface PlatformSocket {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
@@ -97,11 +99,6 @@ class TauriSocketAdapter implements PlatformSocket {
}
}
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
export function createPlatformSocket(url: string): PlatformSocket {
+6
View File
@@ -0,0 +1,6 @@
/** True when running inside the Tauri webview (not a normal browser). Single
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
* backend-config.ts all gate their Tauri-only code paths on it. */
export function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
+50
View File
@@ -130,6 +130,55 @@ function DesktopVersionBadge() {
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
}
/** Desktop-only "change which server this install talks to" control. No-op /
* renders nothing in a browser (the concept doesn't apply — same-origin).
* Simplest correct action: clear the saved backend URL and reload, which
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
* mirrors clearing the session (logout → back to Login), not an inline
* editor, since repointing the app is a rare, deliberate admin action. */
function DesktopServerButton() {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
if (!inTauri()) return null;
return (
<>
<button
type="button"
className="btn btn-ghost btn-sm ml-2"
onClick={() => setConfirming(true)}
>
{t("connect.changeServer")}
</button>
{confirming && (
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
<div className="text-[0.8125rem]">
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
{t("subs.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busy}
onClick={async () => {
setBusy(true);
const { clearBackendUrl } = await import("./lib/backend-config.js");
await clearBackendUrl();
window.location.reload();
}}
>
{busy ? <Spinner /> : t("connect.changeServer")}
</button>
</div>
</div>
</Modal>
)}
</>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
* deep links and the back button work and a denied tab redirects to the booth. */
@@ -150,6 +199,7 @@ function SetupLayout() {
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav>
<Outlet />
</div>
+10
View File
@@ -117,6 +117,9 @@ importers:
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-store':
specifier: ^2.4.0
version: 2.4.4
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
@@ -1592,6 +1595,9 @@ packages:
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-store@2.4.4':
resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
@@ -4112,6 +4118,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-store@2.4.4':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.11.1
+68 -1
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, decisions, desktop, frontend]
sources: []
updated: 2026-09-03
updated: 2026-09-04
status: settled
---
@@ -302,3 +302,70 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
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.
### 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|mca/public_releases]] — 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 by `App.tsx` BEFORE `fetchMe()`/`Login` whenever 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`'s `testBackendUrl`, an unauthenticated-
from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that
route isn't actually public), then **Save & continue**.
- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir,
`autoSave: true`) — survives restarts, is NOT `localStorage` (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`'s `API_BASE` changed from a `const` to a `let`, set once via
`initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the
ConnectScreen saves — no restart required to start using it.
- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was
`'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 raw `fetch()`/`WebSocket`
from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a
regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket`
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
`connect-src`'s jurisdiction entirely. The real access boundary moved to
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri
scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket:
default` already 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`'s `DesktopServerButton`, in the Setup nav bar next to
`DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses
the shared `Modal`, 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.cookie` on desktop.** `tauri-plugin-http`'s
`fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by
`reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on
a `tauri-plugin-http` response 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, so `document.cookie` on the `tauri://localhost` page
can never see it. This is an open, unresolved upstream Tauri bug
([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045),
[#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by
changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
cookie via `document.cookie` to echo it in `X-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
hardcoded `127.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 touching
`assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`,
shared by `login` and `me`) 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 reading `document.cookie` when `inTauri()`. The actual cookie is still what
`assertCsrf()` 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.
+24
View File
@@ -2817,3 +2817,27 @@ version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBa
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
Full detail on [[desktop-shell-tauri]].
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
connect-src tightened to 'self' only (all backend traffic already went through
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate
from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the
server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was first built, regardless of
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified,
and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the
value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]].