// "Log in with Nostr" — open (or create) a wallet from a nostr identity, and // link an existing wallet to one after the fact. // // The mechanics and their tradeoffs live in ../nostr-login.js. In short: a // pasted key DERIVES its wallet deterministically; an extension or remote // signer ASSOCIATES one via a seed published encrypted to the user's own key. // The second is what makes "log in on a new device" work without the key // ever reaching us, and it is spelled out in the UI before anything is // published, because it means the nostr key can recover the money. import { newMnemonic } from '../wallet.js'; import { npubOf, nsecOf } from '../nostr.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; export const NOSTR_MARK = OSTRICH; export function nostrLoginFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); // The signer that opened this session, kept so the names feature can claim // the user's real npub as their payment address (and nominate the wallet // key as manager, so later updates work without the signer). let live = null; // true while a sign-in is mid-flight: the signer is live but its link to // the wallet hasn't been persisted yet, so init() must not sever it let attaching = false; let resuming = null; let _spendingHinted = false; // relay backup already carries the spending hint let resumeFailedAt = 0; // The signer, but only if it still belongs to the wallet that's open. // Checked on every READ rather than at init() alone, because a wallet // switch has a gap: the new account is loaded and its directory entry // written (stamping the identity it shows) BEFORE features re-init, and the // previous account's signer was still answering in that gap — which is how // two different wallets ended up wearing the same face in the account // switcher. (Mid-sign-in the link isn't saved yet; `attaching` keeps this // from severing it.) const liveSigner = () => { if (live && !attaching && load().pubkey !== live.pubkey) live = null; return live; }; // A remote signer's connection can die quietly — the app is left holding a // signer object that will never answer again, and every send after that // fails until the page is reloaded. Notice the first failure and drop it, so // the next attempt resumes a fresh connection instead. // // But only a CONNECTION-shaped failure. A remote signer answering "can't // decrypt that" is a healthy connection doing its job — the DM inbox probes // it with wraps that turn out to be someone else's, and treating each // refusal as a dead signer disconnected Amethyst users over and over. const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i; const selfHealing = (signer) => { if (!signer || signer.kind !== 'bunker') return signer; const guard = (fn) => async (...args) => { try { return await fn(...args); } catch (e) { if (live === wrapped && SIGNER_DEAD.test(e?.message || '')) { // drop it AND dial again, silently: the notice (and its Reconnect // button) is for a resume that failed, not for every hiccup live = null; try { signer.close && signer.close(); } catch {} resumeQuietly(); } throw e; } }; const wrapped = { ...signer, signEvent: guard(signer.signEvent.bind(signer)), encryptSelf: guard(signer.encryptSelf.bind(signer)), decryptSelf: guard(signer.decryptSelf.bind(signer)), encryptTo: guard(signer.encryptTo.bind(signer)), decryptFrom: guard(signer.decryptFrom.bind(signer)), }; return wrapped; }; // What it takes to be this identity again after a reload. Without it the app // shows your face in the header but signs everything else with the wallet's // own key — a different pubkey, which is exactly the mismatch people notice // when their chat messages come out under a stranger's name. // // A remote signer stores its NIP-46 client key: the signer authorised that // key for this app, and it cannot sign anything on its own. // // A pasted key stores NOTHING. The UI promises it is used once and never // kept, and that promise is worth more than saving someone a paste after a // reload — they reconnect from the nostr settings card instead. // // A PASSKEY-derived key is the exception among keys: no such promise was // made, the key exists only through this app's PRF derivation, and // re-deriving it takes a WebAuthn ceremony — a system dialog plus a // biometric on every open, which is why passkey users kept meeting "your // signer isn't connected" in chat. The same storage already holds the // wallet seed, so keeping the key is the same exposure class — and it's // what makes a passkey identity always-available the way its owner expects. const sessionOf = (signer) => { if (!signer) return {}; const out = { session: signer.session || null }; if (viaOf(signer) === 'passkey' && signer.kind === 'key' && signer.secret) { out.sk = bytesToHex(signer.secret); } return out; }; // HOW this account was connected — so the reconnect surfaces can offer the // matching door instead of every door. A Google or passkey flow is running // when ui.nostrLoginVia says so (Google hands us a bunker, passkey a raw // key, so the signer kind alone can't tell); otherwise the signer speaks // for itself. const viaOf = (signer) => ui.nostrLoginVia === 'google' || ui.nostrLoginVia === 'passkey' ? ui.nostrLoginVia : signer && signer.kind === 'bunker' ? 'bunker' : signer && signer.kind === 'extension' ? 'extension' : 'key'; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { attaching = false; ui.nostrLoginError = e.message || String(e); busy(false); }; // A signer just went live: features holding work only a signer can do // (the DM inbox's undecrypted wraps) get their cue. Deferred a tick so // the `live` assignment has settled wherever this is called from. const announceLive = () => { setTimeout(() => { try { ctx.hook('nostrSignerLive'); } catch {} }, 0); }; // Open the wallet a signer identifies. New accounts confirm first, because // publishing an encrypted seed to relays deserves an explicit yes. async function loginWith(makeSigner) { ui.nostrLoginError = ''; busy(true); let signer; try { signer = await makeSigner(); // warm the identity's profile + avatar in parallel with the seed work, // so the home screen's first paint already has the picture ctx.hook('warmProfile', signer.pubkey); // and the payment name too — the Receive pane should open with its QR const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey); const res = await walletForSigner(signer); if (res.mode === 'new') { // No wallet on this identity yet — make one and walk straight in. // The interstitial that used to ask first read as an error screen to // Google/passkey users (a fresh identity is the NORMAL first run for // them); anyone signing in wants a wallet, and the backup mechanics // stay spelled out in the nostr settings card. await createForSigner({ signer }); return; } // Same wallet however you log in: a key-derived seed is published too, // so an extension login later finds this wallet instead of making a new // one. Costs nothing — anyone with the key could derive it anyway. if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {}); const spendingHint = !!res.spending; attaching = true; live = selfHealing(signer); announceLive(); // hand the wizard over BEFORE the screen flips: walletScreen routes // back into onboarding while ui.onb is set, so clearing it afterwards // re-animated the welcome screen for a beat before the wallet appeared ctx.onbNostrLogin(res.mode === 'restored'); ui.navAnimSkip = true; // the handover swaps instantly, no fade // usually resolved long ago; the race caps a slow registrar at 1.5s const nameHint = await Promise.race([ Promise.resolve(nameHintP).catch(() => null), new Promise((r) => setTimeout(() => r(null), 1500)), ]); await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey, spendingHint, nameHint }); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) }); attaching = false; // claim the real npub as the payment address while this signer is live ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); // no success toast: the wallet appearing IS the message busy(false); } catch (e) { fail(e); } } async function createForSigner(st) { if (!st) return; busy(true); try { // One last look before publishing a fresh seed: if a backup surfaces // now (a relay that was unreachable a moment ago), open that wallet // instead of replacing it on the relays that can see it. const existing = await fetchWalletBackup(st.signer).catch(() => null); const mnemonic = existing ? existing.mnemonic : newMnemonic(); if (!existing) await publishWalletBackup(st.signer, { mnemonic }); attaching = true; live = selfHealing(st.signer); announceLive(); ctx.onbNostrLogin(!!existing); // before the screen flips — see loginWith ui.navAnimSkip = true; // the handover swaps instantly, no fade await ctx.openMnemonic(mnemonic, (existing && existing.passphrase) || '', { nostrPubkey: st.signer.pubkey, spendingHint: !!(existing && existing.spending) }); save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), via: viaOf(st.signer), ...sessionOf(st.signer) }); attaching = false; ctx.hook('namesAdoptIdentity', st.signer, npubOf(st.signer.pubkey))?.catch?.(() => {}); busy(false); } catch (e) { fail(e); } } // Link the wallet that's already open to a nostr identity, so logging in // with it on another device opens this same wallet. async function linkOpenWallet(makeSigner) { ui.nostrLoginError = ''; busy(true); let signer; try { signer = await makeSigner(); if (!wallet.mnemonic) throw new Error(t('nlNeedSeed')); const existing = await walletForSigner(signer); if (existing.mnemonic && existing.mnemonic !== wallet.mnemonic) { throw new Error(t('nlAlreadyLinked')); } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); live = selfHealing(signer); announceLive(); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) }); ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); toast(t('nlLinked')); busy(false); } catch (e) { fail(e); } } // ---- UI --------------------------------------------------------------- // Client-initiated remote signing: one live connect attempt at a time. The // URI renders as a deep link (tap launches the signer app) and a QR (scan // from another device); when the app connects, login continues by itself. let nc = null; let ncRun = null; let ncToken = null; // guards the async gap while the nip46 module loads async function startNostrConnect(run, resume = null) { if (nc) nc.cancel(); ncRun = run; const token = (ncToken = {}); let mine; try { mine = await nostrConnect({ resume }); } catch { if (token === ncToken && ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } return; } if (token !== ncToken || !ncRun) { mine.cancel(); return; } nc = mine; render(); mine.ready.then((signer) => { if (nc !== mine || !ncRun) return; ui.nostrConnectOpen = false; ncRun(() => Promise.resolve(signer)); nc = null; }).catch(() => { if (nc === mine) { nc = null; if (ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } } }); } function stopNostrConnect() { if (nc) { nc.cancel(); nc = null; } ncRun = null; ncToken = null; } // A reload mid-nostrconnect (the signer app's callbackUrl navigating the // tab home, or Android discarding the frozen tab) used to strand the // login: the approval lands on the relays and nobody is waiting. On boot, // pick the persisted handshake back up — the poller finds the stored // approval and the login finishes on its own. Deferred a beat so the boot // restore has decided whether this tab is even logged out. setTimeout(() => { const pending = !nc && !wallet.mnemonic && ui.screen === 'unlock' && nostrConnectPending(); if (!pending) return; if (ui.onb && ['welcome', 'signin'].includes(ui.onb.step)) ui.onb = { step: 'signin' }; ui.nostrLoginOpen = true; ui.nostrConnectOpen = true; startNostrConnect(loginWith, pending); render(); }, 400); function nostrConnectSection(run) { if (!ui.nostrConnectOpen) { return h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => { ui.nostrConnectOpen = true; startNostrConnect(run); render(); }, }, t('nlConnectApp')); } // the nip46 module is still fetching — hold the spot with a spinner if (!nc) return h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center;padding:8px' }, h('span', { class: 'spinner sm' })); return h('div', { class: 'col', style: 'gap:10px;align-items:center' }, h('a', { class: 'btn btn-block btn-primary', href: nc.uri, style: 'text-align:center;display:block;text-decoration:none', }, t('nlOpenSigner')), h('div', { html: qrSvg(nc.uri) }), h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('nlWaitingSigner'))), h('div', { class: 'row gap6' }, copyBtn(nc.uri, t('copy')), h('button', { class: 'btn-sm', onClick: () => { stopNostrConnect(); ui.nostrConnectOpen = false; render(); } }, t('cancel')))); } // "Sign in with Google" — pomegranate: the code (and its FROST dealer) // rides in a lazy chunk, so the popup must open HERE, inside the click's // user activation, and the module navigates it once loaded. async function googleLogin(run) { const popup = window.open('about:blank', 'OAuth', 'width=600,height=600'); ui.nostrLoginError = ''; // no status narration — a quiet spinner on the button itself is the whole // story (the popup is where anything needing the user's eyes happens) ui.nostrLoginVia = 'google'; busy(true); try { const pome = await import('../pomegranate.js'); const { uri } = await pome.loginWithGoogle(popup); await run(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } })); } catch (e) { try { popup && popup.close(); } catch {} ui.nostrLoginError = e.message; } finally { ui.nostrLoginVia = null; busy(false); } } // Passkey sign-in: try an existing credential first; when the device has // none (or the picker is dismissed), roll straight into creating one — the // browser's own passkey dialog IS the confirmation, so an interstitial of // ours would just be a second question. async function passkeyLogin(run) { ui.nostrLoginError = ''; ui.nostrLoginVia = 'passkey'; render(); const pass = await import('../passkey.js'); try { const sk = await pass.passkeySignIn(); await run(() => keySigner(sk)); } catch (e) { if (/derive a wallet key/.test(e.message)) { ui.nostrLoginError = e.message; render(); return; } try { const sk = await pass.passkeyCreate(); await run(() => keySigner(sk)); } catch (e2) { // dismissing the create dialog is a plain cancel, not an error if (!/NotAllowed|abort/i.test(String(e2.name || '') + e2.message)) ui.nostrLoginError = e2.message; render(); } } finally { ui.nostrLoginVia = null; render(); } } // The official four-colour "G" (brand-fixed colours, not themed). const GOOGLE_MARK = ''; // Person + key, stroke style matching the app's other line icons. const PASSKEY_MARK = ''; // `spinning` swaps the ICON for a same-sized spinner and keeps the label — // the button must not change size mid-login, or the whole column shifts. const markedLabel = (mark, label, spinning) => h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, spinning ? h('span', { class: 'spinner sm', style: 'flex-shrink:0' }) : h('span', { style: 'display:flex;flex-shrink:0', html: mark }), label); // Google + passkey, with their transient status rows — shared between the // collapsed front-door card and the expanded signer list. function externalButtons(run, big = false) { const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; const pad = big ? 'padding:14px' : ''; return [ h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run) }, markedLabel(GOOGLE_MARK, t('nlGoogle'), ui.nostrLoginVia === 'google')), hasPasskey ? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy, onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t('nlPasskey'), ui.nostrLoginVia === 'passkey')) : null, ]; } // The ways in, as buttons. `run` receives a signer factory. The expanded // "Log in with Nostr" card passes nostrOnly — Google and passkey already // stand on the front door, and repeating them under a Nostr heading reads // as noise. Linking keeps them: a different account may live behind either. // // RECONNECTING is narrower: the account is known, so only the door it came // through belongs. `reconnectVia` says which — Google/passkey show their // button, everything else (bunker, extension, key) gets the nostr surfaces // only; an Amethyst user staring at a Google button was signing into the // wrong identity at worst and noise at best. 'legacy' (pre-`via` state with // no stored session) can't be told apart from a passkey account, so it // keeps every door. function signerButtons(run, { nostrOnly = false, reconnectVia = null } = {}) { const hasExt = typeof window !== 'undefined' && !!window.nostr; const externals = nostrOnly ? [] : reconnectVia && !['google', 'passkey', 'legacy'].includes(reconnectVia) ? [] : externalButtons(run); return h('div', { class: 'col', style: 'gap:8px' }, ...externals, hasExt ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => extensionSigner()) }, t('nlExtension')) : null, nostrConnectSection(run), // These are alternatives, not steps: without the divider the field under // the button reads as something you also have to fill in. h('div', { class: 'or-split' }, h('span', {}, t('or'))), h('div', { class: 'row gap6' }, h('input', { type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.nostrLoginInput || '', onInput: (e) => { ui.nostrLoginInput = e.target.value.trim(); }, }), h('button', { class: 'btn-sm', disabled: ui.nostrLoginBusy, onClick: () => { const v = (ui.nostrLoginInput || '').trim(); const sk = parseNostrSecret(v); if (sk) return run(() => keySigner(sk)); if (/^bunker:\/\//i.test(v)) { return run(() => bunkerSigner(v, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } })); } ui.nostrLoginError = t('nlUnrecognized'); render(); } }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlGo'))), h('div', { class: 'small faint' }, t('nlHint')), ui.nostrLoginAuthUrl ? h('a', { href: ui.nostrLoginAuthUrl, target: '_blank', class: 'small' }, t('nlApprove')) : null, ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); } // Shown under the create/import tabs on the unlock screen. function unlockExtra() { // Collapsed by default: Google, passkey, and Nostr right on the front // door; the Nostr button expands into the full signer list. if (!ui.nostrLoginOpen) { return h('div', { class: 'col', style: 'gap:8px' }, ...externalButtons(loginWith), // NB .btn-block forces display:block, so the flex centering lives on // an inner wrapper rather than the button itself. h('button', { class: 'btn-block', onClick: () => { ui.nostrLoginOpen = true; ui.nostrLoginError = ''; render(); }, }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }), t('nlSignInNostr'))), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); } return h('div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row between' }, h('h3', { style: 'margin:0' }, t('nlTitle')), h('span', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; render(); } }, t('cancel'))), h('p', { class: 'small muted', style: 'margin:0' }, t('nlDesc')), signerButtons(loginWith, { nostrOnly: true })); } // Re-attach a signer for the account already linked here. Deliberately not // linkOpenWallet: this must not become a way to quietly swap identities, so // a different account is refused rather than linked. async function reconnectSigner(makeSigner) { ui.nostrLoginError = ''; busy(true); try { const st = load(); const signer = await makeSigner(); if (signer.pubkey !== st.pubkey) { if (signer.close) signer.close(); throw new Error(t('nlWrongAccount', { npub: npubOf(st.pubkey) || '' })); } live = selfHealing(signer); announceLive(); save({ ...load(), via: viaOf(signer), ...sessionOf(signer) }); // No toast: the badge flips to "connected" and the reconnect controls // disappear, which says it better than a pill floating over the footer. busy(false); } catch (e) { fail(e); } } // Reveal the seed-derived nostr key. A self-backup is published FIRST so // the promise on the reveal screen is true: pasting this nsec into any // coinos login finds the backup and opens THIS wallet, instead of // key-deriving a fresh empty one (walletForSigner checks relays before // deriving). async function exportWalletKey() { ui.nostrLoginError = ''; busy(true); try { const sk = wallet.nostr && wallet.nostr.sk; if (!sk || !wallet.mnemonic) throw new Error(t('nlNeedSeed')); await publishWalletBackup(keySigner(sk), { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); ui.nostrExportStep = 'shown'; busy(false); } catch (e) { fail(e); } } // The built-in key, behind the same two-step gate as the recovery phrase: // a warning you have to act on before anything secret is in the DOM. function exportSection() { const sk = wallet.nostr && wallet.nostr.sk; if (!sk) return null; if (ui.nostrExportStep === 'shown') { const nsec = nsecOf(sk); return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'addr-box break' }, nsec), h('p', { class: 'small faint', style: 'margin:0' }, t('nlExportedNote')), h('div', { class: 'row gap6' }, copyBtn(nsec, t('copy')), h('button', { class: 'btn-sm grow', onClick: () => { ui.nostrExportStep = false; render(); } }, t('hide')))); } if (ui.nostrExportStep === 'warn') { return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'warn-box' }, t('nlExportWarn')), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: exportWalletKey }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlExportReveal')), h('button', { class: 'btn-sm', onClick: () => { ui.nostrExportStep = false; ui.nostrLoginError = ''; render(); } }, t('cancel')))); } return h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => { ui.nostrExportStep = 'warn'; ui.nostrLoginError = ''; render(); }, }, t('nlExportBtn')); } function linkSection() { if (!ui.nostrLinkOpen) { return h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => { ui.nostrLinkOpen = true; ui.nostrLoginError = ''; render(); }, }, t('nlChangeBtn')); } return h('div', { class: 'col', style: 'gap:10px' }, h('div', { class: 'row between' }, h('strong', { class: 'small' }, t('nlChangeBtn')), h('span', { class: 'linklike small', onClick: () => { ui.nostrLinkOpen = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))), h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')), h('div', { class: 'notice info small' }, t('nlBackupWarning')), signerButtons(linkOpenWallet)); } // The silent reattach (extension first, then a stored bunker session) — // callable from anywhere that notices the signer missing. One attempt at a // time, and a pause after a failure: identity() asks on every send, and a // signer that isn't answering shouldn't mean a new relay connection per // keystroke. async function resumeLogin() { if (liveSigner()) return live; const st = load(); if (!st.pubkey) return null; if (resuming) return resuming; if (Date.now() - resumeFailedAt < 20_000) return null; resuming = (async () => { // a stored passkey-derived key reattaches instantly — no network, no // dialog; see sessionOf for why this key (alone among keys) is kept if (st.sk) { try { const s = keySigner(hexToBytes(st.sk)); if (s.pubkey === st.pubkey) return (live = s); } catch {} } if (typeof window !== 'undefined' && window.nostr) { try { const s = await extensionSigner(); if (s.pubkey === st.pubkey) return (live = s); } catch {} } if (st.session) { try { const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); if (s && s.pubkey === st.pubkey) return (live = selfHealing(s)); } catch {} } return null; })(); try { const s = await resuming; if (!s) resumeFailedAt = Date.now(); else announceLive(); return s; } finally { resuming = null; } } // Which door this account came through, for the reconnect surfaces. State // saved before `via` existed: a stored session means a bunker; without one // it could be extension, key, or passkey — offer everything. const reconnectViaOf = (st) => st.via || (st.session ? 'bunker' : 'legacy'); // Fire-and-forget resume for render paths: heal the badge without anyone // tapping, repaint when it lands. resumeLogin's own gating keeps this from // dialing on every render. function resumeQuietly() { resumeLogin().then((s) => { if (s) render(); }).catch(() => {}); } function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); const lv = liveSigner(); if (st.pubkey) { return h('div', { class: 'card col' }, h('div', { class: 'row between' }, h('h3', {}, t('nlLinkTitle')), h('span', { class: 'badge dot' + (lv ? ' live' : ' off') }, lv ? t('nlConnected') : t('nlDisconnected'))), h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')), // Signing needs a live signer, and a reload always drops a remote one. // Without this the app can tell you it isn't connected and offer you // nowhere to fix it. A stored session or an installed extension can // usually reattach without the user doing anything — try that quietly // whenever the card paints disconnected (the resume path rate-limits // itself), and only the doors this account came through are offered. lv ? null : (resumeQuietly(), h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), signerButtons(reconnectSigner, { reconnectVia: reconnectViaOf(st) })))); } // No external account linked — but the wallet still HAS a nostr account: // the one derived from its seed, already signing chat and holding the // payment address. A bare login form here read as "no account yet", so // the built-in identity comes first and both exporting its key and // switching to another account are explicit steps behind buttons. const npub = wallet.nostrNpub && wallet.nostrNpub(); return h('div', { class: 'card col' }, h('h3', {}, t('nlLinkTitle')), npub ? h('div', { class: 'small muted break' }, npub) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('nlBuiltinDesc')), exportSection(), linkSection()); } // The reconnect takeover: opened by any feature whose action just found // the signer missing (a reload dropped it; Amethyst and friends can't // reattach silently). Same guarded buttons as the settings card — an // extension, the signer-app deeplink/QR, or a pasted key — and the run // refuses a different account. function reconnectScreen() { const st = load(); const run = (makeSigner) => reconnectSigner(makeSigner).then(() => { if (liveSigner()) { ui.nostrReconnect = null; render(); } }); return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, t('nlReconnectTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })), signerButtons(run, { reconnectVia: reconnectViaOf(st) }), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null), h('button', { class: 'btn-ghost btn-block', onClick: () => { stopNostrConnect(); ui.nostrReconnect = null; ui.nostrConnectOpen = false; ui.nostrLoginError = ''; render(); } }, t('back'))); } return { id: 'nostrlogin', stop() { stopNostrConnect(); }, screenView() { if (ui.screen !== 'wallet' || !ui.nostrReconnect) return null; return reconnectScreen(); }, // An action needed the login signer and it isn't there: open the // reconnect screen (and try the silent extension reattach in the // background — it closes the screen by itself when it lands). nostrReconnectPrompt() { const st = load(); if (!st.pubkey || liveSigner()) return false; ui.nostrReconnect = true; ui.nostrLoginError = ''; this.nostrLoginResume().then((sg) => { if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); } }).catch(() => {}); render(); return true; }, init() { // A live signer is bound to the wallet that linked it. Switching to an // account that never did must not inherit the login identity — it used // to, and the fresh wallet then claimed names under the login npub, // showed its profile, and even wore its hats. liveSigner(); }, // The nostr identity this session is logged in as, for features that // should speak as the user (the payment address defaults to this npub). nostrLoginIdentity() { // A just-connected signer counts immediately — the persisted link lands // only after the wallet opens, and the header avatar shouldn't show the // wallet key's face in the gap. if (liveSigner()) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live }; const st = load(); if (!st.pubkey) return null; return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; }, // A page reload loses the signer. A stored passkey key or an installed // extension reattaches without prompting, which is what lets the payment // address stay tied to the user's real identity across reloads. Returns // null for signers we cannot silently reattach (pasted keys, bunkers // without a stored session). nostrLoginResume() { return resumeLogin(); }, // Back from the background: a remote signer's socket may be gone even if // the pool will bring it back — ask it something small now, and if the // answer never comes, resume a fresh session before anyone notices. resumed(awayMs) { const s = liveSigner(); if (!s || s.kind !== 'bunker' || !s.ping) return; if (awayMs && awayMs < 5_000) return; s.ping().catch(() => { if (live !== s) return; live = null; try { s.close && s.close(); } catch {} resumeQuietly(); }); return true; }, unlockExtra() { return unlockExtra(); }, // The welcome screen's sign-in block: Google and passkey act right there; // the Nostr button steps into the wizard's signer list. // Spending just became real (name claimed / balance appeared): stamp the // hint into the relay backup while a signer is live, so the NEXT restore // paints Spending from its first frame. Once per session is plenty. spendingEvident() { if (_spendingHinted || !liveSigner() || !wallet.mnemonic) return null; _spendingHinted = true; publishWalletBackup(live, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '', spending: true }) .catch(() => { _spendingHinted = false; }); return true; }, frontDoorSignin() { return h('div', { class: 'col', style: 'gap:8px' }, ...externalButtons(loginWith, true), h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { if (ui.onb) ui.onb.step = 'signin'; ui.nostrLoginOpen = true; ui.nostrLoginError = ''; render(); } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }), t('nlSignInNostr'))), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null); }, nostrSettingsCards() { return [settingsCard()]; }, }; }