diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 25 | ||||
| -rw-r--r-- | src/features/hats.js | 2 | ||||
| -rw-r--r-- | src/features/index.js | 35 | ||||
| -rw-r--r-- | src/features/names.js | 2 | ||||
| -rw-r--r-- | src/nip98.js | 15 | ||||
| -rw-r--r-- | src/nostr-login.js | 8 |
6 files changed, 61 insertions, 26 deletions
@@ -8,12 +8,12 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo import { qrSvg } from './qr.js'; import { makeSearcher, resultRows, searchable, punkUrl } from './recipient-search.js'; import { npubOf } from './nostr.js'; -import { nip98Header } from './nostr-login.js'; +import { nip98Header } from './nip98.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; import { STAGING } from './build-flags.js'; -import { buildFeatures } from './features/index.js'; +import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js'; import { fmtBtc, @@ -1093,6 +1093,7 @@ async function activateAccount(acc, opts = {}) { // threads, decrypted DMs) belong to the old keys, and surviving into this // account they'd both leak the old account's messages on screen and block // the new account's own subscriptions from ever starting. + _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } activeId = acc.id; // A gift link generates this wallet only to claim into. Keep it provisional @@ -1119,6 +1120,7 @@ async function activateAccount(acc, opts = {}) { persistAccounts(); autoSave(acc); for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle + _featuresInited = true; // a deferred feature landing after this point gets its init() on arrival // A freshly generated identity provably has no profile yet — let features // skip the "is there a kind-0?" wait (avatar would sit blank meanwhile). if (opts.generated) featureHook('identityGenerated'); @@ -1783,6 +1785,7 @@ function lock({ offerPassword = false } = {}) { wallet.stopRealtime(); // A locked session must not keep listening as the account that just left — // and whatever logs in next must not inherit its threads or subscriptions. + _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } clearAccounts(); vaultPassword = null; @@ -1909,6 +1912,7 @@ function softLock() { } } const keepId = activeId; + _featuresInited = false; for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} } vaultPassword = null; accounts = watch.slice(); @@ -4398,6 +4402,18 @@ const ctx = { }, }; const FEATURES = buildFeatures(ctx); +// Deferred features (gifts, NWC, hats) hold placeholder slots in FEATURES — +// hook precedence is position — and arrive as separate chunks right after +// boot, filled in place. If a wallet opened before they landed, each newcomer +// gets the init() it missed; if the wallet locked again, it doesn't. +let _featuresInited = false; +const _deferredReady = (async () => { + try { + const late = await loadDeferredFeatures(ctx, FEATURES); + if (_featuresInited) for (const f of late) { try { f.init && f.init(); } catch {} } + if (late.length) render(); + } catch (e) { console.error('deferred features failed to load', e); } +})(); // apply text direction, then restore a wallet left open in this tab — otherwise // show the unlock screen. @@ -4467,8 +4483,11 @@ document.addEventListener('visibilitychange', () => { else onAppVisible(); }); window.addEventListener('load', () => setTimeout(() => { if (activeAccount()) claimMigratedName(); }, 1500)); -loadLocale(getLang()).finally(() => { +loadLocale(getLang()).finally(async () => { applyBootAutoLogout(); // clear an overdue session before we read it for claim targets + // A gift link's feature is deferred — wait for it before asking who owns + // the URL, or the claim would fall through to a normal boot and be lost. + if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady; if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim) if (!restoreAccountsState()) render(); }); diff --git a/src/features/hats.js b/src/features/hats.js index bb9b8b0..43151ad 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -13,7 +13,7 @@ import { t } from '../i18n.js'; import { fmtSats } from '../format.js'; -import { nip98Header } from '../nostr-login.js'; +import { nip98Header } from '../nip98.js'; import { getNetwork } from '../api.js'; const REGISTRAR = 'https://names.coinos.io'; diff --git a/src/features/index.js b/src/features/index.js index 5ecb7c0..a32bced 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -3,24 +3,33 @@ // build.js replaces this module's contents based on HAL_FEATURES, so a // disabled feature's code — and its network endpoints — never enters the // bundle. See featureIndexSource() in build.js. - -import { giftsFeature } from './gifts.js'; -import { nostrLoginFeature } from './nostrlogin.js'; +// +// This checked-in copy must stay byte-equivalent (comments aside) to +// featureIndexSource(all features) — tools/feature-registry-check.js enforces +// it. Deferred features (gifts, nwc, hats) hold their array position with an +// empty placeholder — hook precedence is position — and are filled in place +// by loadDeferredFeatures right after boot, as separate chunks in the split +// build. NB order is meaningful where hooks stack: gifts' receive takeover +// and balance line come before ark's; zaps sits after ark so an npub prefers +// an instant Ark zap; nwc sits after ark because it drives ark's headless +// pay/balance seam; names sits between ark and zaps so a pasted user@domain +// resolves over DNS (BIP-353) before zaps treats it as a Lightning address. import { arkFeature } from './ark.js'; +import { nostrLoginFeature } from './nostrlogin.js'; import { namesFeature } from './names.js'; import { zapsFeature } from './zaps.js'; import { syncFeature } from './sync.js'; -import { nwcFeature } from './nwc.js'; -import { hatsFeature } from './hats.js'; import { messagesFeature } from './messages.js'; -// NB order is meaningful where hooks stack: gifts' receive takeover and -// balance line come before ark's, matching the pre-plugin layout. zaps sits -// after ark so an npub prefers an instant Ark zap and only falls through to a -// Lightning zap when Ark can't serve it. nwc sits after ark because it -// drives ark's headless pay/balance seam. -// names sits between ark and zaps: a pasted user@domain resolves over DNS -// (BIP-353) before the zaps feature treats it as a plain Lightning address. export function buildFeatures(ctx) { - return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), hatsFeature(ctx), syncFeature(ctx), messagesFeature(ctx)]; + return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx)]; +} +export async function loadDeferredFeatures(ctx, features) { + const late = []; + await Promise.all([ + import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }), + import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }), + import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); }) + ]); + return late; } diff --git a/src/features/names.js b/src/features/names.js index cf2aa3c..56fc77d 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -12,7 +12,7 @@ // record falls back to the Lightning-address flow (zaps feature). import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js'; -import { nip98Header } from '../nostr-login.js'; +import { nip98Header } from '../nip98.js'; import { qrSvg } from '../qr.js'; import { isArkAddress } from './ark.js'; import { getNetwork } from '../api.js'; diff --git a/src/nip98.js b/src/nip98.js new file mode 100644 index 0000000..aa0343e --- /dev/null +++ b/src/nip98.js @@ -0,0 +1,15 @@ +// NIP-98 HTTP Auth header (kind 27235): the signature covers the exact URL, +// method and body hash, so a captured header is useless anywhere else. Its +// own module — every build needs it (registrar calls), but only some builds +// need the login/backup machinery around it in nostr-login.js. +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; + +export async function nip98Header(signer, url, method, bodyText) { + const tags = [['u', url], ['method', method.toUpperCase()]]; + if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]); + const evt = await signer.signEvent({ + kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '', + }); + return 'Nostr ' + btoa(JSON.stringify(evt)); +} diff --git a/src/nostr-login.js b/src/nostr-login.js index 6958da0..b032c73 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -270,11 +270,3 @@ export async function walletForSigner(signer) { // request with a nostr key. `u` and `method` bind the signature to this exact // request and `payload` to its body, so a captured header can't be replayed // against a different endpoint or a tampered body. -export async function nip98Header(signer, url, method, bodyText) { - const tags = [['u', url], ['method', method.toUpperCase()]]; - if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]); - const evt = await signer.signEvent({ - kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '', - }); - return 'Nostr ' + btoa(JSON.stringify(evt)); -} |
