fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is blocked as mixed content, a WebKit limitation CSP's connect-src can't override. Added tauri-plugin-http (genuine fetch() drop-in, wired via a new platformFetch() in origin.ts, used by api.ts + logger.ts) and tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket- shaped interface in the new platform-ws.ts so use-live-feed.ts needed no changes). Both route through Tauri's Rust side instead of the webview's own fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching the existing CSP allowlist.
This commit is contained in:
+2
-2
@@ -6,7 +6,7 @@
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl } from "./lib/origin.js";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
@@ -28,7 +28,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||
}
|
||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
import { apiUrl, platformFetch } from "./origin.js";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
@@ -76,7 +77,7 @@ async function flush(): Promise<void> {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await fetch(ENDPOINT, {
|
||||
await platformFetch(apiUrl(ENDPOINT), {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
@@ -90,7 +91,10 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
||||
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
||||
* so this drops silently in the desktop shell (unload is rare there; the regular
|
||||
* 4s-interval flush above covers the common case). */
|
||||
function flushBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
@@ -99,7 +103,7 @@ function flushBeacon(): void {
|
||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// through Tauri's native side instead of the webview's own fetch, sidestepping
|
||||
// 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(/\/$/, "");
|
||||
@@ -28,3 +37,22 @@ export function wsUrl(path: string): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||
* shell (see the file header for why the webview's own fetch can't reach
|
||||
* the local backend). Same signature as the global fetch; a plain pass-
|
||||
* through in the browser.
|
||||
*/
|
||||
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
if (inTauri()) {
|
||||
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||
return tauriFetch(input, init);
|
||||
}
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Desktop-only WebSocket adapter.
|
||||
//
|
||||
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
|
||||
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
|
||||
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
|
||||
// but WS is a separate browser check with its own plugin
|
||||
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
|
||||
// native side instead of the webview's own WebSocket.
|
||||
//
|
||||
// That plugin's API is async/listener-based, not the synchronous
|
||||
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
|
||||
// (and has already been hardened for — reconnect backoff, StrictMode
|
||||
// double-invoke, cleanup). Rather than rewrite that hook around a different
|
||||
// API shape, this adapter presents the same native-WebSocket-like interface
|
||||
// use-live-feed.ts already expects, so that hook needs no changes at all.
|
||||
//
|
||||
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||
// createPlatformSocket is only called from inside inTauri() callers).
|
||||
|
||||
export interface PlatformSocket {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
class NativeSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#sock: WebSocket;
|
||||
|
||||
constructor(url: string) {
|
||||
this.#sock = new WebSocket(url);
|
||||
this.#sock.onopen = () => this.onopen?.();
|
||||
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
|
||||
this.#sock.onclose = () => this.onclose?.();
|
||||
this.#sock.onerror = () => this.onerror?.();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#sock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class TauriSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#closed = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#conn: any = null;
|
||||
|
||||
constructor(url: string) {
|
||||
void this.#connect(url);
|
||||
}
|
||||
|
||||
async #connect(url: string): Promise<void> {
|
||||
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);
|
||||
if (this.#closed) {
|
||||
void conn.disconnect();
|
||||
return;
|
||||
}
|
||||
this.#conn = conn;
|
||||
conn.addListener((msg: { type: string; data: unknown }) => {
|
||||
if (msg.type === "Text") {
|
||||
this.onmessage?.({ data: msg.data as string });
|
||||
} else if (msg.type === "Close") {
|
||||
this.onclose?.();
|
||||
}
|
||||
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
|
||||
// routes/ws.ts) — nothing else is expected.
|
||||
});
|
||||
this.onopen?.();
|
||||
} catch {
|
||||
this.onerror?.();
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
void this.#conn?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||
import { wsUrl } from "./origin.js";
|
||||
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
||||
|
||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||
@@ -36,7 +37,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
||||
useLiveStore();
|
||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
const sockRef = useRef<PlatformSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
@@ -50,7 +51,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
||||
const connect = () => {
|
||||
if (closedRef.current) return;
|
||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
||||
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||
sockRef.current = sock;
|
||||
|
||||
sock.onopen = () => {
|
||||
|
||||
Reference in New Issue
Block a user