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:
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user