theseus/addons-host.js
Local Dev 870986de21 fix(theseus): an add-on cannot restart the browser on its own
Aegis relaunches Theseus after staging its own update; seen twice in a
dev instance, the whole browser restarted with no warning, mid-session.
The add-on API's restartApp now asks the user in a native dialog
("Aegis Wallet wants to restart Theseus" — Restart now / Later, Later
is the default) and resolves { restarted, deferred }. Declining loses
nothing: a staged update applies on the next normal launch. The guard
lives in the host, so it covers every Aegis version on the channel and
any future add-on.
2026-09-22 21:59:44 +02:00

752 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Theseus add-on framework — loader + API surface.
//
// Add-ons live in <userData>/extensions/<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>/extensions-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",
// context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}]
// — chrome merges these into every tab's right-click
// menu, filtered by `when` (selectionText | linkURL |
// editable | image | always). Picking one dispatches
// "context-menu" with the item id + the surrounding
// context (selection text, link URL, media info, host)
// to the add-on's onMessage handler. Add-ons that also
// declare "sidebar-panel" typically follow up with
// api.revealSidebar(panelId) to surface the result.
"context-menu-item",
]);
// 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,
};
}
const contextMenuItems = [];
if (capabilities.includes("context-menu-item")) {
const rawItems = m["context-menu-items"];
if (!Array.isArray(rawItems) || !rawItems.length) {
throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`);
}
const seen = new Set();
const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]);
for (let idx = 0; idx < rawItems.length; idx++) {
const it = rawItems[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}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`);
}
if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`);
seen.add(iid);
const when = String(it.when || "always");
if (!ALLOWED_WHEN.has(when)) {
throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`);
}
contextMenuItems.push({
id: iid,
label: String(it.label || iid),
when,
icon: it.icon == null ? "" : String(it.icon),
});
}
}
// `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`);
}
// Category: "plugin" for first-class Silent Mode components (Aegis and
// future Ariadne-as-addon) that are surfaced in Settings Plug-ins with
// their own copy instead of the raw Extensions list. Anything else falls
// back to plain-extension rendering.
const category = m.category && ["plugin"].includes(String(m.category))
? String(m.category) : null;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category };
}
// 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, checkAndStageUpdates, restartApp, revealSidebar }) {
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;
// Panel-driven self-update: an add-on may ask the host to run the
// OTA check + verify + stage flow for itself and, if a newer signed
// build lands, restart Theseus so promoteStagedUpdates picks it up.
// Owns the entire trust chain (sig, hash, manifest match) so no
// add-on ever gets to hand-write into its own installed folder.
this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null;
this._restartApp = typeof restartApp === "function" ? restartApp : null;
// revealSidebar: opens the sidebar and switches to the given panel id.
// Signature: (addonId, panelId) => void. Add-ons use this from their
// context-menu handler to surface a result in their sidebar UI.
this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : 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,
// Per-extension data folder (<userData>/extensions-data): where the kv
// store lives and where an add-on should keep scratch files rather
// than guessing the path from its own folder.
dataDir: this.dataDir,
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}`);
},
// Programmatically open the sidebar and switch to one of THIS add-on's
// panels. `panelId` is the un-namespaced id passed to registerSidebarPanel
// (e.g. "main"); host prefixes with the add-on id under the hood. Used
// by context-menu handlers to surface a result in the sidebar. No-op if
// the add-on doesn't own that panel or the host isn't wired.
revealSidebar: (panelId) => {
if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; }
const pid = String(panelId || "").trim();
const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`;
const owned = active.sidebarPanels.some((p) => p.panelId === full);
if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; }
this._revealSidebar(manifest.id, full);
},
// 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 : "");
},
// Check the OTA channel for a newer signed build of THIS add-on and
// stage it if one is found. Returns { status, staged, current, next }
// — status matches the shared addon-updater report vocabulary
// ("up-to-date" | "staged" | "already-staged" | "fetch-failed" | …).
// The staged copy activates on the next Theseus launch, so pair with
// restartApp() when the caller wants an immediate apply. Scoped to
// the calling add-on so a plug-in can't stage updates for its
// neighbours.
checkAndStageSelfUpdate: async () => {
if (!this._checkAndStageUpdates) throw new Error("checkAndStageSelfUpdate unavailable (host not wired)");
const full = await this._checkAndStageUpdates();
const own = (full?.report || []).find((r) => r.id === manifest.id) || { status: "no-update-url" };
return {
status: own.status || "unknown",
detail: own.detail || null,
current: own.currentVer || manifest.version,
next: own.newVer || null,
staged: (full?.staged || []).find((s) => s.id === manifest.id) || null,
};
},
// Ask Theseus to relaunch (the plug-in card's "apply update" chip).
// The host asks the user first and resolves { restarted, deferred }
// — an add-on cannot restart the browser on its own.
restartApp: () => {
if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)");
return Promise.resolve(this._restartApp(manifest.name || manifest.id));
},
// 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 ?? [],
// "plugin" — first-class Silent Mode component (Aegis, future
// Ariadne-as-addon) surfaced in Settings Plug-ins instead of
// the raw Extensions list. Absent → plain extension.
category: manifest?.category || null,
folder,
enabled: manifest?.id ? this._active.has(manifest.id) : false,
error: error || null,
})),
sidebarPanels: this.getSidebarPanels(),
toolbarMenus: this.getToolbarMenus(),
};
}
// `plugin` marks surfaces of a first-class Silent Mode component (manifest
// category "plugin", e.g. Aegis): the chrome pins those to their own dock
// instead of the extensions row.
getSidebarPanels() {
const out = [];
for (const active of this._active.values()) {
const plugin = active.manifest.category === "plugin";
for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name });
}
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,
plugin: active.manifest.category === "plugin",
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
});
}
return out;
}
// Right-click menu items declared by add-ons, filtered by the current
// context. `ctx` is what Electron's context-menu event carries:
// { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL }
// Returns [{ addonId, id, label, icon, when }]. `when` filters:
// selectionText — non-empty text is selected
// linkURL — right-clicked on a link
// editable — right-clicked inside a form control / contenteditable
// image — mediaType === "image" and srcURL is present
// always — every menu
getContextMenuItems(ctx = {}) {
const has = {
selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()),
linkURL: !!ctx.linkURL,
editable: !!ctx.isEditable,
image: ctx.mediaType === "image" && !!ctx.srcURL,
};
const out = [];
for (const active of this._active.values()) {
const items = active.manifest.contextMenuItems || [];
for (const it of items) {
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 });
}
}
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 };