From 4b7eb866acb3575a197358a9ba4114ff6ae01a1e Mon Sep 17 00:00:00 2001 From: Jeff Litt Date: Mon, 27 Jul 2026 15:30:58 -0400 Subject: [PATCH 1/2] Fix identity query hanging indefinitely outside a Tauri runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the desktop chat UI is served as a plain web page (no window.__TAURI_INTERNALS__), get_identity() has no way to succeed, so useIdentityQuery's queryFn always rejects. With the default retry config, the single configured retry can call the query-core retryer's pause() path if the window isn't focused at that exact moment (canContinue() requires focusManager.isFocused() regardless of networkMode) — leaving the query stuck on fetchStatus "paused" forever instead of ever settling to "error". Any consumer gating UI on this query's settled status (success/error) then hangs indefinitely, since neither state is ever reached. - useIdentityQuery: retry: false — a missing native identity backend can't be fixed by retrying, so there's no reason to ever enter the retry/pause branch. - tauriIdentity.ts: guard get_identity/get_nsec/import_identity/ persist_current_identity/sign_out with isTauri(), matching the guard convention already used elsewhere in this codebase (e.g. audioWorklet.ts), so a missing runtime produces a clean, identifiable Error instead of a raw "Cannot read properties of undefined (reading 'invoke')" TypeError surfaced from deep inside invoke(). --- desktop/src/shared/api/hooks.ts | 9 +++++++++ desktop/src/shared/api/tauriIdentity.ts | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/desktop/src/shared/api/hooks.ts b/desktop/src/shared/api/hooks.ts index 8902175f0d..f075f3194d 100644 --- a/desktop/src/shared/api/hooks.ts +++ b/desktop/src/shared/api/hooks.ts @@ -7,5 +7,14 @@ export function useIdentityQuery() { queryKey: ["identity"], queryFn: getIdentity, staleTime: Number.POSITIVE_INFINITY, + // A failure here means "no native identity backend" (e.g. web deployment + // outside Tauri) — retrying can't change that. This also sidesteps a + // query-core gotcha: the default queryClient's networkMode: "always" only + // bypasses the *online* check in the retryer, not focusManager.isFocused(). + // If the window isn't OS-focused at the moment the single default retry + // fires, canContinue() returns false and the retryer calls pause() — + // which then waits indefinitely for a focus/visibility event, leaving + // this query stuck on fetchStatus "paused" forever instead of "error". + retry: false, }); } diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..d2c209005c 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -1,3 +1,5 @@ +import { isTauri } from "@tauri-apps/api/core"; + import { invokeTauri } from "@/shared/api/tauri"; import type { Identity } from "@/shared/api/types"; @@ -19,21 +21,39 @@ function fromRawIdentity(raw: RawIdentity): Identity { }; } +// Web deployments serve this same bundle outside the Tauri shell, where +// there is no native keychain-backed identity at all. Without this guard, +// get_identity fell through to invokeTauri, which throws deep inside +// @tauri-apps/api/core's invoke() — the identity query never settled to a +// clean "error" (it hung on "pending"/"paused" indefinitely), which kept +// useMachineOnboardingState() stuck on the "blocking" stage forever. Failing +// fast here lets identityQuery.status reach "error" immediately, which +// machineOnboarding.ts already treats as a signal to proceed to "ready". +function requireTauri(command: string): void { + if (!isTauri()) { + throw new Error(`${command}: no native identity backend in web mode`); + } +} + export async function getIdentity(): Promise { + requireTauri("get_identity"); return fromRawIdentity(await invokeTauri("get_identity")); } export async function getNsec(): Promise { + requireTauri("get_nsec"); return invokeTauri("get_nsec"); } export async function importIdentity(nsec: string): Promise { + requireTauri("import_identity"); return fromRawIdentity( await invokeTauri("import_identity", { nsec }), ); } export async function persistCurrentIdentity(): Promise { + requireTauri("persist_current_identity"); return fromRawIdentity( await invokeTauri("persist_current_identity"), ); @@ -47,5 +67,6 @@ export async function persistCurrentIdentity(): Promise { * state until the process exits and only handle errors (e.g. display a toast). */ export async function signOut(): Promise { + requireTauri("sign_out"); await invokeTauri("sign_out"); } From e05684728b19dad8536a0daf974dc451c9e179ef Mon Sep 17 00:00:00 2001 From: Jeff Litt Date: Mon, 27 Jul 2026 15:49:13 -0400 Subject: [PATCH 2/2] Add regression coverage for the standalone-web identity-guard fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the actual failure mode this PR fixes: get_identity and its siblings must never reach invokeTauri when there is no Tauri runtime. Without this guard the query hung on fetchStatus "paused" instead of settling to "error" — these tests assert the guard short-circuits before any native call is attempted, and that it doesn't when Tauri is available. The "Tauri available" happy-path case lives in its own file (tauriIdentity.available.test.mjs) rather than alongside the "unavailable" cases: node's per-process ESM module cache means mixing an isTauri: () => true mock with an isTauri: () => false mock in the same test file causes a later import of tauriIdentity.ts to return the first test's cached module instance instead of re-evaluating against the new mock. node --test already runs each matched file in its own process, which sidesteps that. Requires --experimental-test-module-mocks (added to the test script) for node:test's t.mock.module — available in the project's pinned Node version (22.22.3) but not previously enabled. --- desktop/package.json | 2 +- .../api/tauriIdentity.available.test.mjs | 29 +++++++++ desktop/src/shared/api/tauriIdentity.test.mjs | 64 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 desktop/src/shared/api/tauriIdentity.available.test.mjs create mode 100644 desktop/src/shared/api/tauriIdentity.test.mjs diff --git a/desktop/package.json b/desktop/package.json index 6726bfdcad..974d09dd38 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --experimental-test-module-mocks --test \"src/**/*.test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/src/shared/api/tauriIdentity.available.test.mjs b/desktop/src/shared/api/tauriIdentity.available.test.mjs new file mode 100644 index 0000000000..eba9fa36ce --- /dev/null +++ b/desktop/src/shared/api/tauriIdentity.available.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Split into its own file (not merged with tauriIdentity.test.mjs) because +// node's per-process ESM module cache means mixing an "isTauri: () => true" +// mock with an "isTauri: () => false" mock in the same test file causes +// later imports of tauriIdentity.ts to return the first test's cached module +// instance rather than re-evaluating against the new mock. node --test runs +// each matched file in its own process, which sidesteps that entirely. + +test("getIdentity calls the native bridge when Tauri is available", async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => true }, + }); + const invokeTauriFn = t.mock.fn(async () => ({ + pubkey: "abc", + display_name: "Test", + })); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const { getIdentity } = await import("@/shared/api/tauriIdentity"); + + const identity = await getIdentity(); + assert.equal(identity.pubkey, "abc"); + assert.equal(invokeTauriFn.mock.calls.length, 1); + assert.equal(invokeTauriFn.mock.calls[0].arguments[0], "get_identity"); +}); diff --git a/desktop/src/shared/api/tauriIdentity.test.mjs b/desktop/src/shared/api/tauriIdentity.test.mjs new file mode 100644 index 0000000000..2d095feccd --- /dev/null +++ b/desktop/src/shared/api/tauriIdentity.test.mjs @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression coverage for the standalone-web splash-screen hang: get_identity +// (and its siblings) must never reach invokeTauri when there is no Tauri +// runtime — reaching it produced a raw TypeError deep inside +// @tauri-apps/api/core's invoke(), and combined with the identity query's +// retry policy, left the query stuck on fetchStatus "paused" forever instead +// of settling to "error". These tests assert the guard short-circuits before +// any native call is attempted. +// +// Each test mocks fresh via its own TestContext (`t.mock.module`), which +// auto-restores at test end — a shared/global `mock.module` call here hits +// "module is already mocked" on the second test, since the mock registry is +// process-global and outlives a manual `.restore()` call made mid-run. +// +// The "Tauri is available" happy-path case lives in +// tauriIdentity.available.test.mjs, a separate file — see that file's +// comment for why it can't share a process with these "unavailable" tests. + +test("getIdentity rejects without invoking the native bridge when Tauri is unavailable", async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => false }, + }); + const invokeTauriFn = t.mock.fn(); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const { getIdentity } = await import("@/shared/api/tauriIdentity"); + + await assert.rejects(() => getIdentity(), /no native identity backend/); + assert.equal( + invokeTauriFn.mock.calls.length, + 0, + "invokeTauri must not be called when Tauri is unavailable", + ); +}); + +for (const [name, fn] of [ + ["getNsec", (m) => m.getNsec()], + ["importIdentity", (m) => m.importIdentity("nsec1test")], + ["persistCurrentIdentity", (m) => m.persistCurrentIdentity()], + ["signOut", (m) => m.signOut()], +]) { + test(`${name} rejects without invoking the native bridge when Tauri is unavailable`, async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => false }, + }); + const invokeTauriFn = t.mock.fn(); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const module = await import("@/shared/api/tauriIdentity"); + + await assert.rejects(() => fn(module)); + assert.equal( + invokeTauriFn.mock.calls.length, + 0, + `invokeTauri must not be called by ${name} when Tauri is unavailable`, + ); + }); +}