Merge extensions-on-first-use: add-ons start when first used, not at launch

The left-edge panels landed meanwhile, so a panel record now carries both
its side and replace-by-id; a manifest-declared panel may say side:left too,
or a dormant add-on would show its panel on the wrong edge until it starts.
This commit is contained in:
Local Dev 2026-10-03 21:07:44 +02:00
commit 278da658ce
11 changed files with 511 additions and 35 deletions

View file

@ -81,6 +81,24 @@ The same applies to `home-preload.js`, which is in *every* tab. Handlers
behind it check `isHomePageSender` / `isErrorPageSender`, which compare behind it check `isHomePageSender` / `isErrorPageSender`, which compare
the sender against the exact shipped file:// URL. the sender against the exact shipped file:// URL.
## An enabled add-on is not necessarily running
An add-on whose `addon.json` says `"activation": "on-demand"` is listed at
launch but not started. Its declared `panels`, `toolbar-menu`,
`context-menu-items` and `page-inject` bridge are live anyway, and
`AddonHost.dispatch()` starts it on the first call (see `ensureActive` in
[addons-host.js](addons-host.js)). So in main:
- Reach an add-on through `addonHost.dispatch(...)`, never by checking
`isActive()` / `hasHandler()` first. Both are false for a waiting
add-on, and the call would never wake it (`routeWizUri` shows the
pattern for when you really need to check).
- "Enabled" in the snapshot means running or waiting; `running` tells
them apart.
- Settings › Performance › "Start extensions when first used" off makes
every add-on start at launch again, which is the quickest way to rule
this out when an add-on "does nothing".
## The resolver in Theseus is `resolver-web.mjs`, not `.js` ## The resolver in Theseus is `resolver-web.mjs`, not `.js`
Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs` Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs`

147
NOTE-aegis-on-demand.md Normal file
View file

@ -0,0 +1,147 @@
# 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.

View file

