// Theseus add-on framework — loader + API surface. // // Add-ons live in /extensions// 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 // /extensions-data/.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. ":///" 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. 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 // 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 (/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, }; }, // Cleanly relaunch Theseus. Used by the plug-in card's "apply // update" chip to activate a staged build without asking the user // to hunt for the app menu. restartApp: () => { if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); this._restartApp(); }, // 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}/"`); } 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 "+"; with // `select` {id, label, options:[{value,label}]} and a non-empty value // chosen, "+=". 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(), }; } 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; } // 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 };