// Theseus add-on framework — loader + API surface. // // Add-ons live in /addons// 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 // /addons-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. "vault-derive", "page-inject", "approval-modal", ]); // 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) }; } return { id, name, version, description, author, icon, main, capabilities, pageInject }; } // 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, approvalModal, emitToPanel, hostRequire }) { 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; 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; // 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; } ensureDirs() { for (const d of [this.addonsDir, this.dataDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); } } } 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}`); this._installed.push({ manifest: null, folder, error: String(e?.message || e) }); } } 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); }, // 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 so one add-on // can never ask for another's material. 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 (!p.startsWith(manifest.id + "/") || /[^a-z0-9/._-]/i.test(p) || p.includes("..")) { throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); } return this._vaultDerive(p, manifest.id); }, }, // 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 "+". 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); }, }; } // 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(), }; } getSidebarPanels() { const out = []; for (const active of this._active.values()) out.push(...active.sidebarPanels); return out; } getInstalled() { return this._installed.slice(); } isActive(id) { return this._active.has(id); } } module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };