Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec | |||
| 4a7029cea6 | |||
| 7317042e8d |
Generated
+29
@@ -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"
|
||||
|
||||
@@ -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://*:*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@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
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,18 @@
|
||||
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
||||
// network — the appliance is usually offline) is swallowed; updates only happen
|
||||
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
||||
//
|
||||
// A release build's console.error is invisible with no way to attach devtools
|
||||
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
|
||||
// remote inspector doesn't answer standard discovery endpoints either — both
|
||||
// confirmed dead ends 2026-09-03). logClient() ships straight to the
|
||||
// server-side app_logs store regardless of the client's console-forward log
|
||||
// level (that gate is meant for noisy console chatter, not this), so a real
|
||||
// post-accept install failure is visible via wiki/concepts/app-logs.md /
|
||||
// LogsViewer.tsx without needing a terminal or devtools at all.
|
||||
|
||||
/** True when running inside the Tauri webview (not a normal browser). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
import { logClient } from "./logger.js";
|
||||
import { inTauri } from "./tauri-env.js";
|
||||
|
||||
export interface UpdatePrompt {
|
||||
/** Newer version string offered by the server. */
|
||||
@@ -42,13 +49,24 @@ export async function checkForDesktopUpdate(
|
||||
// Download + install the signed update (signature verified against the
|
||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||
try {
|
||||
await update.downloadAndInstall();
|
||||
await update.downloadAndInstall((progress) => {
|
||||
logClient({
|
||||
level: "info",
|
||||
message: `desktop update download progress: ${progress.event}`,
|
||||
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// A real update WAS found and accepted — this is a genuine install
|
||||
// failure (bad signature, corrupted download, disk/permission issue),
|
||||
// not "offline". Surface it instead of silently reverting to the old
|
||||
// version with no explanation.
|
||||
console.error("desktop update download/install failed:", err);
|
||||
logClient({
|
||||
level: "error",
|
||||
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
context: { kind: "desktop_update_install_failed", version: update.version },
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
@@ -56,7 +74,13 @@ export async function checkForDesktopUpdate(
|
||||
} catch (err) {
|
||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||
// keeps running on the current version; checking again next launch. Still
|
||||
// log it so a real install failure (rethrown above) isn't invisible.
|
||||
console.warn("desktop update check/apply skipped:", err);
|
||||
// log it (info, not error — this path is expected/normal far more often
|
||||
// than it's a real problem) so a real install failure (rethrown above,
|
||||
// logged as error) isn't lost among routine offline checks.
|
||||
logClient({
|
||||
level: "info",
|
||||
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||
context: { kind: "desktop_update_skipped" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
export { inTauri };
|
||||
|
||||
/**
|
||||
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||
|
||||
@@ -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;
|
||||
@@ -62,7 +64,13 @@ class TauriSocketAdapter implements PlatformSocket {
|
||||
try {
|
||||
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||
if (this.#closed) return; // close() called before connect resolved
|
||||
const conn = await TauriWebSocket.connect(url);
|
||||
// Runs on Tauri's native (Rust) side, NOT inside the webview page — there
|
||||
// is no page context to auto-attach an Origin header the way a real
|
||||
// browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts)
|
||||
// rejects any handshake with a missing/mismatched Origin, so it must be
|
||||
// set explicitly here to match what WS_ALLOWED_ORIGINS expects
|
||||
// (tauri://localhost — see apps/server/.env.example).
|
||||
const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } });
|
||||
if (this.#closed) {
|
||||
void conn.disconnect();
|
||||
return;
|
||||
@@ -78,7 +86,8 @@ class TauriSocketAdapter implements PlatformSocket {
|
||||
// routes/ws.ts) — nothing else is expected.
|
||||
});
|
||||
this.onopen?.();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("Tauri WebSocket connect failed:", url, err);
|
||||
this.onerror?.();
|
||||
this.onclose?.();
|
||||
}
|
||||
@@ -90,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+76
-1
@@ -6,7 +6,7 @@ import {
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||
@@ -30,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
import { inTauri } from "./lib/origin.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||
import { StatusDot } from "./ui/StatusDot.js";
|
||||
@@ -106,6 +107,78 @@ function VersionBadge() {
|
||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||
}
|
||||
|
||||
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||||
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||||
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||||
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||||
* build was actually installed short of reading the update-available prompt. */
|
||||
function DesktopVersionBadge() {
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!inTauri()) return;
|
||||
let cancelled = false;
|
||||
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||
getVersion().then((v) => {
|
||||
if (!cancelled) setVersion(v);
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
if (!version) return null;
|
||||
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. */
|
||||
@@ -125,6 +198,8 @@ function SetupLayout() {
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
<DesktopVersionBadge />
|
||||
<DesktopServerButton />
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -49,10 +49,13 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-28bd838
|
||||
TAG=stage-7d67934
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||
@@ -79,10 +82,13 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-28bd838
|
||||
TAG=stage-7d67934
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||
JWT_SECRET=[[park_2_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||
BACKUP_KEY=[[park_2_backup_key]]
|
||||
|
||||
Generated
+13
@@ -108,12 +108,18 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.170.16
|
||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.11.1
|
||||
version: 2.11.1
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.5.2
|
||||
version: 2.6.0
|
||||
'@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
|
||||
@@ -1589,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==}
|
||||
|
||||
@@ -4109,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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: decision
|
||||
tags: [parking, decisions, desktop, frontend]
|
||||
sources: []
|
||||
updated: 2026-09-03
|
||||
updated: 2026-09-04
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -164,6 +164,30 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
||||
- `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 — and see [[fleet-deployment-komodo]] for
|
||||
a real ResourceSync-branch gotcha this exact fix ran into.
|
||||
- **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in
|
||||
`router.tsx` shows the *server's* `<branch>-<sha>` (from `/api/version`, gated `site:read`) — but
|
||||
nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way
|
||||
to confirm which build was actually installed short of reading the update-available prompt's
|
||||
target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`:
|
||||
calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from
|
||||
`tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above),
|
||||
no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of
|
||||
redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive
|
||||
via the plugins).
|
||||
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||
@@ -278,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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: decision
|
||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||
sources: []
|
||||
updated: 2026-07-07
|
||||
updated: 2026-09-03
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -192,3 +192,32 @@ FILE is read from, each stack's `branch` picks its compose files, `TAG` picks th
|
||||
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
||||
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
||||
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
||||
|
||||
## ResourceSync branch drift — the exact gotcha this page already warned about (2026-09-03)
|
||||
|
||||
This page's own §"park-lab" note (2026-07-07) already spelled it out: *"the ResourceSync's own
|
||||
branch only governs where the FILE is read from"* — independent of any `[[stack]]`'s own `branch`
|
||||
field. It bit anyway. `resource-sync-park-systems` in Komodo Core was pointed at **`dev`**, while
|
||||
`park-buzi` and `park-2` are `stage`-tier Stacks (`branch = "stage"`, pinned `TAG=stage-<sha>`, per
|
||||
the promotion-tiers model above). `resources.toml` had been byte-identical on `dev` and `stage`
|
||||
since park-buzi's Stack was first written, so this had **zero observable effect for months** — until
|
||||
a desktop-app debugging session (see [[desktop-shell-tauri]]) landed 9 real commits on `dev`
|
||||
(including a `WS_ALLOWED_ORIGINS` fix) that were never merged to `stage`, creating the first genuine
|
||||
divergence between the two branches.
|
||||
|
||||
**Symptom:** merged `dev` → `stage`, pushed, bumped `TAG` in `resources.toml` on `stage`, committed,
|
||||
pushed — then destroyed + recreated the `park-2` Stack in Komodo Core and it STILL came back running
|
||||
the old image. Every sync was silently re-reading `resources.toml` from `dev` (which still had the
|
||||
stale `TAG`), overwriting the correct value just committed on `stage`. No error, no warning — the
|
||||
sync just quietly did what it was configured to do, from the wrong branch.
|
||||
|
||||
**Fix:** pointed `resource-sync-park-systems` at `stage` in Komodo Core's UI (Sync config → branch
|
||||
field), then re-synced + redeployed `park-2` — confirmed via `/api/version` (previously 404,
|
||||
proving a stale image; correctly 401-auth-gated after the fix, proving the new image + route exist).
|
||||
|
||||
**Standing lesson, now written twice:** a `[[stack]]`'s promotion tier (which branch its own
|
||||
`branch`/`TAG` fields track) and the ResourceSync resource's own git branch are **two independently
|
||||
configured settings in Komodo Core — nothing enforces they agree**, and a mismatch is invisible
|
||||
until the two branches' `resources.toml` actually diverge. **Check this FIRST** whenever a
|
||||
redeploy doesn't pick up an expected `resources.toml` change, before assuming the change itself,
|
||||
the CI build, or the deploy step is broken.
|
||||
|
||||
+62
@@ -2779,3 +2779,65 @@ HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch(
|
||||
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
|
||||
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
|
||||
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
|
||||
|
||||
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
|
||||
|
||||
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
|
||||
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
|
||||
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
|
||||
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
|
||||
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
|
||||
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
|
||||
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
|
||||
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
|
||||
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||||
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||||
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||||
|
||||
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
|
||||
|
||||
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
|
||||
but logger.ts only forwards console output to the server when the client log level is debug/trace
|
||||
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
|
||||
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
|
||||
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
|
||||
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
|
||||
the console-forward gate entirely — a genuine post-accept install failure now always reaches
|
||||
app_logs regardless of client log level. Also added download-progress logging. Separately: found
|
||||
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
|
||||
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
|
||||
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
|
||||
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
|
||||
|
||||
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
|
||||
|
||||
There was no way to see which desktop build was actually installed anywhere in the app — an
|
||||
operator debugging a stuck update had to infer it backwards from the update prompt's target
|
||||
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
|
||||
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]].
|
||||
|
||||
Reference in New Issue
Block a user