// "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()]; },
};
}