@ -8,7 +8,11 @@
// dev-mode browser extensions: the user is choosing to run local code with // dev-mode browser extensions: the user is choosing to run local code with
// the app's full privileges. // the app's full privileges.
// //
// Loading is synchronous at app-ready time; there is no hot-reload. Failed // Discovery is synchronous at app-ready time. An add-on whose manifest says
// "activation": "startup" (the default) is activated right then; one that
// says "on-demand" is only listed — its declared panels, toolbar menu,
// context-menu items and page-inject bridge are live, and activate() runs
// the first time one of them is actually used (see ensureActive). Failed
// activations are logged and skipped without breaking the app. // activations are logged and skipped without breaking the app.
// //
// Persistence: // Persistence:
@ -19,6 +23,10 @@ const fs = require("node:fs");
const { storeFor } = require("./lib/addon-store.cjs"); const { storeFor } = require("./lib/addon-store.cjs");
const path = require("node:path"); const path = require("node:path");
// How long a call waits for an async activate() to settle before it is
// delivered anyway.
const READY_WAIT_MS = 5000;
// Extension points the framework understands. Extending this list means also // Extension points the framework understands. Extending this list means also
// teaching main.js and (typically) the chrome renderer about the new point. // teaching main.js and (typically) the chrome renderer about the new point.
// Right now only sidebar panels are wired — future rev adds toolbar-chip, // Right now only sidebar panels are wired — future rev adds toolbar-chip,
@ -212,19 +220,64 @@ function validateManifest(raw, folderName) {
// dock: "hidden" — the add-on starts without a toolbar button (its settings // dock: "hidden" — the add-on starts without a toolbar button (its settings
// live in Settings); main honours it once, the user can show it later. // live in Settings); main honours it once, the user can show it later.
const dock = m.dock === "hidden" ? "hidden" : undefined; const dock = m.dock === "hidden" ? "hidden" : undefined;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock }; // panels: sidebar panels declared up front — [{id, title, icon?, page}].
// The dock shows them before the add-on runs, and they are registered
// for it at activation, so activate() does not have to call
// registerSidebarPanel for them (calling it with the same id replaces
// the declared entry).
const panels = [];
if (m.panels != null) {
if (!Array.isArray(m.panels)) throw new Error(`addon "${id}": "panels" must be an array`);
const seen = new Set();
m.panels.forEach((p, idx) => {
const pid = String(p && p.id || "").trim();
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(pid)) throw new Error(`addon "${id}": panels[${idx}].id is required and must match [a-z0-9._-]`);
if (seen.has(pid)) throw new Error(`addon "${id}": panels[${idx}].id "${pid}" duplicates an earlier entry`);
seen.add(pid);
const page = String(p.page || "").replace(/^[\\/]+/, "");
if (!page || page.includes("..") || path.isAbsolute(page)) throw new Error(`addon "${id}": panels[${idx}].page must be a relative path inside the addon folder`);
panels.push({ id: pid, title: String(p.title || name), icon: p.icon == null ? icon : String(p.icon), page, side: p.side === "left" ? "left" : "right" });
});
}
// activation: "startup" runs activate() at launch; "on-demand" waits for
// the first use of something the manifest declares. Startup is the
// default because the host cannot know what an add-on that predates this
// field does in activate() — most register their panels there, and some
// start work that must not wait (timers, a proxy, a filter). Two cases
// are forced back to startup: a request filter has to see the first
// request, and an add-on that declares nothing could never be woken.
let activation = m.activation === "on-demand" ? "on-demand" : "startup";
let activationNote = "";
if (activation === "on-demand") {
if (capabilities.includes("request-filter")) { activation = "startup"; activationNote = "request-filter add-ons start at launch"; }
else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject) { activation = "startup"; activationNote = "declares nothing that could start it"; }
}
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, activation, activationNote };
} }
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`. // of the app queries via `getActive()` / `getInstalled()`.
class AddonHost { class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, scanTabForUris, checkAndStageUpdates, restartApp, revealSidebar, requestFilter, tabs }) { constructor({ addonsDir, dataDir, isDisabled, activationFor, onActivated, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, scanTabForUris, checkAndStageUpdates, restartApp, revealSidebar, requestFilter, tabs }) {
this.addonsDir = addonsDir; this.addonsDir = addonsDir;
this.dataDir = dataDir; this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false); this.isDisabled = isDisabled || (() => false);
this.log = logger || ((...a) => console.log("[addons]", ...a)); this.log = logger || ((...a) => console.log("[addons]", ...a));
this._installed = []; // [{ manifest, folder, error? }] this._installed = []; // [{ manifest, folder, error? }]
this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject } this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject, ready }
// On-demand add-ons that are enabled but not started yet:
// id -> { manifest, folder, panels, injectSource }.
this._dormant = new Map();
this._activating = new Map(); // id -> Promise of a first-use activation
// First-use activations run one at a time, in the order they were asked for.
this._queue = Promise.resolve();
// activationFor(manifest) -> "startup" | "on-demand": main's settings may
// override the manifest (Settings › Performance › Startup).
this._activationFor = typeof activationFor === "function" ? activationFor : (m) => m.activation;
this._onActivated = typeof onActivated === "function" ? onActivated : null;
// setStartAtLaunch(id, on): backs api.startAtLaunch; main persists it and
// folds it into activationFor.
this._setStartAtLaunch = typeof arguments[0].setStartAtLaunch === "function" ? arguments[0].setStartAtLaunch : null;
// Capability hooks injected by main. Each is (args..., addonId) so main // Capability hooks injected by main. Each is (args..., addonId) so main
// can log/gate per add-on. Missing hook = capability unavailable. // can log/gate per add-on. Missing hook = capability unavailable.
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
@ -340,8 +393,14 @@ class AddonHost {
discoverAndActivate() { discoverAndActivate() {
this.ensureDirs(); this.ensureDirs();
// A re-discover (toggle, reload, hot-applied update) restarts whatever
// was running, on-demand or not: its panel or tab may be open and
// listening for api.emit, and nothing would wake it again until that
// page next called in.
const wasRunning = new Set(this._active.keys());
this._deactivateAll(); this._deactivateAll();
this._installed = []; this._installed = [];
this._dormant.clear();
let entries = []; let entries = [];
try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; }
for (const dirent of entries) { for (const dirent of entries) {
@ -354,6 +413,14 @@ class AddonHost {
this.log(`skipping disabled add-on ${manifest.id}`); this.log(`skipping disabled add-on ${manifest.id}`);
continue; continue;
} }
if (manifest.activationNote) this.log(`${manifest.id}: starts at launch (${manifest.activationNote})`);
if (!wasRunning.has(manifest.id) && this._activationFor(manifest) === "on-demand") {
if (this._active.has(manifest.id) || this._dormant.has(manifest.id)) throw new Error(`duplicate add-on id "${manifest.id}"`);
this._dormant.set(manifest.id, { manifest, folder, panels: this._resolvePanels(manifest, folder), injectSource: null });
this.log(`${manifest.id} v${manifest.version} starts on first use`);
continue;
}
if (this._dormant.has(manifest.id)) throw new Error(`duplicate add-on id "${manifest.id}"`);
this._activateOne(manifest, folder); this._activateOne(manifest, folder);
} catch (e) { } catch (e) {
this.log(`failed to load ${dirent.name}: ${e?.message || e}`); this.log(`failed to load ${dirent.name}: ${e?.message || e}`);
@ -373,6 +440,61 @@ class AddonHost {
return validateManifest(raw, folderName); return validateManifest(raw, folderName);
} }
// Declared panels → the records getSidebarPanels hands out. A declared
// page that is missing fails the add-on's load, same as a bad manifest.
_resolvePanels(manifest, folder) {
return (manifest.panels || []).map((p) => {
const abs = path.join(folder, p.page);
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
return { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" };
});
}
// Start an on-demand add-on now, if it isn't running yet. Resolves once
// activate() has run and, when it returned a promise, that promise has
// settled (bounded by READY_WAIT_MS) — so a call that woke the add-on
// finds the handlers it registers. Callers arriving while an activation
// is under way share its promise. Activations run one at a time.
ensureActive(id, reason = "") {
const running = this._active.get(id);
if (running) return running.ready || Promise.resolve();
const pending = this._activating.get(id);
if (pending) return pending;
const d = this._dormant.get(id);
if (!d) return Promise.reject(new Error(`add-on "${id}" is not active`));
const p = this._queue.then(() => {
const now = this._active.get(id);
if (now) return now.ready;
// A re-discover while this waited in the queue replaced the entry.
if (this._dormant.get(id) !== d) throw new Error(`add-on "${id}" is no longer installed`);
this._dormant.delete(id);
const t0 = Date.now();
try { this._activateOne(d.manifest, d.folder); }
catch (e) {
const msg = String(e?.message || e);
this.log(`failed to start ${id}: ${msg}`);
const i = this._installed.findIndex((x) => x.folder === d.folder);
if (i >= 0) this._installed[i] = { manifest: null, folder: d.folder, error: msg };
try { if (this._onActivated) this._onActivated(id, false); } catch {}
throw e;
}
this.log(`started ${id} on first use${reason ? ` (${reason})` : ""} in ${Date.now() - t0} ms`);
try { if (this._onActivated) this._onActivated(id, true); } catch {}
return this._active.get(id).ready;
}).finally(() => { if (this._activating.get(id) === p) this._activating.delete(id); });
this._activating.set(id, p);
this._queue = p.catch(() => {});
return p;
}
// Start every add-on still waiting for first use (the setting that defers
// them was switched off).
activateAllDormant(reason = "") {
return Promise.all([...this._dormant.keys()].map((id) => this.ensureActive(id, reason).catch(() => {})));
}
isDormant(id) { return this._dormant.has(id); }
// Enabled = running or waiting for first use.
isEnabled(id) { return this._active.has(id) || this._dormant.has(id); }
_activateOne(manifest, folder) { _activateOne(manifest, folder) {
const mainPath = path.join(folder, manifest.main); const mainPath = path.join(folder, manifest.main);
if (this._active.has(manifest.id)) { if (this._active.has(manifest.id)) {
@ -400,7 +522,8 @@ class AddonHost {
if (!mod || typeof mod.activate !== "function") { if (!mod || typeof mod.activate !== "function") {
throw new Error(`main file must export an activate(api) function`); throw new Error(`main file must export an activate(api) function`);
} }
const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null, tabListeners: [] }; // Declared panels are registered up front; activate() may still add more.
const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], ready: Promise.resolve() };
if (manifest.pageInject) { if (manifest.pageInject) {
// Read the inject source once at activation. It's shipped to every // Read the inject source once at activation. It's shipped to every
// matching tab's preload verbatim, so a syntax error surfaces in the // matching tab's preload verbatim, so a syntax error surfaces in the
@ -420,7 +543,17 @@ class AddonHost {
}; };
try { try {
const r = mod.activate(api); const r = mod.activate(api);
if (r && typeof r.then === "function") r.catch((e) => this.log(`[${manifest.id}] activate() rejected: ${e?.message || e}`)); if (r && typeof r.then === "function") {
r.catch((e) => this.log(`[${manifest.id}] activate() rejected: ${e?.message || e}`));
// Calls wait for this (see dispatch) — bounded, because some
// activations await things that can take forever (a vault the user
// never unlocks).
active.ready = new Promise((resolve) => {
const t = setTimeout(resolve, READY_WAIT_MS);
if (t.unref) t.unref();
Promise.resolve(r).catch(() => {}).then(() => { clearTimeout(t); resolve(); });
});
}
} catch (e) { cleanup(); throw new Error(`activate() threw: ${e?.message || e}`); } } catch (e) { cleanup(); throw new Error(`activate() threw: ${e?.message || e}`); }
this._active.set(manifest.id, active); this._active.set(manifest.id, active);
this.log(`activated ${manifest.id} v${manifest.version}`); this.log(`activated ${manifest.id} v${manifest.version}`);
@ -474,7 +607,9 @@ class AddonHost {
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
// Namespaced id so two add-ons can't collide. // Namespaced id so two add-ons can't collide.
const panelId = `${manifest.id}:${id}`; const panelId = `${manifest.id}:${id}`;
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" }); const rec = { panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" };
const at = active.sidebarPanels.findIndex((p) => p.panelId === panelId);
if (at >= 0) active.sidebarPanels[at] = rec; else active.sidebarPanels.push(rec);
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
}, },
// Programmatically open the sidebar and switch to one of THIS add-on's // Programmatically open the sidebar and switch to one of THIS add-on's
@ -612,6 +747,13 @@ class AddonHost {
if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)");
return Promise.resolve(this._restartApp(manifest.name || manifest.id)); return Promise.resolve(this._restartApp(manifest.name || manifest.id));
}, },
// An on-demand add-on that has started something the user expects to
// keep working with Theseus closed to it — a live relay session, say —
// asks to be started at launch from the next start on (false undoes
// it). Saved by the host; no effect on a startup add-on.
startAtLaunch: (on) => {
if (this._setStartAtLaunch) this._setStartAtLaunch(manifest.id, !!on);
},
// Resolves once the browser chrome has painted (immediately if it // Resolves once the browser chrome has painted (immediately if it
// already has). Put expensive dependency loading behind this so it // already has). Put expensive dependency loading behind this so it
// never competes with the first frame at launch. // never competes with the first frame at launch.
@ -736,10 +878,26 @@ class AddonHost {
} }
// Route a message to an add-on's registered handler. Callers (main) have // Route a message to an add-on's registered handler. Callers (main) have
// already established WHO is asking; `ctx` carries that provenance. // already established WHO is asking; `ctx` carries that provenance. An
// on-demand add-on that hasn't started yet is started first — this is the
// path every first use goes through (panel and add-on-tab pages, page
// bridges, toolbar and context menus, Settings), and the call waits for
// the activation rather than being dropped.
async dispatch(id, msg, payload, ctx) { async dispatch(id, msg, payload, ctx) {
const active = this._active.get(id); if (!this._active.has(id)) {
if (!this._dormant.has(id) && !this._activating.has(id)) throw new Error(`add-on "${id}" is not active`);
await this.ensureActive(id, `${(ctx && ctx.from) || "call"} ${msg}`);
}
// An add-on whose activate() returned a promise gets calls only once it
// has settled (bounded): handlers may be registered after an await, or
// need state the activation is still loading.
let active = this._active.get(id);
if (!active) throw new Error(`add-on "${id}" is not active`); if (!active) throw new Error(`add-on "${id}" is not active`);
if (active.ready) {
await active.ready;
active = this._active.get(id);
if (!active) throw new Error(`add-on "${id}" is not active`);
}
const handler = active.handlers.get(String(msg)); const handler = active.handlers.get(String(msg));
if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`);
return handler(payload, ctx || {}); return handler(payload, ctx || {});
@ -748,13 +906,36 @@ class AddonHost {
const active = this._active.get(id); const active = this._active.get(id);
return !!(active && active.handlers.has(String(msg))); return !!(active && active.handlers.has(String(msg)));
} }
// Inject scripts that apply to a tab URL — [{ id, source }]. // Running and waiting add-ons in discovery order — the order the dock,
// menus and Settings list them in, which must not change when an add-on
// starts.
_enabledEntries() {
const out = [];
for (const { manifest } of this._installed) {
if (!manifest) continue;
const active = this._active.get(manifest.id);
const dormant = active ? null : this._dormant.get(manifest.id);
if (active || dormant) out.push({ manifest: (active || dormant).manifest, active, dormant });
}
return out;
}
// Inject scripts that apply to a tab URL — [{ id, source }]. A waiting
// add-on's bridge is injected too (its source is read on first match); the
// bridge's first message is what starts the add-on.
injectionsFor(url) { injectionsFor(url) {
const out = []; const out = [];
for (const active of this._active.values()) { for (const { manifest, active, dormant } of this._enabledEntries()) {
if (active.inject && urlMatchesAny(url, active.inject.matchers)) { if (active) {
out.push({ id: active.manifest.id, source: active.inject.source }); if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source });
continue;
} }
const pi = manifest.pageInject;
if (!pi || !urlMatchesAny(url, pi.matchers)) continue;
if (dormant.injectSource == null) {
try { dormant.injectSource = fs.readFileSync(path.join(dormant.folder, pi.preload), "utf8"); }
catch (e) { this.log(`[${manifest.id}] page-inject preload not readable: ${e?.message || e}`); dormant.injectSource = ""; }
}
if (dormant.injectSource) out.push({ id: manifest.id, source: dormant.injectSource });
} }
return out; return out;
} }
@ -762,7 +943,9 @@ class AddonHost {
// page → add-on IPC so a non-matching page can't spoof a matching one. // page → add-on IPC so a non-matching page can't spoof a matching one.
pageAllowed(id, url) { pageAllowed(id, url) {
const active = this._active.get(id); const active = this._active.get(id);
return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers)); if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers));
const d = this._dormant.get(id);
return !!(d && d.manifest.pageInject && urlMatchesAny(url, d.manifest.pageInject.matchers));
} }
// Read-only views for the rest of the app. // Read-only views for the rest of the app.
@ -781,7 +964,11 @@ class AddonHost {
// the raw Extensions list. Absent → plain extension. // the raw Extensions list. Absent → plain extension.
category: manifest?.category || null, category: manifest?.category || null,
folder, folder,
enabled: manifest?.id ? this._active.has(manifest.id) : false, enabled: manifest?.id ? this.isEnabled(manifest.id) : false,
// What the manifest asks for, and whether it is running right now
// (false while an enabled on-demand add-on waits for first use).
activation: manifest?.activation || null,
running: manifest?.id ? this._active.has(manifest.id) : false,
error: error || null, error: error || null,
})), })),
sidebarPanels: this.getSidebarPanels(), sidebarPanels: this.getSidebarPanels(),
@ -793,25 +980,25 @@ class AddonHost {
// instead of the extensions row. // instead of the extensions row.
getSidebarPanels() { getSidebarPanels() {
const out = []; const out = [];
for (const active of this._active.values()) { for (const { manifest, active, dormant } of this._enabledEntries()) {
const plugin = active.manifest.category === "plugin"; const plugin = manifest.category === "plugin";
for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name }); for (const p of (active ? active.sidebarPanels : dormant.panels)) out.push({ ...p, plugin, addonName: manifest.name });
} }
return out; return out;
} }
// Menu declarations from every active add-on that carries a toolbar-menu // Menu declarations from every enabled add-on that carries a toolbar-menu
// manifest block. Chrome renders one dock button per entry, opens the // manifest block. Chrome renders one dock button per entry, opens the
// dropdown, then dispatches "menu-select" with the picked item id. // dropdown, then dispatches "menu-select" with the picked item id.
getToolbarMenus() { getToolbarMenus() {
const out = []; const out = [];
for (const active of this._active.values()) { for (const { manifest } of this._enabledEntries()) {
const tm = active.manifest.toolbarMenu; const tm = manifest.toolbarMenu;
if (!tm) continue; if (!tm) continue;
out.push({ out.push({
addonId: active.manifest.id, addonId: manifest.id,
title: tm.title, title: tm.title,
icon: tm.icon, icon: tm.icon,
plugin: active.manifest.category === "plugin", plugin: manifest.category === "plugin",
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
}); });
} }
@ -834,20 +1021,19 @@ class AddonHost {
image: ctx.mediaType === "image" && !!ctx.srcURL, image: ctx.mediaType === "image" && !!ctx.srcURL,
}; };
const out = []; const out = [];
for (const active of this._active.values()) { for (const { manifest } of this._enabledEntries()) {
const items = active.manifest.contextMenuItems || []; for (const it of manifest.contextMenuItems || []) {
for (const it of items) {
if (it.when !== "always" && !has[it.when]) continue; if (it.when !== "always" && !has[it.when]) continue;
out.push({ addonId: active.manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when }); out.push({ addonId: manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when });
} }
} }
return out; return out;
} }
getInstalled() { return this._installed.slice(); } getInstalled() { return this._installed.slice(); }
isActive(id) { return this._active.has(id); } isActive(id) { return this._active.has(id); }
// Absolute folder of an active add-on, or null. Public so main can resolve // Absolute folder of an enabled add-on, or null. Public so main can resolve
// add-on-relative paths (openAddonTab) without reaching into internals. // add-on-relative paths (openAddonTab) without reaching into internals.
folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; } folderOf(id) { const a = this._active.get(id) || this._dormant.get(id); return a ? a.folder : null; }
} }
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern }; module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };

