Theseus: start extensions on first use, Startup switches in Settings

Every enabled add-on used to be activated synchronously in initAddons(),
during app.whenReady and before the window exists. That is the largest
launch cost left (boot tracer, 2026-10-03). An add-on can now say
"activation": "on-demand" in addon.json. It is then listed at launch but
not started. Its declared surfaces stay live: "panels" (new: sidebar
panels declared up front), toolbar-menu, context-menu-items and the
page-inject bridge, whose source is read on the first matching page.
activate() runs on first real use: a panel opened, a menu or context
item picked, a message from its panel, tab or page bridge, a Settings
addon-invoke, a wiz:// link (for Aegis). All of those go through
AddonHost.dispatch(), which starts the add-on and waits for it, so no
call is dropped. Concurrent callers share one activation, and
activations run one at a time.

Startup stays the default: the host cannot tell what an older add-on
does in activate(). request-filter add-ons and add-ons that declare no
surface are forced to startup. If activate() returns a promise, calls
wait for it (at most 5 s). api.startAtLaunch(bool) lets an on-demand
add-on ask to be started at launch again (for live relay sessions).

Converted: notepad, screenshot, translate, docx-editor, pdf-editor, vpn
(none has launch-time work: no file association, no auto-connect, and
add-on file tabs are not part of the saved session). Shield and Cookie
Pop-ups stay startup: Shield owns the request filter and must see the
first request; Cookie Pop-ups costs ~6 ms and acts unasked on every
page. Aegis stays startup and untouched: another session owns it. See
NOTE-aegis-on-demand.md (next commit).

Settings › Performance › Startup:
- "Start extensions when first used" (default on). Off = all at launch;
  switching it off starts the waiting add-ons immediately.
- "Start the wallet at launch". Shown disabled with a hint until the
  installed Aegis manifest allows on-demand. It applies with no Settings
  change once Aegis opts in.
- "Preload common menus" gates prewarmOverlays().
- "Use lightest" preset.
New keys are plain SETTINGS_DEFAULTS through the existing settings-set.
No new IPC channels.

Measured: boot-trace, fresh profile, --seconds 20 so the 30 s add-on OTA
poll can't swap Aegis mid-series; warm runs 2-3 of two paired series.
- Add-on activation at launch: 121-154 ms -> 104-174 ms. The six
  converted add-ons went from 16-20 ms to 0. The rest is Shield (83-148
  ms, noisy) and Aegis (15 ms in this tree's 0.9.0).
- Toolbar painted: 1278-1584 ms -> 1282-1481 ms (within noise).
- With "Preload common menus" off: 0 overlays prewarmed, 8 processes
  instead of 11, about 50-70 MB less at 15 s.

The bundled add-on versions are not bumped. Existing profiles keep their
old addon.json, and so stay on startup activation, until those add-ons
ship with a higher version (seedBundledAddons only reseeds a strictly
newer bundle).
This commit is contained in:
Local Dev 2026-10-03 16:05:32 +02:00
parent e8317fef16
commit 4cc2d53582
9 changed files with 346 additions and 35 deletions

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:
@ -18,6 +22,10 @@
const fs = require("node:fs"); const fs = require("node:fs");
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,
@ -211,19 +219,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 });
});
}
// 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;
@ -336,8 +389,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) {
@ -350,6 +409,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}`);
@ -369,6 +436,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 };
});
}
// 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)) {
@ -396,7 +518,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
@ -416,7 +539,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}`);
@ -472,7 +605,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 }); const rec = { panelId, title, icon, pageFile: abs, addonId: manifest.id };
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
@ -610,6 +745,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.
@ -723,10 +865,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 || {});
@ -735,13 +893,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;
} }
@ -749,7 +930,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.
@ -768,7 +951,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(),
@ -780,25 +967,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 })),
}); });
} }
@ -821,20 +1008,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
@ -1443,7 +1449,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;
@ -2296,6 +2302,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
@ -2793,7 +2819,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
@ -3088,6 +3114,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); }
@ -5383,10 +5415,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;
} }
@ -6892,6 +6932,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

@ -613,6 +613,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>
@ -1041,7 +1058,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
@ -2277,7 +2335,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>'