Users saw a blank window with a white strip across the top for seconds on launch. Root cause: every part of startup, including session restore, waited for chrome.html's did-finish-load. That event also waits for the page's subresources, and the bookmarks bar loads its favicons over bns:// — a BNS lookup plus a network fetch each — so a slow link held the whole boot. On top of that, seven hidden overlay renderers, every restored tab, the BNS index build and three network fetches all started in the same tick and stalled the main thread ~1 s while the toolbar tried to paint. - Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC listeners exist) instead of did-finish-load; 8 s fallback timer. - Window and chrome view get the toolbar's --bg for the active theme so the pre-paint frame is never white. - Overlay pages (site info, engine picker, downloads, suggestions, password fill, link status, approval) load 250 ms after the toolbar or on first use; the approval modal awaits its page so a dapp request can't hang. - Session restore is staggered: active tab first, then one background tab per 150 ms slotted into its saved strip position. Session file v2 records the active index; v1 arrays still load (active = last, as the old loop effectively did). - AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect) behind it. - BNS snapshot warm-up still starts right after createWindow (bookmark favicons need it); Sia refresh, update check and home-card fetch move to the post-paint phase. Measured on a clone of the real profile with nine restored tabs: toolbar usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load down from ~1.1 s to ~0.2 s.
616 lines
32 KiB
JavaScript
616 lines
32 KiB
JavaScript
// Theseus add-on framework — loader + API surface.
|
|
//
|
|
// Add-ons live in <userData>/addons/<id>/ as ordinary folders on disk. Each
|
|
// carries an `addon.json` manifest and (per the manifest's `main` field) a
|
|
// CommonJS entry that exports `activate(api)` and optionally `deactivate()`.
|
|
// Nothing about an add-on ships in the Theseus repo or installer — drop a
|
|
// folder, restart Theseus, it's live. This is the same trust model as
|
|
// dev-mode browser extensions: the user is choosing to run local code with
|
|
// the app's full privileges.
|
|
//
|
|
// Loading is synchronous at app-ready time; there is no hot-reload. Failed
|
|
// activations are logged and skipped without breaking the app.
|
|
//
|
|
// Persistence:
|
|
// settings.disabledAddons — ids the user has toggled off
|
|
// <userData>/addons-data/<id>.json — per-add-on kv store (api.storage)
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
// Extension points the framework understands. Extending this list means also
|
|
// teaching main.js and (typically) the chrome renderer about the new point.
|
|
// Right now only sidebar panels are wired — future rev adds toolbar-chip,
|
|
// proxy, page-inject, etc.
|
|
const KNOWN_CAPABILITIES = new Set([
|
|
"sidebar-panel", "session-proxy",
|
|
// vault-derive: api.vault.derive(purposePath) — HKDF child of the password
|
|
// vault's root, namespaced under the add-on id.
|
|
// page-inject: manifest["page-inject"] = { preload, origins } — the add-on's
|
|
// preload source runs in the isolated world of every tab whose
|
|
// URL matches one of the origin patterns.
|
|
// approval-modal: api.approvalModal({...}) — user-facing consent dialog over
|
|
// the active tab, resolved by main.
|
|
// capture-tab: api.captureTab({mode, ...}) + api.saveCapture({dataUrl, filename})
|
|
// — snapshot the active tab (visible viewport / full page /
|
|
// user-drawn rectangle) and save the result through the app's
|
|
// downloads pipeline. The add-on sees pixels of whatever the
|
|
// current tab is showing, so this is the same trust bar as a
|
|
// page-inject add-on that matches "*://*/*".
|
|
// toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] }
|
|
// — chrome renders a dropdown under the add-on's dock icon;
|
|
// picking an item dispatches "menu-select" with {id} to the
|
|
// add-on's onMessage("menu-select", …) handler.
|
|
// open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL
|
|
// this stays available without the capability (legacy).
|
|
// Declaring "open-tab" additionally lets the add-on open
|
|
// one of its OWN HTML files as a full Theseus tab, with a
|
|
// lean preload so the page can keep talking to the add-on
|
|
// via window.silentmode.invoke().
|
|
"vault-derive", "page-inject", "approval-modal", "capture-tab",
|
|
"toolbar-menu", "open-tab",
|
|
]);
|
|
|
|
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
|
|
// scheme may be "*", host may start with "*." (matches the bare host and any
|
|
// subdomain) or be "*", and path is a glob where "*" matches anything.
|
|
// bns:// (how Theseus fetches BCNR sites internally) is folded into https://
|
|
// so a pattern written the way the address bar shows it keeps working.
|
|
function compileOriginPattern(pattern) {
|
|
const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim());
|
|
if (!m) throw new Error(`bad origin pattern: ${pattern}`);
|
|
const [, scheme, host, pathGlob = "/*"] = m;
|
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase());
|
|
let hostRe;
|
|
if (host === "*") hostRe = "[^/]+";
|
|
else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`;
|
|
else hostRe = esc(host.toLowerCase());
|
|
const pathRe = pathGlob.split("*").map(esc).join(".*");
|
|
const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i");
|
|
return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://"));
|
|
}
|
|
function urlMatchesAny(url, matchers) {
|
|
for (const fn of matchers) { try { if (fn(url)) return true; } catch {} }
|
|
return false;
|
|
}
|
|
|
|
// Manifest field guardrails. Reject anything shape-suspicious so a bad
|
|
// addon.json can't get past the loader gate.
|
|
function validateManifest(raw, folderName) {
|
|
const m = raw && typeof raw === "object" ? raw : {};
|
|
const id = String(m.id || "").trim();
|
|
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) {
|
|
throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`);
|
|
}
|
|
const name = String(m.name || id);
|
|
const version = String(m.version || "0.0.0");
|
|
const description = String(m.description || "");
|
|
const author = String(m.author || "");
|
|
const icon = String(m.icon || "🧩");
|
|
const main = String(m.main || "index.js");
|
|
if (main.includes("..") || path.isAbsolute(main)) {
|
|
throw new Error(`addon "${id}": main must be a relative path inside the addon folder`);
|
|
}
|
|
const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : [];
|
|
for (const cap of capabilities) {
|
|
if (!KNOWN_CAPABILITIES.has(cap)) {
|
|
// Not fatal — log later. Unknown caps are silently dropped.
|
|
}
|
|
}
|
|
let pageInject = null;
|
|
if (capabilities.includes("page-inject")) {
|
|
const pi = m["page-inject"];
|
|
if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`);
|
|
const preload = String(pi.preload || "");
|
|
if (!preload || preload.includes("..") || path.isAbsolute(preload)) {
|
|
throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`);
|
|
}
|
|
const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : [];
|
|
if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`);
|
|
pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) };
|
|
}
|
|
let toolbarMenu = null;
|
|
if (capabilities.includes("toolbar-menu")) {
|
|
const tm = m["toolbar-menu"];
|
|
if (!tm || typeof tm !== "object") {
|
|
throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`);
|
|
}
|
|
const items = Array.isArray(tm.items) ? tm.items : [];
|
|
if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`);
|
|
const seen = new Set();
|
|
const cleanItems = items.map((it, idx) => {
|
|
const iid = String(it && it.id || "").trim();
|
|
if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) {
|
|
throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`);
|
|
}
|
|
if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`);
|
|
seen.add(iid);
|
|
const label = String(it.label || iid);
|
|
const itemIcon = it.icon == null ? "" : String(it.icon);
|
|
return { id: iid, label, icon: itemIcon };
|
|
});
|
|
toolbarMenu = {
|
|
title: tm.title == null ? name : String(tm.title),
|
|
icon: tm.icon == null ? icon : String(tm.icon),
|
|
items: cleanItems,
|
|
};
|
|
}
|
|
// `absorbs`: legacy add-on ids whose vault-derive namespace this add-on
|
|
// inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so
|
|
// funds derived under the old id's paths stay reachable through the new
|
|
// one. Each entry is validated as an id itself and gates vault.derive by
|
|
// (own id OR one of these) in makeApi below.
|
|
const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : [];
|
|
for (const a of absorbs) {
|
|
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) {
|
|
throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`);
|
|
}
|
|
if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`);
|
|
}
|
|
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, absorbs };
|
|
}
|
|
|
|
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
|
// of the app queries via `getActive()` / `getInstalled()`.
|
|
class AddonHost {
|
|
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) {
|
|
this.addonsDir = addonsDir;
|
|
this.dataDir = dataDir;
|
|
this.isDisabled = isDisabled || (() => false);
|
|
this.log = logger || ((...a) => console.log("[addons]", ...a));
|
|
this._installed = []; // [{ manifest, folder, error? }]
|
|
this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject }
|
|
// Capability hooks injected by main. Each is (args..., addonId) so main
|
|
// can log/gate per add-on. Missing hook = capability unavailable.
|
|
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
|
|
// vaultImports: main-process shim {list, add, remove, signer} that owns
|
|
// wallet-imports.enc. Same trust tier as vaultDerive — an add-on that
|
|
// holds vault-derive can also see imports (design §3.2 co-tenancy).
|
|
this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null;
|
|
// vaultLifecycle: main-process shim {status, setup, unlock, lock} so the
|
|
// wallet add-on can drive vault setup/unlock without redirecting users
|
|
// to Settings > Passwords. Same "vault-derive" capability gate.
|
|
this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function"
|
|
? arguments[0].vaultLifecycle : null;
|
|
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
|
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
|
// Add-ons live outside the app's node_modules tree, so a bare require()
|
|
// from their folder can't see Theseus's deps (ws, @noble/*, …). Main
|
|
// hands us its own require so add-ons can share the bundled tree.
|
|
this._hostRequire = typeof hostRequire === "function" ? hostRequire : null;
|
|
// ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's
|
|
// Node; hostImport resolves them from the app tree and import()s them.
|
|
this._hostImport = typeof hostImport === "function" ? hostImport : null;
|
|
this._openTab = typeof openTab === "function" ? openTab : null;
|
|
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
|
|
// Signature: (addonId, relPath, queryString) => Promise<void>.
|
|
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
|
|
// openSettings: opens Theseus's Settings tab, optionally scrolled to a
|
|
// named section (e.g. "passwords"). Uses the same IPC route the picker
|
|
// uses for "Search settings…". Signature: (section?: string) => void.
|
|
this._openSettings = typeof openSettings === "function" ? openSettings : null;
|
|
// Session-proxy hook — injected by main so add-ons can swap the default
|
|
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
|
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
|
// A `null` rule clears the proxy. Kept as a callback rather than requiring
|
|
// the loader itself import electron.
|
|
this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null;
|
|
// capture-tab hooks — main captures/saves; the loader only enforces the
|
|
// manifest gate.
|
|
this._captureTab = typeof captureTab === "function" ? captureTab : null;
|
|
this._saveCapture = typeof saveCapture === "function" ? saveCapture : null;
|
|
// api.whenUiReady() plumbing — see signalUiReady().
|
|
this._uiReady = false;
|
|
this._uiReadyWaiters = [];
|
|
}
|
|
|
|
// main calls this once the browser chrome has painted. Add-ons that pull
|
|
// in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth,
|
|
// WizardConnect) gate that work on api.whenUiReady() so module evaluation
|
|
// doesn't land on the main thread while chrome.html is still trying to
|
|
// paint. Sticky: a later discoverAndActivate() resolves immediately.
|
|
signalUiReady() {
|
|
this._uiReady = true;
|
|
for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} }
|
|
}
|
|
|
|
ensureDirs() {
|
|
for (const d of [this.addonsDir, this.dataDir]) {
|
|
try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); }
|
|
}
|
|
}
|
|
|
|
// api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same
|
|
// "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from
|
|
// its own gate instead of redirecting users to Settings > Passwords.
|
|
_makeLifecycleApi(manifest) {
|
|
const requireCap = () => {
|
|
if (!manifest.capabilities.includes("vault-derive")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
|
}
|
|
if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)");
|
|
};
|
|
return {
|
|
status: async () => { requireCap(); return this._vaultLifecycle.status(); },
|
|
unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); },
|
|
setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); },
|
|
lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); },
|
|
};
|
|
}
|
|
|
|
// api.vault.imports namespace factory. Gated by the "vault-derive" cap
|
|
// because the two surfaces sit at the same trust tier (design §3.2). If
|
|
// main didn't wire the vaultImports shim, calls throw a clear error.
|
|
_makeImportsApi(manifest) {
|
|
const requireCap = () => {
|
|
if (!manifest.capabilities.includes("vault-derive")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
|
}
|
|
if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)");
|
|
};
|
|
return {
|
|
list: async () => { requireCap(); return this._vaultImports.list(); },
|
|
add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); },
|
|
remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); },
|
|
signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); },
|
|
};
|
|
}
|
|
|
|
discoverAndActivate() {
|
|
this.ensureDirs();
|
|
this._deactivateAll();
|
|
this._installed = [];
|
|
let entries = [];
|
|
try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; }
|
|
for (const dirent of entries) {
|
|
if (!dirent.isDirectory()) continue;
|
|
const folder = path.join(this.addonsDir, dirent.name);
|
|
try {
|
|
const manifest = this._readManifest(folder, dirent.name);
|
|
this._installed.push({ manifest, folder });
|
|
if (this.isDisabled(manifest.id)) {
|
|
this.log(`skipping disabled add-on ${manifest.id}`);
|
|
continue;
|
|
}
|
|
this._activateOne(manifest, folder);
|
|
} catch (e) {
|
|
this.log(`failed to load ${dirent.name}: ${e?.message || e}`);
|
|
// A manifest that parsed but whose activate() threw was already
|
|
// pushed above — replace it rather than listing the add-on twice.
|
|
const i = this._installed.findIndex((x) => x.folder === folder);
|
|
const entry = { manifest: null, folder, error: String(e?.message || e) };
|
|
if (i >= 0) this._installed[i] = entry; else this._installed.push(entry);
|
|
}
|
|
}
|
|
return this.snapshot();
|
|
}
|
|
|
|
_readManifest(folder, folderName) {
|
|
const p = path.join(folder, "addon.json");
|
|
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
return validateManifest(raw, folderName);
|
|
}
|
|
|
|
_activateOne(manifest, folder) {
|
|
const mainPath = path.join(folder, manifest.main);
|
|
// require() from a folder outside asar is fine — Electron just uses Node's
|
|
// resolver. This is where the trust decision lives: we're loading arbitrary
|
|
// JS into the main process with full API access.
|
|
let mod;
|
|
try {
|
|
// Bust the require cache so a manual reload (future feature) picks up
|
|
// edits — cheap since add-ons are small.
|
|
delete require.cache[require.resolve(mainPath)];
|
|
mod = require(mainPath);
|
|
} catch (e) {
|
|
throw new Error(`require() failed: ${e?.message || e}`);
|
|
}
|
|
if (!mod || typeof mod.activate !== "function") {
|
|
throw new Error(`main file must export an activate(api) function`);
|
|
}
|
|
const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null };
|
|
if (manifest.pageInject) {
|
|
// Read the inject source once at activation. It's shipped to every
|
|
// matching tab's preload verbatim, so a syntax error surfaces in the
|
|
// tab's console, not here — but a missing file is fatal for the add-on.
|
|
const abs = path.join(folder, manifest.pageInject.preload);
|
|
let source;
|
|
try { source = fs.readFileSync(abs, "utf8"); }
|
|
catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); }
|
|
active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins };
|
|
}
|
|
const api = this._makeApi(active);
|
|
try { mod.activate(api); }
|
|
catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); }
|
|
this._active.set(manifest.id, active);
|
|
this.log(`activated ${manifest.id} v${manifest.version}`);
|
|
}
|
|
|
|
// Tear down every active add-on before a re-discover so long-lived state
|
|
// (sockets, timers) from a previous activation doesn't pile up.
|
|
_deactivateAll() {
|
|
for (const [id, active] of this._active) {
|
|
try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); }
|
|
catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); }
|
|
}
|
|
this._active.clear();
|
|
}
|
|
|
|
_makeApi(active) {
|
|
const { manifest, folder } = active;
|
|
const storageFile = path.join(this.dataDir, `${manifest.id}.json`);
|
|
return {
|
|
// Metadata the add-on may want to reflect on
|
|
id: manifest.id,
|
|
folder,
|
|
log: (...a) => this.log(`[${manifest.id}]`, ...a),
|
|
// Persistent per-add-on storage. Small kv JSON on disk.
|
|
storage: {
|
|
get: (key, fallback = null) => {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(storageFile, "utf8"));
|
|
return key in raw ? raw[key] : fallback;
|
|
} catch { return fallback; }
|
|
},
|
|
set: (key, value) => {
|
|
let store = {};
|
|
try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {}
|
|
store[key] = value;
|
|
try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); }
|
|
},
|
|
all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } },
|
|
},
|
|
// Register a sidebar panel — a right-side WebContentsView that hosts
|
|
// one of the add-on's HTML pages. `page` is a path RELATIVE to the
|
|
// add-on folder. `title` shows in the sidebar tab strip. `icon` is
|
|
// a short emoji/glyph.
|
|
registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => {
|
|
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
|
|
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));
|
|
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
|
|
// Namespaced id so two add-ons can't collide.
|
|
const panelId = `${manifest.id}:${id}`;
|
|
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
|
|
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
|
|
},
|
|
// Swap the default session's proxy. Rules follow Chromium's proxy
|
|
// format ("socks5://1.2.3.4:1080" for a single SOCKS server,
|
|
// "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.).
|
|
// Pass null to clear. Add-ons that opt into this capability are
|
|
// fully replacing the browser's outgoing network path — they're
|
|
// the trust boundary while active. Same-signature as the built-in
|
|
// Tor toggle uses under the hood.
|
|
setSessionProxy: async (rules) => {
|
|
if (!this._setSessionProxy) {
|
|
this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`);
|
|
return;
|
|
}
|
|
if (!manifest.capabilities.includes("session-proxy")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`);
|
|
}
|
|
await this._setSessionProxy(rules, manifest.id);
|
|
},
|
|
// Resolve a module from Theseus's own dependency tree. Add-ons run with
|
|
// the app's full privileges anyway; this only saves them from shipping
|
|
// a second copy of ws / @noble / etc.
|
|
require: (name) => {
|
|
if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`);
|
|
return this._hostRequire(name);
|
|
},
|
|
// Same, for ES-module-only packages: resolves to a Promise of the
|
|
// module namespace.
|
|
import: async (name) => {
|
|
if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`);
|
|
return this._hostImport(name);
|
|
},
|
|
// Open a new Theseus tab. Two shapes:
|
|
// - api.openTab("https://…") — no capability needed
|
|
// - api.openTab("editor.html", { query: {...} }) — opens one of the
|
|
// add-on's OWN files as a full tab; requires the "open-tab" cap.
|
|
// Path is resolved inside the add-on folder and rejected if it
|
|
// escapes it (path traversal). Query is URL-encoded. The page
|
|
// loads under addon-tab-preload.js so window.silentmode.invoke()
|
|
// reaches the same handlers as a sidebar panel — main gates by
|
|
// sender URL so a page hosted anywhere else gets nothing back.
|
|
openTab: (pathOrUrl, opts) => {
|
|
const s = String(pathOrUrl || "");
|
|
// Bare http(s) URL with no opts — legacy behaviour, unchanged.
|
|
if (/^https?:\/\//i.test(s) && !opts) {
|
|
if (!this._openTab) throw new Error("openTab unavailable (host not wired)");
|
|
this._openTab(s, manifest.id);
|
|
return;
|
|
}
|
|
if (!manifest.capabilities.includes("open-tab")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`);
|
|
}
|
|
if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)");
|
|
if (!s || path.isAbsolute(s) || s.includes("..")) {
|
|
throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`);
|
|
}
|
|
let qs = "";
|
|
if (opts && opts.query && typeof opts.query === "object") {
|
|
const usp = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v));
|
|
qs = usp.toString();
|
|
}
|
|
return this._openAddonTab(manifest.id, s, qs);
|
|
},
|
|
// Open Theseus's Settings tab, optionally scrolled to a named section
|
|
// (validated against a known list in main). No capability needed —
|
|
// it's the same thing the user could do from the ⋮ menu, just a
|
|
// one-click shortcut so add-ons can point users at the right place
|
|
// (e.g. Aegis's "Set up vault" gate → Passwords).
|
|
openSettings: (section) => {
|
|
if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)");
|
|
this._openSettings(typeof section === "string" ? section : "");
|
|
},
|
|
// Resolves once the browser chrome has painted (immediately if it
|
|
// already has). Put expensive dependency loading behind this so it
|
|
// never competes with the first frame at launch.
|
|
whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))),
|
|
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
|
|
// injected page bridges) call into the add-on with a message name +
|
|
// one JSON payload; the handler's return value goes back as the
|
|
// response. `ctx.from` is "panel" or "page"; pages also carry
|
|
// `ctx.origin` ("https://host") so the add-on can scope permissions.
|
|
onMessage: (msg, handler) => {
|
|
if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`);
|
|
active.handlers.set(msg, handler);
|
|
},
|
|
// Push an event to the add-on's own sidebar panel if it's currently
|
|
// loaded. Fire-and-forget; silently dropped when the panel is closed.
|
|
emit: (msg, payload) => {
|
|
if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload);
|
|
},
|
|
// vault-derive: a 32-byte HKDF child of the password vault's root,
|
|
// keyed by a path that MUST start with this add-on's id — or one of
|
|
// the ids it declared under `absorbs` in addon.json, so a superseding
|
|
// add-on can keep deriving the same keys as the add-on it replaced
|
|
// (funds stay reachable across the transition). Resolves only once
|
|
// the user has unlocked the vault (main polls; the await can be long).
|
|
vault: {
|
|
derive: async (purposePath) => {
|
|
if (!manifest.capabilities.includes("vault-derive")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
|
}
|
|
if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`);
|
|
const p = String(purposePath || "");
|
|
if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) {
|
|
throw new Error(`vault.derive: purposePath must look like "${manifest.id}/<name>"`);
|
|
}
|
|
const allowed = [manifest.id, ...(manifest.absorbs || [])];
|
|
if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) {
|
|
const list = allowed.length > 1
|
|
? `one of "${allowed.join('", "')}"`
|
|
: `"${manifest.id}"`;
|
|
throw new Error(`vault.derive: purposePath must start with ${list} + "/"`);
|
|
}
|
|
return this._vaultDerive(p, manifest.id);
|
|
},
|
|
imports: this._makeImportsApi(manifest),
|
|
lifecycle: this._makeLifecycleApi(manifest),
|
|
},
|
|
// approval-modal: ask the user. Resolves to the chosen action id, or
|
|
// "cancel" (Escape / mask click / window closed). With `checkbox` set
|
|
// and ticked, the id comes back suffixed "+<checkbox.id>"; with
|
|
// `select` {id, label, options:[{value,label}]} and a non-empty value
|
|
// chosen, "+<select.id>=<value>".
|
|
approvalModal: async (opts) => {
|
|
if (!manifest.capabilities.includes("approval-modal")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`);
|
|
}
|
|
if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`);
|
|
return this._approvalModal(opts || {}, manifest.id);
|
|
},
|
|
// capture-tab: snapshot the currently-active tab.
|
|
// opts.mode "visible" | "full" | "region" (required)
|
|
// opts.format "png" | "jpeg" (default "png")
|
|
// opts.quality 1-100 (jpeg only, default 90)
|
|
// opts.overlaySource string (region only — DOM
|
|
// code the add-on wants injected while the user
|
|
// drags a selection. Must resolve to `{x,y,w,h}`
|
|
// in CSS pixels; return null/undefined to cancel.)
|
|
// Resolves to `{ dataUrl, width, height, host, format }`.
|
|
captureTab: async (opts) => {
|
|
if (!manifest.capabilities.includes("capture-tab")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`);
|
|
}
|
|
if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`);
|
|
return this._captureTab(opts || {}, manifest.id);
|
|
},
|
|
// capture-tab: route an in-memory image into the app's downloads pipeline
|
|
// so it lands in the user's Downloads folder AND shows up in the
|
|
// download-chip list the same way any HTTP download would.
|
|
// opts.dataUrl "data:image/png;base64,…" (required)
|
|
// opts.filename filename shown in the chip (required)
|
|
// Resolves to `{ savePath }`.
|
|
saveCapture: async (opts) => {
|
|
if (!manifest.capabilities.includes("capture-tab")) {
|
|
throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`);
|
|
}
|
|
if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`);
|
|
return this._saveCapture(opts || {}, manifest.id);
|
|
},
|
|
};
|
|
}
|
|
|
|
// Route a message to an add-on's registered handler. Callers (main) have
|
|
// already established WHO is asking; `ctx` carries that provenance.
|
|
async dispatch(id, msg, payload, ctx) {
|
|
const active = this._active.get(id);
|
|
if (!active) throw new Error(`add-on "${id}" is not active`);
|
|
const handler = active.handlers.get(String(msg));
|
|
if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`);
|
|
return handler(payload, ctx || {});
|
|
}
|
|
hasHandler(id, msg) {
|
|
const active = this._active.get(id);
|
|
return !!(active && active.handlers.has(String(msg)));
|
|
}
|
|
// Inject scripts that apply to a tab URL — [{ id, source }].
|
|
injectionsFor(url) {
|
|
const out = [];
|
|
for (const active of this._active.values()) {
|
|
if (active.inject && urlMatchesAny(url, active.inject.matchers)) {
|
|
out.push({ id: active.manifest.id, source: active.inject.source });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
// Does this add-on's page-inject declaration cover the URL? Used to gate
|
|
// page → add-on IPC so a non-matching page can't spoof a matching one.
|
|
pageAllowed(id, url) {
|
|
const active = this._active.get(id);
|
|
return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers));
|
|
}
|
|
|
|
// Read-only views for the rest of the app.
|
|
snapshot() {
|
|
return {
|
|
installed: this._installed.map(({ manifest, folder, error }) => ({
|
|
id: manifest?.id ?? null,
|
|
name: manifest?.name ?? null,
|
|
version: manifest?.version ?? null,
|
|
description: manifest?.description ?? "",
|
|
author: manifest?.author ?? "",
|
|
icon: manifest?.icon ?? "🧩",
|
|
capabilities: manifest?.capabilities ?? [],
|
|
folder,
|
|
enabled: manifest?.id ? this._active.has(manifest.id) : false,
|
|
error: error || null,
|
|
})),
|
|
sidebarPanels: this.getSidebarPanels(),
|
|
toolbarMenus: this.getToolbarMenus(),
|
|
};
|
|
}
|
|
getSidebarPanels() {
|
|
const out = [];
|
|
for (const active of this._active.values()) out.push(...active.sidebarPanels);
|
|
return out;
|
|
}
|
|
// Menu declarations from every active add-on that carries a toolbar-menu
|
|
// manifest block. Chrome renders one dock button per entry, opens the
|
|
// dropdown, then dispatches "menu-select" with the picked item id.
|
|
getToolbarMenus() {
|
|
const out = [];
|
|
for (const active of this._active.values()) {
|
|
const tm = active.manifest.toolbarMenu;
|
|
if (!tm) continue;
|
|
out.push({
|
|
addonId: active.manifest.id,
|
|
title: tm.title,
|
|
icon: tm.icon,
|
|
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
getInstalled() { return this._installed.slice(); }
|
|
isActive(id) { return this._active.has(id); }
|
|
// Absolute folder of an active add-on, or null. Public so main can resolve
|
|
// add-on-relative paths (openAddonTab) without reaching into internals.
|
|
folderOf(id) { const a = this._active.get(id); return a ? a.folder : null; }
|
|
}
|
|
|
|
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };
|