View file

@ -6,6 +6,10 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M6%202h8l6%206v12a2%202%200%200%201-2%202H6a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2z%22%20fill%3D%22%232563eb%22%2F%3E%3Cpath%20d%3D%22M14%202v6h6z%22%20fill%3D%22%231e40af%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20text-anchor%3D%22middle%22%20font-family%3D%22system-ui%2C-apple-system%2Csans-serif%22%20font-size%3D%225.5%22%20font-weight%3D%22800%22%20fill%3D%22%23fff%22%3EDOC%3C%2Ftext%3E%3C%2Fsvg%3E", "icon": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M6%202h8l6%206v12a2%202%200%200%201-2%202H6a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2z%22%20fill%3D%22%232563eb%22%2F%3E%3Cpath%20d%3D%22M14%202v6h6z%22%20fill%3D%22%231e40af%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20text-anchor%3D%22middle%22%20font-family%3D%22system-ui%2C-apple-system%2Csans-serif%22%20font-size%3D%225.5%22%20font-weight%3D%22800%22%20fill%3D%22%23fff%22%3EDOC%3C%2Ftext%3E%3C%2Fsvg%3E",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"panels": [
{ "id": "main", "title": "Word editor", "page": "panel.html" }
],
"capabilities": [ "capabilities": [
"sidebar-panel", "sidebar-panel",
"open-tab" "open-tab"

View file

@ -6,5 +6,9 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "📝", "icon": "📝",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"panels": [
{ "id": "main", "title": "Notepad", "icon": "📝", "page": "note.html" }
],
"capabilities": ["sidebar-panel"] "capabilities": ["sidebar-panel"]
} }

