theseus/NOTE-aegis-on-demand.md
Local Dev 434f6d35d0 Theseus: hand-off for moving Aegis to on-demand activation
Aegis is most of the remaining launch cost: ~165 ms of synchronous
activation, then ~610 ms of main-thread work loading its crypto and
WizardConnect deps. It was left on startup because another session
owns bundled-addons/aegis. NOTE-aegis-on-demand.md says exactly what it
needs, measured against 0.27.1:

- addon.json: "activation": "on-demand", plus panels [{id:"main",
  title:"Wallet", page:"panel.html"}].
- activate() must return a promise that resolves once deps are loaded
  and, only if the vault is already unlocked, wallets are mounted.
  vault.derive never rejects on a locked vault, so awaiting
  mountAllWallets there would stall every first call for the full 5 s
  host wait.
- Persisted WizardConnect pairings (wc/<walletId>/uris) need a relay
  listener: call api.startAtLaunch(true) while any exist, false when
  none are left.
- Bridge calls, panel open, wiz:// links and Settings calls already wake
  it. The dapp globals are injected from the manifest before it runs.

GOTCHAS: an enabled add-on may not be running, so code in main reaches
add-ons through dispatch() rather than checking isActive()/hasHandler().
2026-10-03 16:05:41 +02:00

7.3 KiB
Raw Blame History

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). 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

"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:

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/<walletId>/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:

// 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.