diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 700b3b2..0b96b3a 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -206,12 +206,14 @@ export async function loginWithBunker(input: string) { // Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops // waiting when the user leaves the view. -export function loginWithNostrConnect(): { +// `onAck` reports the signer's first reply so the UI can show progress while +// the session is finalized. +export function loginWithNostrConnect(onAck?: () => void): { uri: string; done: Promise; cancel: () => void; } { - const nc: NostrConnect = startNostrConnect(); + const nc: NostrConnect = startNostrConnect(onAck); return { uri: nc.uri, done: nc.session.then(loginWithBunkerSession), diff --git a/src/lib/bunker.ts b/src/lib/bunker.ts index 11184dc..817602d 100644 --- a/src/lib/bunker.ts +++ b/src/lib/bunker.ts @@ -3,7 +3,11 @@ import { createNostrConnectURI, parseBunkerInput, toBunkerURL, + type BunkerPointer, } from "@nostr/tools/nip46"; +import { SimplePool } from "@nostr/tools/pool"; +import { NostrConnect as NostrConnectKind } from "@nostr/tools/kinds"; +import { decrypt, getConversationKey } from "@nostr/tools/nip44"; import { generateSecretKey, getPublicKey } from "@nostr/tools/pure"; import { bytesToHex, hexToBytes } from "@nostr/tools/utils"; import { NOSTRCONNECT_RELAYS } from "$lib/config"; @@ -21,7 +25,37 @@ export type BunkerSession = { clientSecretKey: string; }; +// The signer app usually lives on the phone, and on mobile the browser tab is +// backgrounded while the user approves there, which can drop the socket. A +// reconnecting pool keeps the subscriptions alive across that. +const pool = new SimplePool({ enableReconnect: true }); + +// The approval happens in the signer app, so the tab is backgrounded and its +// sockets can drop; nostr-tools only retries after a 10s backoff. Reconnect +// the relays this flow has used as soon as the tab or the network is back, +// and keep a shorter backoff for drops in between. +const FAST_BACKOFF = [1000, 2000, 5000, 10000]; +const usedRelays = new Set(); +function touchRelays(urls: string[] = []) { + for (const url of urls) usedRelays.add(url); + for (const url of usedRelays) { + pool + .ensureRelay(url) + .then((relay) => { + relay.resubscribeBackoff = FAST_BACKOFF; + }) + .catch(() => {}); + } +} +if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (!document.hidden) touchRelays(); + }); + window.addEventListener("online", () => touchRelays()); +} + const signerParams = { + pool, onauth: (url: string) => { window.open(url, "_blank"); }, @@ -33,11 +67,16 @@ function stripSecret(url: string): string { return u.toString(); } +function describe(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + export async function connectBunker(input: string): Promise { const bp = await parseBunkerInput(input.trim()); if (!bp) throw new Error("Invalid bunker"); if (bp.relays.length === 0) throw new Error("Bunker URL has no relay"); const sk = generateSecretKey(); + touchRelays(bp.relays); const signer = BunkerSigner.fromBunker(sk, bp, signerParams); try { await signer.connect(); @@ -50,7 +89,7 @@ export async function connectBunker(input: string): Promise { }; } catch (e) { signer.close().catch(() => {}); - throw e; + throw new Error(`Bunker error: ${describe(e)}`); } } @@ -61,29 +100,105 @@ export type NostrConnect = { cancel: () => void; }; -export function startNostrConnect(): NostrConnect { +// Waits for the signer's reply to a nostrconnect:// URI. Unlike the library's +// helper, the filter has no `limit: 0`, so a reply published while the tab was +// offline is replayed once the relay reconnects. +function waitForConnect( + sk: Uint8Array, + secret: string, + signal: AbortSignal, +): Promise { + const clientPubkey = getPublicKey(sk); + touchRelays(NOSTRCONNECT_RELAYS); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + sub.close(); + fn(); + }; + const sub = pool.subscribe( + NOSTRCONNECT_RELAYS, + { + kinds: [NostrConnectKind], + "#p": [clientPubkey], + since: Math.floor(Date.now() / 1000) - 60, + }, + { + onevent: (event) => { + let reply: { result?: string; error?: string }; + try { + const key = getConversationKey(sk, event.pubkey); + reply = JSON.parse(decrypt(event.content, key)); + } catch (e) { + console.warn("nostrconnect: undecryptable reply", e); + return; + } + if (reply.result === secret) { + finish(() => + resolve({ + pubkey: event.pubkey, + relays: NOSTRCONNECT_RELAYS, + secret, + }), + ); + } else if (reply.error) { + finish(() => reject(new Error(`Signer refused: ${reply.error}`))); + } else { + finish(() => + reject( + new Error( + `Signer replied "${reply.result}" instead of the secret`, + ), + ), + ); + } + }, + onclose: (reasons) => + finish(() => + reject(new Error(`Relay closed: ${reasons.join("; ")}`)), + ), + }, + ); + // Closes the subscription on every relay; the pool's own abort handling + // only reaches the last one + signal.addEventListener("abort", () => + finish(() => reject(new Error("Cancelled"))), + ); + }); +} + +// `onAck` fires the moment the signer answers the URI, before the follow-up +// round-trip that completes the session — the point where "waiting for the +// signer" becomes "finishing up". +export function startNostrConnect(onAck?: () => void): NostrConnect { const sk = generateSecretKey(); + const secret = Math.random().toString(36).substring(2, 10); const uri = createNostrConnectURI({ clientPubkey: getPublicKey(sk), relays: NOSTRCONNECT_RELAYS, - secret: Math.random().toString(36).substring(2, 10), + secret, name: location.host, url: location.origin, }); const abort = new AbortController(); - const session = BunkerSigner.fromURI( - sk, - uri, - signerParams, - abort.signal, - ).then(async (signer) => { - const pubkey = await signer.getPublicKey(); - return { - signer, - pubkey, - bunkerUrl: stripSecret(toBunkerURL(signer.bp)), - clientSecretKey: bytesToHex(sk), - }; + const session = waitForConnect(sk, secret, abort.signal).then(async (bp) => { + onAck?.(); + // The signer already accepted this client key; no connect round-trip + const signer = BunkerSigner.fromBunker(sk, bp, signerParams); + try { + const pubkey = await signer.getPublicKey(); + return { + signer, + pubkey, + bunkerUrl: stripSecret(toBunkerURL(bp)), + clientSecretKey: bytesToHex(sk), + }; + } catch (e) { + signer.close().catch(() => {}); + throw new Error(`Bunker error: ${describe(e)}`); + } }); return { uri, session, cancel: () => abort.abort() }; } @@ -96,5 +211,6 @@ export async function restoreBunker( ): Promise { const bp = await parseBunkerInput(bunkerUrl); if (!bp) throw new Error("Invalid bunker"); + touchRelays(bp.relays); return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams); } diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte index 6e05fc8..5547e21 100644 --- a/src/lib/components/LoginModal.svelte +++ b/src/lib/components/LoginModal.svelte @@ -38,8 +38,15 @@ (typeof location !== "undefined" ? location.host : ""), ); - // Client-initiated NIP-46 flow, alive only while the bunker view is shown - let connect = $state | null>(null); + // Client-initiated NIP-46 flow, alive only while the bunker view is shown. + // `finalizing` flips when the signer first answers, so the QR gives way to + // a progress state while the session completes. + // $state.raw so identity checks against the raw object (connect === nc) + // hold; the object is replaced wholesale, never mutated. + let connect = $state.raw | null>( + null, + ); + let finalizing = $state(false); const qrSvg = $derived( connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "", ); @@ -57,6 +64,7 @@ function stopConnect() { connect?.cancel(); connect = null; + finalizing = false; } function reset() { @@ -116,7 +124,9 @@ function startConnect() { stopConnect(); - const nc = loginWithNostrConnect(); + const nc = loginWithNostrConnect(() => { + if (connect === nc) finalizing = true; + }); connect = nc; nc.done .then(() => { @@ -127,6 +137,7 @@ .catch((e) => { if (connect !== nc) return; // cancelled by leaving the view connect = null; + finalizing = false; error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`; }); } @@ -325,71 +336,89 @@ Sign up {:else if view === "bunker"} - {#if connect} - - {@html qrSvg} - - -

- Scan or paste this in your signer app, then approve the connection. + +

+ Connected to your signer — finishing login… +

+ + {:else} + {#if connect} + + {@html qrSvg} + + +

+ Scan or paste this in your signer app, then approve the + connection. +

+ {/if} + + + + + e.key === "Enter" && handleBunker()} + class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700" + /> + +

+ A NIP-05 address that points to a bunker works too.

{/if} - - - - - e.key === "Enter" && handleBunker()} - class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700" - /> - -

- A NIP-05 address that points to a bunker works too. -