View file

@ -6,6 +6,7 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"capabilities": ["toolbar-menu", "open-tab", "context-menu-item"], "capabilities": ["toolbar-menu", "open-tab", "context-menu-item"],
"toolbar-menu": { "toolbar-menu": {
"title": "PDF Editor", "title": "PDF Editor",

View file

@ -6,6 +6,10 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "📸", "icon": "📸",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"panels": [
{ "id": "main", "title": "Screenshot", "icon": "📸", "page": "panel.html" }
],
"capabilities": ["sidebar-panel", "capture-tab"], "capabilities": ["sidebar-panel", "capture-tab"],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json" "updateURL": "https://navigate.st/bns/theseus.x/extensions/screenshot/updates.json"
} }

View file

@ -6,6 +6,10 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"panels": [
{ "id": "main", "title": "Translate", "page": "panel.html" }
],
"capabilities": [ "capabilities": [
"sidebar-panel", "sidebar-panel",
"context-menu-item" "context-menu-item"

View file

@ -6,6 +6,10 @@
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=",
"main": "index.js", "main": "index.js",
"activation": "on-demand",
"panels": [
{ "id": "main", "title": "VPN", "page": "panel.html" }
],
"capabilities": ["sidebar-panel", "session-proxy"], "capabilities": ["sidebar-panel", "session-proxy"],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/vpn/updates.json" "updateURL": "https://navigate.st/bns/theseus.x/extensions/vpn/updates.json"
} }

