Bump version to v0.1.9 — fix account switch read-only bug (root cause)

Root cause: switchAccount fetched the nsec from the OS keychain on every
switch. Any keychain failure (timeout, Windows Credential Manager quirk,
no entry yet) silently fell through to loginWithPubkey → read-only mode.

Fix: cache each NDKPrivateKeySigner in-memory (_signerCache map) the
moment loginWithNsec succeeds. switchAccount checks the cache first;
the OS keychain is now only consulted at startup (restoreSession). Signers
are pure crypto objects with no session state — safe to reuse indefinitely.

Verified with a 9-switch stress test across 3 accounts (A1→A2→A3→A1→
A2→A1→A3→A2→A1): hasSigner=true and correct pubkey on every switch.

Also adds dev tooling:
- src/lib/tauri-dev-mock.ts: localStorage-backed keychain + SQLite stubs
  so the frontend can run in a plain browser for Playwright testing
- src/main.tsx: import mock first in DEV mode (no-op in production)
- test/gen-accounts.mjs: generates 3 deterministic test keypairs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-11 15:11:03 +01:00
parent cdf1a0b4a2
commit fdb7aab9d1
9 changed files with 158 additions and 5 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* Dev-only mock for Tauri's invoke() — lets the frontend run in a plain browser.
*
* Provides:
* - localStorage-backed keychain (store_nsec / load_nsec / delete_nsec)
* - No-op SQLite stubs (db_save_notes / db_load_feed / db_save_profile / db_load_profile)
*
* Injected before any invoke() call only when import.meta.env.DEV is true and
* Tauri internals are not already present (i.e. running in browser, not Tauri window).
*/
if (import.meta.env.DEV && !(window as any).__TAURI_INTERNALS__) {
const keychainKey = (pubkey: string) => `__dev_nsec_${pubkey}`;
const mockInvoke = async (cmd: string, args: Record<string, unknown> = {}): Promise<unknown> => {
switch (cmd) {
case "store_nsec":
localStorage.setItem(keychainKey(args.pubkey as string), args.nsec as string);
return null;
case "load_nsec":
return localStorage.getItem(keychainKey(args.pubkey as string)) ?? null;
case "delete_nsec":
localStorage.removeItem(keychainKey(args.pubkey as string));
return null;
case "db_save_notes":
case "db_save_profile":
return null;
case "db_load_feed":
return [];
case "db_load_profile":
return null;
default:
console.warn("[tauri-dev-mock] unhandled invoke:", cmd, args);
return null;
}
};
(window as any).__TAURI_INTERNALS__ = { invoke: mockInvoke };
console.info("[tauri-dev-mock] active — using localStorage keychain");
}
+1
View File
@@ -1,3 +1,4 @@
import "./lib/tauri-dev-mock"; // must be first — mocks Tauri invoke() in browser dev mode
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
+30 -2
View File
@@ -14,6 +14,12 @@ export interface SavedAccount {
picture?: string;
}
// In-memory signer cache — survives account switches within a session.
// Keyed by pubkey hex. NOT persisted to localStorage; rebuilt on next login.
// This means the keychain is only ever consulted at startup (restoreSession),
// not on every switch, eliminating the "read-only after switch" class of bugs.
const _signerCache = new Map<string, NDKPrivateKeySigner>();
function loadSavedAccounts(): SavedAccount[] {
try {
return JSON.parse(localStorage.getItem("wrystr_accounts") ?? "[]");
@@ -87,6 +93,9 @@ export const useUserStore = create<UserState>((set, get) => ({
const pubkey = user.pubkey;
const npub = nip19.npubEncode(pubkey);
// Cache signer in memory so switchAccount can reuse it without keychain
_signerCache.set(pubkey, signer);
// Update accounts list
const accounts = upsertAccount(get().accounts, { pubkey, npub });
persistAccounts(accounts);
@@ -185,13 +194,32 @@ export const useUserStore = create<UserState>((set, get) => ({
switchAccount: async (pubkey: string) => {
// Clear signer immediately — no window where old account could sign
getNDK().signer = undefined;
// Fast path: reuse in-memory signer cached from the login that added this
// account earlier in this session. Avoids a round-trip to the OS keychain
// and eliminates the "becomes read-only after switch" failure class.
const cachedSigner = _signerCache.get(pubkey);
if (cachedSigner) {
getNDK().signer = cachedSigner;
const account = get().accounts.find((a) => a.pubkey === pubkey);
const npub = account?.npub ?? nip19.npubEncode(pubkey);
set({ pubkey, npub, loggedIn: true, loginError: null });
localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec");
useLightningStore.getState().loadNwcForAccount(pubkey);
get().fetchOwnProfile();
get().fetchFollows();
useMuteStore.getState().fetchMuteList(pubkey);
useUIStore.getState().setView("feed");
return;
}
// Slow path: cache miss (first session after restart) — try OS keychain
let succeeded = false;
// Try nsec from keychain first; fall back to read-only
try {
const nsec = await invoke<string | null>("load_nsec", { pubkey });
if (nsec) {
await get().loginWithNsec(nsec);
// Only consider it a success if signer was actually set
succeeded = !!getNDK().signer;
}
} catch {