Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/shared/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
29 changes: 29 additions & 0 deletions desktop/src/shared/api/tauriIdentity.available.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
64 changes: 64 additions & 0 deletions desktop/src/shared/api/tauriIdentity.test.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
});
}
21 changes: 21 additions & 0 deletions desktop/src/shared/api/tauriIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isTauri } from "@tauri-apps/api/core";

import { invokeTauri } from "@/shared/api/tauri";
import type { Identity } from "@/shared/api/types";

Expand All @@ -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<Identity> {
requireTauri("get_identity");
return fromRawIdentity(await invokeTauri<RawIdentity>("get_identity"));
}

export async function getNsec(): Promise<string> {
requireTauri("get_nsec");
return invokeTauri<string>("get_nsec");
}

export async function importIdentity(nsec: string): Promise<Identity> {
requireTauri("import_identity");
return fromRawIdentity(
await invokeTauri<RawIdentity>("import_identity", { nsec }),
);
}

export async function persistCurrentIdentity(): Promise<Identity> {
requireTauri("persist_current_identity");
return fromRawIdentity(
await invokeTauri<RawIdentity>("persist_current_identity"),
);
Expand All @@ -47,5 +67,6 @@ export async function persistCurrentIdentity(): Promise<Identity> {
* state until the process exits and only handle errors (e.g. display a toast).
*/
export async function signOut(): Promise<void> {
requireTauri("sign_out");
await invokeTauri("sign_out");
}