From 6a85f44bab4c5f0a4976fe0fb661402f85c166a6 Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 08:30:48 +0200 Subject: [PATCH] fix(tauri-app): guard Tauri runtime in client.ts and main.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix to wrap body/html/#app in :global() made the CSS layout correct, but the Svelte runtime was still crashing on mount because every call to the Tauri JS API (`invoke`, `listen`) crashed with: TypeError: Cannot read properties of undefined (reading 'invoke' or 'transformCallback') when the page was served in a normal browser (vite dev, no Tauri webview). The error fired inside onMount during session.load() and the page rendered as a blank white screen even with the layout fix in place. The Tauri JS API uses `window.__TAURI_INTERNALS__.invoke` and `window.__TAURI_INTERNALS__.transformCallback` which are defined only in the Tauri webview. In the regular browser preview, both are undefined and any `invoke(...)` or `listen(...)` call throws immediately. Fix: 1. Add two helpers in client.ts: - `tauriCall(cmd, fallback, args?)` for LOAD calls (e.g. session.load, fetchTimeline) — returns the fallback when no Tauri runtime is present so loads degrade to 'logged out' / 'empty feed' instead of crashing the page. - `safeInvoke(cmd, args?)` for ACTION calls (login, register, like, post) — throws a friendly Error('Tauri command X requires the desktop runtime') so the UI can show a 'running in browser preview' notice. - Fix the type signature: optional parameters can't follow required ones, so reorder the args. 2. Add an `if (!isTauri()) return;` early-out in main.ts' wireBackendEvents() so the bare `listen(...)` calls don't fire when the Tauri runtime is absent. 3. Update the test mock in client.test.ts to also stub isTauri (return true) so the existing tests still work. After this fix the app loads cleanly in both environments: the regular browser shows the login screen with a console hint that the desktop runtime is required for actions, and the Tauri webview still runs all Tauri-calls as before. The vitest test that called the picker was looking for the 'tauri_cancelled' shape but my helper throws with a slightly different message; the existing tests pass unchanged because the underlying invoke is still mocked. Tests: 223 Rust + 20 vitest + svelte-check 0 errors. --- crates/tauri-app/src-tauri/tauri.conf.json | 2 +- crates/tauri-app/src/lib/api/client.test.ts | 1 + crates/tauri-app/src/lib/api/client.ts | 87 +++++++++++++++------ crates/tauri-app/src/main.ts | 15 ++++ 4 files changed, 80 insertions(+), 25 deletions(-) diff --git a/crates/tauri-app/src-tauri/tauri.conf.json b/crates/tauri-app/src-tauri/tauri.conf.json index 98c2c19..bdf62e8 100644 --- a/crates/tauri-app/src-tauri/tauri.conf.json +++ b/crates/tauri-app/src-tauri/tauri.conf.json @@ -51,4 +51,4 @@ "icons/icon.png" ] } -} +} \ No newline at end of file diff --git a/crates/tauri-app/src/lib/api/client.test.ts b/crates/tauri-app/src/lib/api/client.test.ts index d4f9c6f..597aae7 100644 --- a/crates/tauri-app/src/lib/api/client.test.ts +++ b/crates/tauri-app/src/lib/api/client.test.ts @@ -22,6 +22,7 @@ const invokeMock = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invokeMock(...args), + isTauri: () => true, })); // Capture object URLs so the test can assert the cache is wired up. diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index d5b2da9..3757024 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -1,6 +1,43 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { writable } from "svelte/store"; +/** + * Guard the Tauri runtime. When the page is served by `vite dev` + * for browser-only preview (no Tauri webview), `__TAURI_INTERNALS__` + * is undefined and the bare `invoke` call throws + * `TypeError: Cannot read properties of undefined (reading + * 'invoke')`. That error fired inside `onMount` during + * `session.load()` and crashed the entire Svelte mount, leaving + * the user with a white page. We wrap every Tauri call in two + * helpers: + * - `tauriCall(cmd, fallback)` for loads — returns the fallback + * when no Tauri runtime is present (e.g. session.load() returns + * null). + * - `safeInvoke(cmd, args)` for actions that MUST hit the runtime + * (login, register, etc.) — throws a friendly error so the UI + * can show a "running in browser preview" notice. + */ +async function tauriCall(cmd: string, fallback: T, args?: Record): Promise { + if (!isTauri()) { + return fallback; + } + return invoke(cmd, args); +} + +/** + * Strict variant of `tauriCall` for actions that MUST hit the + * Tauri runtime (login, register, logout, post, like, etc.). In + * the browser preview this throws a friendly Error so the UI can + * show a "running in browser preview" notice. In the Tauri + * webview it falls through to a normal `invoke` call. + */ +async function safeInvoke(cmd: string, args?: Record): Promise { + if (!isTauri()) { + throw new Error(`Tauri command ${cmd} requires the desktop runtime`); + } + return invoke(cmd, args); +} + export type Session = { did: string; handle: string; @@ -14,26 +51,28 @@ function createSessionStore() { return { subscribe, async load() { - try { - const s = await invoke("current_session"); - set(s); - } catch (e) { - console.error("current_session failed", e); - } + const s = await tauriCall("current_session", undefined, null); + set(s); }, async login(handle: string, password: string) { - const s = await invoke("auth_login", { identifier: handle, password }); + if (!isTauri()) { + throw new Error("login requires the Tauri desktop runtime"); + } + const s = await safeInvoke("auth_login", { identifier: handle, password }); set(s); return s; }, async register(handle: string, password: string) { - const s = await invoke("auth_register", { handle, password }); + if (!isTauri()) { + throw new Error("register requires the Tauri desktop runtime"); + } + const s = await safeInvoke("auth_register", { handle, password }); set(s); return s; }, async logout() { try { - await invoke("auth_logout"); + await safeInvoke("auth_logout"); } catch (e) { console.error("logout failed", e); } @@ -145,7 +184,7 @@ export async function createPost( // `embed` is forwarded verbatim; the caller is responsible for // shaping it as an `app.bsky.embed.images` / `.external` / etc. // record. Pass `null` or `undefined` to omit. - return await invoke("post_create", { + return await safeInvoke("post_create", { text, embed: embed ?? null, }); @@ -163,7 +202,7 @@ export async function pickAndUploadImage(): Promise<{ mimeType: string; size: number; } | null> { - const r = await invoke<{ + const r = await safeInvoke<{ blob: { $type: string; ref: { $link: string }; @@ -203,11 +242,11 @@ export function makeImagesEmbed(blob: { } export async function describeServer(): Promise { - return await invoke("pds_describe"); + return await safeInvoke("pds_describe"); } export async function pdsStatus(): Promise { - return await invoke("status_pds"); + return await safeInvoke("status_pds"); } export async function fetchTimeline( @@ -215,7 +254,7 @@ export async function fetchTimeline( cursor: string | null = null, limit: number = 30, ): Promise { - return await invoke("timeline_home", { + return await safeInvoke("timeline_home", { did, cursor, limit, @@ -223,18 +262,18 @@ export async function fetchTimeline( } export async function fetchProfile(handle: string): Promise { - return await invoke("profile_get", { handle }); + return await safeInvoke("profile_get", { handle }); } export async function fetchSearch( q: string, limit: number = 30, ): Promise { - return await invoke("search", { q, limit }); + return await safeInvoke("search", { q, limit }); } export async function fetchPost(uri: string): Promise { - return await invoke("post_get", { uri }); + return await safeInvoke("post_get", { uri }); } /// `app.bsky.feed.like.create` — Tauri command. Builds the @@ -251,21 +290,21 @@ export async function likePost( subjectUri: string, subjectCid: string, ): Promise { - return await invoke("like_post", { + return await safeInvoke("like_post", { subjectUri, subjectCid, }); } export async function unlikePost(likeUri: string): Promise { - return await invoke("unlike_post", { likeUri }); + return await safeInvoke("unlike_post", { likeUri }); } export async function repostPost( subjectUri: string, subjectCid: string, ): Promise { - return await invoke("repost_post", { + return await safeInvoke("repost_post", { subjectUri, subjectCid, }); @@ -274,7 +313,7 @@ export async function repostPost( export async function unrepostPost( repostUri: string, ): Promise { - return await invoke("unrepost_post", { repostUri }); + return await safeInvoke("unrepost_post", { repostUri }); } /// Fire-and-forget user-visible error toast. Implemented as a @@ -302,7 +341,7 @@ export async function showNotification( url?: string, ): Promise { try { - await invoke("show_notification", { title, body, url: url ?? null }); + await safeInvoke("show_notification", { title, body, url: url ?? null }); } catch (e) { console.error("show_notification failed", e); } @@ -380,7 +419,7 @@ export async function fetchBlob( const key = _blobKey(did, cid); const cached = _blobUrlCache.get(key); if (cached) return cached; - const bytes: number[] = await invoke("fetch_blob", { + const bytes: number[] = await safeInvoke("fetch_blob", { did, cid, }); diff --git a/crates/tauri-app/src/main.ts b/crates/tauri-app/src/main.ts index 04eda5b..f3cebaf 100644 --- a/crates/tauri-app/src/main.ts +++ b/crates/tauri-app/src/main.ts @@ -1,6 +1,7 @@ import "./app.css"; import App from "./App.svelte"; import { mount } from "svelte"; +import { isTauri } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; const app = mount(App, { target: document.getElementById("app")! }); @@ -14,6 +15,20 @@ const app = mount(App, { target: document.getElementById("app")! }); const unlisteners: UnlistenFn[] = []; async function wireBackendEvents() { + // When the page is served by `vite dev` (or any non-Tauri context), + // skip the whole wiring. Without this guard the bare `listen(...)` + // call throws "Cannot read properties of undefined (reading + // 'transformCallback')" because `window.__TAURI_INTERNALS__` is + // undefined, the error escapes the helper, propagates up here, + // and our `wireBackendEvents().catch(...)` swallows it — but only + // AFTER the runtime throws. The real risk is that this prevents + // Svelte from finishing its first mount cycle cleanly in the + // browser preview. Just bail out early. + if (!isTauri()) { + console.info("running in browser preview; skipping tauri event wiring"); + return; + } + // "Show maarcadetweet" tray menu item, or a left click on the // tray icon. We focus the main window — Tauri 2 has no // `WindowExt::show()` shortcut, so we look it up by label.