diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 09a011a..5441423 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -30,12 +30,40 @@ async function tauriCall(cmd: string, fallback: T, args?: Record(cmd: string, args?: Record): Promise { if (!isTauri()) { throw new Error(`Tauri command ${cmd} requires the desktop runtime`); } - return invoke(cmd, args); + try { + return await invoke(cmd, args); + } catch (e: unknown) { + if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e; + const fresh = await session.refresh(); + if (!fresh) throw e; + return await invoke(cmd, args); + } +} + +/// Sniff out a `TokenInvalid` response from the Rust error string. +/// Returns true when the error message looks like an expired/ +/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its +/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim). +function isTokenInvalid(e: unknown): boolean { + if (typeof e !== "object" || e === null) return false; + const msg = (e as { message?: string }).message ?? String(e); + if (!msg) return false; + return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature"); } export type Session = { @@ -47,9 +75,35 @@ export type Session = { function createSessionStore() { const { subscribe, set } = writable(null); + // Coalesce concurrent refresh requests into one — every safeInvoke + // call that hits a 401 would otherwise race to call auth_refresh in + // parallel. The pending promise is reset to `null` exactly once in + // the finally block; subsequent callers await the same one. + let pendingRefresh: Promise | null = null; return { subscribe, + /// Mint a fresh access JWT from the stored refresh JWT. Called + /// automatically by [`safeInvoke`] on `TokenInvalid` responses. + /// Returns the new session, or `null` if the refresh itself failed + /// (e.g. refresh JWT expired; at that point the user has to log + /// in again). + async refresh(): Promise { + if (pendingRefresh) return pendingRefresh; + pendingRefresh = (async () => { + try { + const s = await invoke("auth_refresh"); + set(s); + return s; + } catch (e) { + console.warn("session refresh failed", e); + return null; + } finally { + pendingRefresh = null; + } + })(); + return pendingRefresh; + }, async load() { const s = await tauriCall("current_session", null); set(s);