From 9f46d932b6a25ea4b337ee3998e8be062e9739b1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 21:17:39 +0200 Subject: [PATCH] Aegis 0.29.0: start on first use, not at every Theseus launch Aegis was most of what was left of launch cost: its crypto and WizardConnect deps block the main thread for ~0.6 s right after the first frame. It now declares its panel and "activation": "on-demand"; the dapp bridges are still on every page from the start, and the first page call, panel open or wiz:// link starts it. activate() returns a promise the host waits on, so the call that woke Aegis finds WizardConnect and the mounted wallets. With a locked vault it does not wait for the mount (derive blocks until unlock), so the page gets the usual locked answer in ~0.7 s instead of after the host's 5 s limit. Two things only work while Aegis runs: a live WizardConnect pairing (no one else listens on its relays) and "stay unlocked" (which also opens Settings > Passwords). While either is on, Aegis asks the host to start it at launch, and withdraws the request when both are off. Pairings left behind by a removed wallet do not count. Boot trace, same profile, 3 warm runs: main thread blocked in the first 6 s 970-1040 ms -> 300-320 ms, longest block 605-667 ms -> 227-244 ms, toolbar 1.34-1.61 s -> 0.83-0.87 s. --- NOTE-aegis-on-demand.md | 147 -------------------------------- bundled-addons/aegis/addon.json | 10 ++- bundled-addons/aegis/index.js | 56 ++++++++++-- 3 files changed, 59 insertions(+), 154 deletions(-) delete mode 100644 NOTE-aegis-on-demand.md diff --git a/NOTE-aegis-on-demand.md b/NOTE-aegis-on-demand.md deleted file mode 100644 index 674c6208..00000000 --- a/NOTE-aegis-on-demand.md +++ /dev/null @@ -1,147 +0,0 @@ -# Hand-off: switching Aegis to on-demand activation - -Theseus can now start an add-on on first use instead of at every launch -(`"activation": "on-demand"` in `addon.json`, see [addons-host.js](addons-host.js)). -The small bundled add-ons use it. **Aegis was deliberately left on startup** -because another session owns `bundled-addons/aegis/*`. It is where most of -the launch win is: measured 2026-10-03, Aegis costs ~165 ms of synchronous -activation plus ~610 ms of main-thread work right after (`loadDeps`: noble -curves, bitcoinjs, libauth, WizardConnect). This note lists what Aegis has to -declare and handle to switch. It was written against Aegis 0.27.1 -(branch `claude/sleepy-maxwell-251ee6-b`). Nothing in the host needs to change. - -## What the host already does for an on-demand add-on - -- While the add-on waits, the host still serves everything it declares: - `panels` (dock button + sidebar), `toolbar-menu`, `context-menu-items` and - the `page-inject` bridge. The bridge source is read from disk the first - time a matching page loads, so `window.bitcoincash`, `window.ethereum`, - `window.solana`, `window.tronWeb`/`tronLink`, `window.wizardconnect` and - the EIP-6963 announcements are all on the page **before** Aegis runs. -- These start it, then deliver the call. A call never gets dropped: it waits - for the activation, and parallel calls share one activation. - - any `addon-page-msg` from the bridge (`getAddress`, `eth.*`, `sol.*`, - `trx.*`, `wcPageReady`, `wcConnectFromPage`, …) - - any `addon-msg` from `panel.html` or an Aegis full-tab page - - opening the Aegis panel (`setSidebar` starts it alongside the page load) - - a `wiz://` link click (`routeWizUri` in main.js wakes Aegis, then - dispatches `wcConnectFromPage`) - - Settings `addon-invoke` calls -- **If `activate()` returns a promise, every call waits for it, up to - 5 s** (`READY_WAIT_MS`). After 5 s the call goes through anyway. -- `api.startAtLaunch(true|false)` lets an add-on ask to be started at the next - launch even though its manifest says on-demand. The host saves the choice - in `settings.addonsStartAtLaunch`. Settings › Performance › "Start the - wallet at launch" does the same thing from the user's side, for `aegis` - only. -- Settings › Performance › Startup already has the "Start the wallet at - launch" switch. It is disabled, with a hint, until the installed Aegis - manifest says `"activation": "on-demand"`. After that it works with no - Settings change (default: off). - -## What Aegis has to change - -### 1. `addon.json` - -```json -"activation": "on-demand", -"panels": [{ "id": "main", "title": "Wallet", "page": "panel.html" }], -``` - -Leave `icon` out of the panel entry so it inherits the manifest icon, as -today. The `api.registerSidebarPanel({ id: "main", … })` call in `activate()` -can stay: the same id replaces the declared entry. Bump the version, or -existing profiles keep the old manifest (`seedBundledAddons` only reseeds a -strictly newer bundle). - -### 2. `activate()` must return a "started" promise, and must not wait for the vault - -Today `activate()` returns `undefined`. It registers every handler -synchronously, then loads deps after `whenUiReady()`, then `tryAutoUnlock`, -then `mountAllWallets()`. That was fine at launch, because dapps arrived -seconds later. On demand, **the call that woke Aegis is the first thing it -sees**. Right now that call would fail: - -- `getAddress` / `signAndSend` / `signMessage` → `legacyBchRuntime()` throws - "wallet is not ready (vault locked?)" because no runtime is mounted yet -- `wcConnectFromPage` throws "WizardConnect is still starting up" -- `wcPageReady` answers `{available:false}`, and the dapp falls back to a QR -- `eth.*` / `sol.*` / `trx.*` see no runtimes - -Fix: return a promise that resolves once Aegis can actually serve calls: - -```js -activate(api) { - …register handlers exactly as today… - const started = uiReady.then(() => loadDeps(api)).then(async (d) => { - …c.d = d; c.wc = …; // as today - await tryAutoUnlock(api); - const st = await api.vault.lifecycle.status().catch(() => null); - const mounting = mountAllWallets(); - if (st && st.unlocked) await mounting; // see below - }); - started.catch(…); // as today - return started; -} -``` - -**Vault-derive waits:** `api.vault.derive()` does not reject while the vault -is locked. It polls until the user unlocks (main.js `vaultDerive`: -`while (!vaultState) await …`). So with a locked vault, -`mountAllWallets()` never resolves. **Never await it in the returned promise -unless the vault is already unlocked.** The host would wait out the full -5 s on every first call, and then deliver the call to a wallet that is -still not ready. With a locked vault, resolve right after deps load. The -existing locked-vault behaviour then applies unchanged: the panel shows its -unlock gate, and page calls get "wallet is not ready (vault locked?)", same -as a locked vault at launch today. - -The ~610 ms `loadDeps` cost now lands on the first dapp call or panel open, -not on launch. `whenUiReady()` has long resolved by then, so it runs at once. -That is the intended trade, but it is visible: the first -`eth_requestAccounts` approval appears about 0.6–1 s later than it does -today. - -### 3. Live WizardConnect pairings must keep Aegis at launch - -`lib/wc.js` saves pairings under `wc//uris` and reconnects them to -the Nostr relays in `mountWallet()`. While Aegis is waiting for first use, -**nobody listens on those relays**. A paired dapp that sends a sign request -gets no answer until something else wakes Aegis. Aegis must tell the host: - -```js -// after any change to the persisted pairing set (persist(), disconnect, removeWallet) -const live = [...uris.values()].some((s) => s.size > 0); -api.startAtLaunch(live); -``` - -Call it once at startup as well, after the pairings are restored, so a -profile that already has pairings flips to startup on the next launch. Guard -it with `typeof api.startAtLaunch === "function"` for older hosts. - -### 4. Other work that only runs while Aegis runs (decide, no fix required) - -- Price feed polling (`prices.js`, opt-in) and per-chain adapter refresh / - electrum subscriptions do not run until first use. Balances are fetched - when the panel opens, which already happens today for a locked vault. -- `tryAutoUnlock` (the safeStorage "stay unlocked" session) now runs at - first use, not at launch. It unlocks the shared password vault, so - Settings › Passwords also stays locked until then. If that matters, the - "stay unlocked" option should also call `api.startAtLaunch(true)`. -- `installStorageCache(api)` and `migrateLegacyStorage(api)` are cheap and - just move to first use. - -### 5. Check after switching - -- Fresh profile, launch: the log shows `aegis v… starts on first use`, the - dock shows the Wallet button, and `scripts/boot-trace/run.mjs` shows the - `addons` column without Aegis. -- On a dapp: `window.ethereum` exists at page load. The first - `eth_requestAccounts` starts Aegis (`started aegis on first use (page - eth.requestAccounts)`) and shows the approval. -- With a locked vault: the first page call fails with the usual locked - message right away, not after 5 s. -- A `wiz://` link click on a cold Aegis opens the pairing approval. -- After pairing a WC dapp, `settings.json` → `addonsStartAtLaunch` contains - `aegis`. The next launch logs `activated aegis` and the paired dapp's - requests arrive. diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index f87b5b92..cf0882cf 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,12 +1,20 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.28.1", + "version": "0.29.0", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.", "author": "Silent Mode", "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", "main": "index.js", + "activation": "on-demand", + "panels": [ + { + "id": "main", + "title": "Wallet", + "page": "panel.html" + } + ], "updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json", "capabilities": [ "sidebar-panel", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index abb5d5c0..e78e808c 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -438,7 +438,7 @@ function coinsForPanel() { // host so nothing changes about durability or who owns the file; only the // repeated parsing of it disappears. Falls back to the host's storage // untouched if the api object will not accept the substitution. -function installStorageCache(api) { +function installStorageCache(api, onSet) { const raw = api.storage; if (!raw || typeof raw.all !== "function") return false; let mem = null; @@ -454,6 +454,7 @@ function installStorageCache(api) { set: (key, value) => { load()[key] = value; raw.set(key, value); + if (onSet) try { onSet(key); } catch {} }, all: () => ({ ...load() }), }; @@ -463,6 +464,33 @@ function installStorageCache(api) { } catch { return false; } } +// Aegis starts on first use (addon.json "activation": "on-demand"). Two +// things only work while it runs, so while either is on it asks the host to +// start it at launch: a live WizardConnect pairing (nobody else listens on +// the relays for the dapp's sign requests) and "stay unlocked" (that +// auto-unlock also opens Settings › Passwords). Pairings of a removed wallet +// stay in storage, so only wallets still in the list count. +function needsLaunchStart(api) { + const ids = new Set((readWallets(api) || []).map((w) => w.id)); + const all = api.storage.all ? api.storage.all() : {}; + for (const [k, v] of Object.entries(all)) { + const m = /^wc\/(.+)\/uris$/.exec(k); + if (m && ids.has(m[1]) && Array.isArray(v) && v.length) return true; + } + const cfg = api.storage.get("aegis/session/cfg", null); + const blob = api.storage.get("aegis/session/enc", null); + return !!(cfg && cfg.lockOnClose === false && blob && blob.encPwB64); +} +let launchStartAsked = null; +function syncLaunchStart(api) { + if (typeof api.startAtLaunch !== "function") return; // host predates on-demand + const on = needsLaunchStart(api); + if (on === launchStartAsked) return; + launchStartAsked = on; + try { api.startAtLaunch(on); } catch (e) { api.log("startAtLaunch:", e?.message || e); } +} +const LAUNCH_START_KEYS = /^(wallets$|wc\/|aegis\/session\/)/; + function readWallets(api) { const raw = api.storage.get("wallets", null); return Array.isArray(raw) ? raw : null; @@ -3308,10 +3336,14 @@ function registerPageMessages(api) { // ---- activate --------------------------------------------------------------- module.exports = { + // Returns a promise that settles once Aegis can serve calls. Aegis starts + // on first use, so the call that woke it is the first thing it sees, and + // the host holds that call (up to 5 s) until this promise settles. activate(api) { // No explicit icon — inherit manifest.icon (branded shield data URI) // so the toolbar dock button renders the aegis.x brand mark instead - // of a fallback emoji. + // of a fallback emoji. Same id as the panel addon.json declares, which + // this replaces. api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" }); // Serve reads from memory. The host's storage.get() does a readFileSync // plus a JSON.parse of the ENTIRE add-on store on every single call, on @@ -3323,7 +3355,7 @@ module.exports = { // Safe because this process is the only writer: Aegis's panel talks to // the host over addon messages and never touches addon storage directly. // If that ever changes, this cache has to go or be invalidated. - installStorageCache(api); + installStorageCache(api, (key) => { if (LAUNCH_START_KEYS.test(key)) syncLaunchStart(api); }); const c = ctx = { api, d: null, @@ -3344,6 +3376,8 @@ module.exports = { migrateLegacyStorage(api); registerPanelMessages(api); registerPageMessages(api); + launchStartAsked = null; + syncLaunchStart(api); // Restore the user's opt-in choice from storage. Off by default so a // fresh install never hits any oracle without asking. Source can also // be pre-restored so a user who picked Kraken stays on Kraken. @@ -3355,7 +3389,7 @@ module.exports = { // for the browser chrome to paint so the cost never delays Theseus's // first frame; older hosts without whenUiReady load immediately. const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve(); - uiReady.then(() => loadDeps(api)).then((d) => { + const started = uiReady.then(() => loadDeps(api)).then(async (d) => { if (ctx !== c) return; c.d = d; // Start the WizardConnect manager once deps are ready. Per-wallet @@ -3380,12 +3414,22 @@ module.exports = { }, }); c.wc.onStateChange(() => emitState()); - return tryAutoUnlock(api).then(() => mountAllWallets()); - }).catch((e) => { + await tryAutoUnlock(api); + // vault.derive() polls until the user unlocks, so with a locked vault + // mountAllWallets() never settles. Only wait for it when the vault is + // open; otherwise the waking call gets the usual locked answer now + // rather than after the host's 5 s timeout. + const st = await api.vault.lifecycle.status().catch(() => null); + const mounting = mountAllWallets(); + if (st && st.unlocked) await mounting; + else mounting.catch((e) => api.log("mount:", e?.message || e)); + }); + started.catch((e) => { if (ctx !== c) return; api.log("startup failed:", e?.message); emitState(); }); + return started.catch(() => {}); }, deactivate() { const c = ctx; ctx = null;