54
main.js
View file

@ -479,6 +479,12 @@ const SETTINGS_DEFAULTS = {
restoreSession: true, // reopen last session's tabs on launch restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
freezeBackgroundTabs: true, // a tab you switch away from stops (JS, timers, media) unless marked "Keep running" freezeBackgroundTabs: true, // a tab you switch away from stops (JS, timers, media) unless marked "Keep running"
// Startup (Settings › Performance). Off = the old behaviour: every enabled
// extension's activate() runs at launch, before the window exists.
extensionsOnDemand: true, // extensions whose manifest allows it start on first use
walletAtLaunch: false, // start Aegis at launch even once it allows on-demand (no effect while it is startup-only)
preloadMenus: true, // load address suggestions / link pill / site info after the first page (prewarmOverlays)
addonsStartAtLaunch: [], // on-demand add-ons that asked (api.startAtLaunch) to start at launch anyway
// Storage retention — nothing persists by default. Auto-clear on quit // Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type. // means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit: true, // drop cookies + logins + saved-form data clearCookiesOnQuit: true, // drop cookies + logins + saved-form data
@ -1446,7 +1452,7 @@ const justClosedByClickAway = (view) => Date.now() - (clickAwayClosedAt.get(view
// Idle prewarm, one at a time, of the overlays used in nearly every session. // Idle prewarm, one at a time, of the overlays used in nearly every session.
let overlaysPrewarmed = false; let overlaysPrewarmed = false;
async function prewarmOverlays() { async function prewarmOverlays() {
if (overlaysPrewarmed) return; if (overlaysPrewarmed || settings.preloadMenus === false) return;
overlaysPrewarmed = true; overlaysPrewarmed = true;
for (const view of [addressPicker, linkStatus, popover]) { for (const view of [addressPicker, linkStatus, popover]) {
if (!view || !winAlive()) return; if (!view || !winAlive()) return;
@ -2339,6 +2345,26 @@ function initAddons() {
}, },
dataDir: addonsDataDir(), dataDir: addonsDataDir(),
isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id), isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id),
// Settings › Performance › Startup can only move an add-on towards
// startup, never make a startup-only manifest on-demand.
activationFor: (m) => {
if (m.activation !== "on-demand") return "startup";
if (settings.extensionsOnDemand === false) return "startup";
if (m.id === "aegis" && settings.walletAtLaunch) return "startup";
if (Array.isArray(settings.addonsStartAtLaunch) && settings.addonsStartAtLaunch.includes(m.id)) return "startup";
return "on-demand";
},
setStartAtLaunch: (id, on) => {
const ids = new Set(Array.isArray(settings.addonsStartAtLaunch) ? settings.addonsStartAtLaunch : []);
if (ids.has(id) === on) return;
if (on) ids.add(id); else ids.delete(id);
settings.addonsStartAtLaunch = [...ids];
saveSettings();
console.log(`[addons] ${id} ${on ? "asked to start at launch" : "no longer needs to start at launch"}`);
},
// A started add-on may register panels beyond the ones it declared, and
// one that failed to start drops out of the dock.
onActivated: () => emitSidebarState(),
logger: (...a) => console.log("[addons]", ...a), logger: (...a) => console.log("[addons]", ...a),
// Session-proxy capability. Add-ons that declare "session-proxy" in // Session-proxy capability. Add-ons that declare "session-proxy" in
// their manifest can call api.setSessionProxy(rules) to swap // their manifest can call api.setSessionProxy(rules) to swap
@ -2841,7 +2867,7 @@ function initAddons() {
}); });
addonHost.discoverAndActivate(); addonHost.discoverAndActivate();
const snap = addonHost.snapshot(); const snap = addonHost.snapshot();
console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled, ${snap.sidebarPanels.length} sidebar panels`); console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled (${snap.installed.filter((x) => x.running).length} started at launch), ${snap.sidebarPanels.length} sidebar panels`);
} }
// Given a webContents sender URL, work out which add-on folder it lives in. // Given a webContents sender URL, work out which add-on folder it lives in.
// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch // Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch
@ -3155,6 +3181,12 @@ function setSidebar(show, panelId) {
if (panelId) { console.warn(`setSidebar: no panel "${panelId}"; ignoring`); return; } if (panelId) { console.warn(`setSidebar: no panel "${panelId}"; ignoring`); return; }
panel = panels[0]; panel = panels[0];
} }
// Opening a panel is a first use: start its add-on alongside the page
// load. The panel's own calls would start it anyway, but a panel that
// only listens for api.emit never calls.
if (addonHost.isDormant(panel.addonId)) {
addonHost.ensureActive(panel.addonId, "panel opened").catch((e) => console.warn(`[addons] ${panel.addonId} failed to start:`, e?.message));
}
if (sidebarActivePanelId !== panel.panelId) { if (sidebarActivePanelId !== panel.panelId) {
sidebarActivePanelId = panel.panelId; sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); } try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
@ -5537,10 +5569,18 @@ function routeWizUri(uri, origin, tabId) {
if (!addonHost) return false; if (!addonHost) return false;
const u = String(uri || ""); const u = String(uri || "");
if (!/^wiz:/i.test(u) || u.length > 4096) return false; if (!/^wiz:/i.test(u) || u.length > 4096) return false;
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false; const send = () => addonHost
addonHost
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId }) .dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err)); .catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
// A wallet that starts on first use is woken by the link itself.
if (addonHost.isDormant("aegis")) {
addonHost.ensureActive("aegis", "wiz link")
.then(() => { if (addonHost.hasHandler("aegis", "wcConnectFromPage")) send(); })
.catch((err) => console.log("[wiz] wallet failed to start:", err?.message || err));
return true;
}
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false;
send();
return true; return true;
} }
@ -7156,6 +7196,12 @@ ipcMain.handle("settings-set", (_e, key, val) => {
if (key === "searchEngine") emitEngines(); // the toolbar button and the picker show the default if (key === "searchEngine") emitEngines(); // the toolbar button and the picker show the default
if (key === "theme") applyTheme(); if (key === "theme") applyTheme();
if (key === "backgroundThrottle") applyThrottle(); if (key === "backgroundThrottle") applyThrottle();
// Switching deferral off (or asking for the wallet at launch) starts what
// is still waiting now rather than at the next launch. Switching it on
// leaves running add-ons alone; it applies from the next launch.
if (addonHost && key === "extensionsOnDemand" && val === false) addonHost.activateAllDormant("setting").catch(() => {});
if (addonHost && key === "walletAtLaunch" && val && addonHost.isDormant("aegis")) addonHost.ensureActive("aegis", "setting").catch(() => {});
if (key === "preloadMenus" && val && chromeReadyDone) prewarmOverlays();
if (key === "freezeBackgroundTabs") applyFreezeSetting(); if (key === "freezeBackgroundTabs") applyFreezeSetting();
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue", if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
"locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); } "locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }

View file

@ -639,6 +639,23 @@
<div class="txt"><div class="t">Stop tabs in the background</div><div class="d">A tab you switch away from stops running — scripts, timers, audio and video — until you come back to it. Right-click a tab and choose “Keep running in background” for music, calls or anything that should keep going.</div></div> <div class="txt"><div class="t">Stop tabs in the background</div><div class="d">A tab you switch away from stops running — scripts, timers, audio and video — until you come back to it. Right-click a tab and choose “Keep running in background” for music, calls or anything that should keep going.</div></div>
<label class="sw"><input type="checkbox" id="freezeBackgroundTabs"><span class="track"><span class="knob"></span></span></label> <label class="sw"><input type="checkbox" id="freezeBackgroundTabs"><span class="track"><span class="knob"></span></span></label>
</div> </div>
<h2 class="sub">Startup</h2>
<div class="row">
<div class="txt"><div class="t">Start extensions when first used</div><div class="d">Extensions such as Notepad, Screenshot, Translate and the document editors start the first time you open them, instead of every time Theseus starts. Their buttons and menu items are there either way. Shield and Cookie Pop-ups always start right away. <span id="startupNow"></span></div></div>
<label class="sw"><input type="checkbox" id="extensionsOnDemand"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" id="walletAtLaunchRow">
<div class="txt"><div class="t">Start the wallet at launch</div><div class="d" id="walletAtLaunchHint">Aegis is ready the moment a site asks for it, at the cost of a slower start.</div></div>
<label class="sw"><input type="checkbox" id="walletAtLaunch"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Preload common menus</div><div class="d">After the first page loads, get address suggestions, the link preview and site info ready in the background so they open instantly. Off saves memory; each opens a moment slower the first time.</div></div>
<label class="sw"><input type="checkbox" id="preloadMenus"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Lightweight start</div><div class="d">Set all of the above to the lightest choice.</div></div>
<button class="btn small" id="lightStart">Use lightest</button>
</div>
</section> </section>
<!-- PRIVACY --> <!-- PRIVACY -->
<section id="privacy" hidden> <section id="privacy" hidden>
@ -1055,7 +1072,48 @@
el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim())); el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim()));
}); });
})(); })();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc", // Performance › Startup. The three switches are plain TOGGLES; this adds
// what they can't say on their own: how many extensions are running now,
// whether the wallet switch can do anything yet (only once Aegis's
// manifest allows on-demand starts — until then it always starts at
// launch, so the switch is shown but off-limits), and the preset.
(function () {
const $ = (id) => document.getElementById(id);
const onDemand = $("extensionsOnDemand"), wallet = $("walletAtLaunch"), menus = $("preloadMenus");
if (!onDemand || !wallet || !menus) return;
let aegisOnDemand = false;
function syncWallet() {
const usable = aegisOnDemand && onDemand.checked;
wallet.disabled = !usable;
$("walletAtLaunchRow").style.opacity = usable ? "" : ".55";
$("walletAtLaunchHint").textContent = !aegisOnDemand
? "Aegis still starts with Theseus every time, so there is nothing to choose yet. This switch takes over once Aegis can start on first use."
: !onDemand.checked
? "Every extension starts at launch while the switch above is off."
: "Aegis is ready the moment a site asks for it, at the cost of a slower start. Off, it starts the first time a site, a link or its panel needs it.";
}
async function refresh() {
let list = {};
try { list = await (C.listAddons ? C.listAddons() : Promise.resolve({})); } catch {}
const inst = (list.installed || []).filter((a) => a.id && !a.error);
const aegis = inst.find((a) => a.id === "aegis");
$("walletAtLaunchRow").hidden = !aegis;
aegisOnDemand = !!(aegis && aegis.activation === "on-demand");
const on = inst.filter((a) => a.enabled);
$("startupNow").textContent = on.length ? `Running now: ${on.filter((a) => a.running).length} of ${on.length}.` : "";
syncWallet();
}
onDemand.addEventListener("change", syncWallet);
$("lightStart").addEventListener("click", () => {
const want = { extensionsOnDemand: true, walletAtLaunch: false, preloadMenus: false };
for (const [k, v] of Object.entries(want)) { $(k).checked = v; C.set(k, v); }
syncWallet();
});
refresh();
// Running counts change as extensions start; refresh when the page is shown.
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit", "clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow", "translateAutoOffer"]; "quickLinksShow", "translateAutoOffer"];
// Text inputs that round-trip through settings-set on change. Trimmed; a // Text inputs that round-trip through settings-set on change. Trimmed; a
@ -2322,7 +2380,7 @@
box.innerHTML = box.innerHTML =
'<div class="xhead"><button class="xback" id="xback" title="Back to the list" aria-label="Back">‹ Back</button>' '<div class="xhead"><button class="xback" id="xback" title="Back to the list" aria-label="Back">‹ Back</button>'
+ '<span class="xi">' + addonIconHtml(a.icon) + '</span>' + '<span class="xi">' + addonIconHtml(a.icon) + '</span>'
+ '<div class="xtitle"><div class="t">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + (a.enabled ? '' : '<span class="xbadge off">OFF</span>') + '<span class="xv">' + escapeHtml(a.version) + '</span></div>' + '<div class="xtitle"><div class="t">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + (a.enabled ? (a.running ? '' : '<span class="xbadge off" title="Starts the first time you use it">NOT STARTED</span>') : '<span class="xbadge off">OFF</span>') + '<span class="xv">' + escapeHtml(a.version) + '</span></div>'
+ '<div class="d">' + escapeHtml(a.description || "No description.") + '</div></div>' + '<div class="d">' + escapeHtml(a.description || "No description.") + '</div></div>'
+ '<span class="xctl"><label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>' + '<span class="xctl"><label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>'
+ '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options">⋯</button>' + '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options">⋯</button>'