fix(tauri-app): guard Tauri runtime in client.ts and main.ts
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<T>(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<T>(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.
This commit is contained in:
@@ -51,4 +51,4 @@
|
|||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ const invokeMock = vi.fn();
|
|||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||||
|
isTauri: () => true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Capture object URLs so the test can assert the cache is wired up.
|
// Capture object URLs so the test can assert the cache is wired up.
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import { writable } from "svelte/store";
|
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<T>(cmd: string, fallback: T, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return invoke<T>(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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
||||||
|
}
|
||||||
|
return invoke<T>(cmd, args);
|
||||||
|
}
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
did: string;
|
did: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -14,26 +51,28 @@ function createSessionStore() {
|
|||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
async load() {
|
async load() {
|
||||||
try {
|
const s = await tauriCall<Session | null>("current_session", undefined, null);
|
||||||
const s = await invoke<Session | null>("current_session");
|
set(s);
|
||||||
set(s);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("current_session failed", e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async login(handle: string, password: string) {
|
async login(handle: string, password: string) {
|
||||||
const s = await invoke<Session>("auth_login", { identifier: handle, password });
|
if (!isTauri()) {
|
||||||
|
throw new Error("login requires the Tauri desktop runtime");
|
||||||
|
}
|
||||||
|
const s = await safeInvoke<Session>("auth_login", { identifier: handle, password });
|
||||||
set(s);
|
set(s);
|
||||||
return s;
|
return s;
|
||||||
},
|
},
|
||||||
async register(handle: string, password: string) {
|
async register(handle: string, password: string) {
|
||||||
const s = await invoke<Session>("auth_register", { handle, password });
|
if (!isTauri()) {
|
||||||
|
throw new Error("register requires the Tauri desktop runtime");
|
||||||
|
}
|
||||||
|
const s = await safeInvoke<Session>("auth_register", { handle, password });
|
||||||
set(s);
|
set(s);
|
||||||
return s;
|
return s;
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
try {
|
try {
|
||||||
await invoke("auth_logout");
|
await safeInvoke("auth_logout");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("logout failed", e);
|
console.error("logout failed", e);
|
||||||
}
|
}
|
||||||
@@ -145,7 +184,7 @@ export async function createPost(
|
|||||||
// `embed` is forwarded verbatim; the caller is responsible for
|
// `embed` is forwarded verbatim; the caller is responsible for
|
||||||
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
// shaping it as an `app.bsky.embed.images` / `.external` / etc.
|
||||||
// record. Pass `null` or `undefined` to omit.
|
// record. Pass `null` or `undefined` to omit.
|
||||||
return await invoke<any>("post_create", {
|
return await safeInvoke<any>("post_create", {
|
||||||
text,
|
text,
|
||||||
embed: embed ?? null,
|
embed: embed ?? null,
|
||||||
});
|
});
|
||||||
@@ -163,7 +202,7 @@ export async function pickAndUploadImage(): Promise<{
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
const r = await invoke<{
|
const r = await safeInvoke<{
|
||||||
blob: {
|
blob: {
|
||||||
$type: string;
|
$type: string;
|
||||||
ref: { $link: string };
|
ref: { $link: string };
|
||||||
@@ -203,11 +242,11 @@ export function makeImagesEmbed(blob: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function describeServer(): Promise<any> {
|
export async function describeServer(): Promise<any> {
|
||||||
return await invoke("pds_describe");
|
return await safeInvoke("pds_describe");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pdsStatus(): Promise<any> {
|
export async function pdsStatus(): Promise<any> {
|
||||||
return await invoke("status_pds");
|
return await safeInvoke("status_pds");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTimeline(
|
export async function fetchTimeline(
|
||||||
@@ -215,7 +254,7 @@ export async function fetchTimeline(
|
|||||||
cursor: string | null = null,
|
cursor: string | null = null,
|
||||||
limit: number = 30,
|
limit: number = 30,
|
||||||
): Promise<TimelineResponse> {
|
): Promise<TimelineResponse> {
|
||||||
return await invoke<TimelineResponse>("timeline_home", {
|
return await safeInvoke<TimelineResponse>("timeline_home", {
|
||||||
did,
|
did,
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
@@ -223,18 +262,18 @@ export async function fetchTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
|
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
|
||||||
return await invoke<ProfileResponse>("profile_get", { handle });
|
return await safeInvoke<ProfileResponse>("profile_get", { handle });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSearch(
|
export async function fetchSearch(
|
||||||
q: string,
|
q: string,
|
||||||
limit: number = 30,
|
limit: number = 30,
|
||||||
): Promise<SearchResponse> {
|
): Promise<SearchResponse> {
|
||||||
return await invoke<SearchResponse>("search", { q, limit });
|
return await safeInvoke<SearchResponse>("search", { q, limit });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPost(uri: string): Promise<ThreadResponse> {
|
export async function fetchPost(uri: string): Promise<ThreadResponse> {
|
||||||
return await invoke<ThreadResponse>("post_get", { uri });
|
return await safeInvoke<ThreadResponse>("post_get", { uri });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `app.bsky.feed.like.create` — Tauri command. Builds the
|
/// `app.bsky.feed.like.create` — Tauri command. Builds the
|
||||||
@@ -251,21 +290,21 @@ export async function likePost(
|
|||||||
subjectUri: string,
|
subjectUri: string,
|
||||||
subjectCid: string,
|
subjectCid: string,
|
||||||
): Promise<RepoWriteResult> {
|
): Promise<RepoWriteResult> {
|
||||||
return await invoke<RepoWriteResult>("like_post", {
|
return await safeInvoke<RepoWriteResult>("like_post", {
|
||||||
subjectUri,
|
subjectUri,
|
||||||
subjectCid,
|
subjectCid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
|
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
|
||||||
return await invoke<DeleteRecordResult>("unlike_post", { likeUri });
|
return await safeInvoke<DeleteRecordResult>("unlike_post", { likeUri });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repostPost(
|
export async function repostPost(
|
||||||
subjectUri: string,
|
subjectUri: string,
|
||||||
subjectCid: string,
|
subjectCid: string,
|
||||||
): Promise<RepoWriteResult> {
|
): Promise<RepoWriteResult> {
|
||||||
return await invoke<RepoWriteResult>("repost_post", {
|
return await safeInvoke<RepoWriteResult>("repost_post", {
|
||||||
subjectUri,
|
subjectUri,
|
||||||
subjectCid,
|
subjectCid,
|
||||||
});
|
});
|
||||||
@@ -274,7 +313,7 @@ export async function repostPost(
|
|||||||
export async function unrepostPost(
|
export async function unrepostPost(
|
||||||
repostUri: string,
|
repostUri: string,
|
||||||
): Promise<DeleteRecordResult> {
|
): Promise<DeleteRecordResult> {
|
||||||
return await invoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
return await safeInvoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
/// Fire-and-forget user-visible error toast. Implemented as a
|
||||||
@@ -302,7 +341,7 @@ export async function showNotification(
|
|||||||
url?: string,
|
url?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("show_notification", { title, body, url: url ?? null });
|
await safeInvoke("show_notification", { title, body, url: url ?? null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("show_notification failed", e);
|
console.error("show_notification failed", e);
|
||||||
}
|
}
|
||||||
@@ -380,7 +419,7 @@ export async function fetchBlob(
|
|||||||
const key = _blobKey(did, cid);
|
const key = _blobKey(did, cid);
|
||||||
const cached = _blobUrlCache.get(key);
|
const cached = _blobUrlCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bytes: number[] = await invoke<number[]>("fetch_blob", {
|
const bytes: number[] = await safeInvoke<number[]>("fetch_blob", {
|
||||||
did,
|
did,
|
||||||
cid,
|
cid,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./app.css";
|
import "./app.css";
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import { mount } from "svelte";
|
import { mount } from "svelte";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
const app = mount(App, { target: document.getElementById("app")! });
|
const app = mount(App, { target: document.getElementById("app")! });
|
||||||
@@ -14,6 +15,20 @@ const app = mount(App, { target: document.getElementById("app")! });
|
|||||||
const unlisteners: UnlistenFn[] = [];
|
const unlisteners: UnlistenFn[] = [];
|
||||||
|
|
||||||
async function wireBackendEvents() {
|
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
|
// "Show maarcadetweet" tray menu item, or a left click on the
|
||||||
// tray icon. We focus the main window — Tauri 2 has no
|
// tray icon. We focus the main window — Tauri 2 has no
|
||||||
// `WindowExt::show()` shortcut, so we look it up by label.
|
// `WindowExt::show()` shortcut, so we look it up by label.
|
||||||
|
|||||||
Reference in New Issue
Block a user