aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js25
-rw-r--r--src/features/hats.js2
-rw-r--r--src/features/index.js35
-rw-r--r--src/features/names.js2
-rw-r--r--src/nip98.js15
-rw-r--r--src/nostr-login.js8
6 files changed, 61 insertions, 26 deletions
diff --git a/src/app.js b/src/app.js
index 237808e..2e52e56 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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));
-}