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.
This commit is contained in:
parent
129e4c6729
commit
9f46d932b6
3 changed files with 59 additions and 154 deletions
|
|
@ -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/<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:
|
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
{
|
{
|
||||||
"id": "aegis",
|
"id": "aegis",
|
||||||
"name": "Aegis Wallet",
|
"name": "Aegis Wallet",
|
||||||
"version": "0.28.1",
|
"version": "0.29.0",
|
||||||
"category": "plugin",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"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",
|
"updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"sidebar-panel",
|
"sidebar-panel",
|
||||||
|
|
|
||||||
|
|
@ -438,7 +438,7 @@ function coinsForPanel() {
|
||||||
// host so nothing changes about durability or who owns the file; only the
|
// 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
|
// repeated parsing of it disappears. Falls back to the host's storage
|
||||||
// untouched if the api object will not accept the substitution.
|
// untouched if the api object will not accept the substitution.
|
||||||
function installStorageCache(api) {
|
function installStorageCache(api, onSet) {
|
||||||
const raw = api.storage;
|
const raw = api.storage;
|
||||||
if (!raw || typeof raw.all !== "function") return false;
|
if (!raw || typeof raw.all !== "function") return false;
|
||||||
let mem = null;
|
let mem = null;
|
||||||
|
|
@ -454,6 +454,7 @@ function installStorageCache(api) {
|
||||||
set: (key, value) => {
|
set: (key, value) => {
|
||||||
load()[key] = value;
|
load()[key] = value;
|
||||||
raw.set(key, value);
|
raw.set(key, value);
|
||||||
|
if (onSet) try { onSet(key); } catch {}
|
||||||
},
|
},
|
||||||
all: () => ({ ...load() }),
|
all: () => ({ ...load() }),
|
||||||
};
|
};
|
||||||
|
|
@ -463,6 +464,33 @@ function installStorageCache(api) {
|
||||||
} catch { return false; }
|
} 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) {
|
function readWallets(api) {
|
||||||
const raw = api.storage.get("wallets", null);
|
const raw = api.storage.get("wallets", null);
|
||||||
return Array.isArray(raw) ? raw : null;
|
return Array.isArray(raw) ? raw : null;
|
||||||
|
|
@ -3308,10 +3336,14 @@ function registerPageMessages(api) {
|
||||||
// ---- activate ---------------------------------------------------------------
|
// ---- activate ---------------------------------------------------------------
|
||||||
|
|
||||||
module.exports = {
|
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) {
|
activate(api) {
|
||||||
// No explicit icon — inherit manifest.icon (branded shield data URI)
|
// No explicit icon — inherit manifest.icon (branded shield data URI)
|
||||||
// so the toolbar dock button renders the aegis.x brand mark instead
|
// 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" });
|
api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" });
|
||||||
// Serve reads from memory. The host's storage.get() does a readFileSync
|
// 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
|
// 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
|
// Safe because this process is the only writer: Aegis's panel talks to
|
||||||
// the host over addon messages and never touches addon storage directly.
|
// the host over addon messages and never touches addon storage directly.
|
||||||
// If that ever changes, this cache has to go or be invalidated.
|
// 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 = {
|
const c = ctx = {
|
||||||
api,
|
api,
|
||||||
d: null,
|
d: null,
|
||||||
|
|
@ -3344,6 +3376,8 @@ module.exports = {
|
||||||
migrateLegacyStorage(api);
|
migrateLegacyStorage(api);
|
||||||
registerPanelMessages(api);
|
registerPanelMessages(api);
|
||||||
registerPageMessages(api);
|
registerPageMessages(api);
|
||||||
|
launchStartAsked = null;
|
||||||
|
syncLaunchStart(api);
|
||||||
// Restore the user's opt-in choice from storage. Off by default so a
|
// 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
|
// fresh install never hits any oracle without asking. Source can also
|
||||||
// be pre-restored so a user who picked Kraken stays on Kraken.
|
// 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
|
// for the browser chrome to paint so the cost never delays Theseus's
|
||||||
// first frame; older hosts without whenUiReady load immediately.
|
// first frame; older hosts without whenUiReady load immediately.
|
||||||
const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve();
|
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;
|
if (ctx !== c) return;
|
||||||
c.d = d;
|
c.d = d;
|
||||||
// Start the WizardConnect manager once deps are ready. Per-wallet
|
// Start the WizardConnect manager once deps are ready. Per-wallet
|
||||||
|
|
@ -3380,12 +3414,22 @@ module.exports = {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
c.wc.onStateChange(() => emitState());
|
c.wc.onStateChange(() => emitState());
|
||||||
return tryAutoUnlock(api).then(() => mountAllWallets());
|
await tryAutoUnlock(api);
|
||||||
}).catch((e) => {
|
// 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;
|
if (ctx !== c) return;
|
||||||
api.log("startup failed:", e?.message);
|
api.log("startup failed:", e?.message);
|
||||||
emitState();
|
emitState();
|
||||||
});
|
});
|
||||||
|
return started.catch(() => {});
|
||||||
},
|
},
|
||||||
deactivate() {
|
deactivate() {
|
||||||
const c = ctx; ctx = null;
|
const c = ctx; ctx = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue