diff --git a/addons-host.js b/addons-host.js index 465d28ff..2db024f0 100644 --- a/addons-host.js +++ b/addons-host.js @@ -1,660 +1,690 @@ -// 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. - // 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. ":///" 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`); - } - // 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, 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 }) { - 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; - // 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, - 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 : ""); - }, - // 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; - } - 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 }; +// 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. + // 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(). + // scan-page: api.scanActiveTabForUris({scheme, limit}) — the HOST + // searches the active tab for URIs of one scheme and + // returns only those. The add-on never receives page text, + // markup or form values, so this sits well below + // page-inject or capture-tab on the trust ladder: it can + // learn that a page is offering e.g. a wiz:// pairing + // code, and nothing else about the page. + "vault-derive", "page-inject", "approval-modal", "capture-tab", + "toolbar-menu", "open-tab", "scan-page", +]); + +// 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, + }; + } + // `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, 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, scanTabForUris, checkAndStageUpdates, restartApp }) { + 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; + this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : 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; + // 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, + 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 : ""); + }, + // 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); + }, + // scan-page: pull URIs of ONE scheme out of the active tab. + // + // Deliberately not a "read the page" API. The host does the matching + // and hands back only the URIs that matched, so an add-on with this + // capability still cannot see page text, form values or anything else + // it did not ask for. The scheme is fixed by the caller and validated + // here, and every call is expected to be user-initiated — nothing in + // the host polls a page on an add-on's behalf. + // + // opts.scheme e.g. "wiz" (required, [a-z][a-z0-9+.-]{0,19}) + // opts.limit max URIs to return (default 20, hard cap 50) + // Resolves to `{ origin, uris: [string] }`. + scanActiveTabForUris: async (opts) => { + if (!manifest.capabilities.includes("scan-page")) { + throw new Error(`add-on "${manifest.id}" must declare the "scan-page" capability in addon.json`); + } + if (!this._scanTabForUris) throw new Error(`scanActiveTabForUris unavailable (host not wired)`); + const scheme = String(opts?.scheme || "").toLowerCase(); + if (!/^[a-z][a-z0-9+.-]{0,19}$/.test(scheme)) throw new Error("invalid scheme"); + const limit = Math.min(Math.max(Number(opts?.limit) || 20, 1), 50); + return this._scanTabForUris({ scheme, limit }, 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; + } + 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 }; diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 7c8edf0f..59cfa0c6 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,14 +1,14 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.9", + "version": "0.9.0", "category": "plugin", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "author": "Silent Mode", "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", "main": "index.js", "updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json", - "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], + "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal", "scan-page"], "absorbs": ["bchwallet", "siawallet"], "page-inject": { "preload": "wallet-inject.js", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index c6ca833e..002e684c 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1585,6 +1585,20 @@ function registerPanelMessages(api) { return fullState(); }); + // Scan the open dapp tab for a wiz:// pairing code, for dapps that render + // one but haven't adopted window.wizardconnect. Strictly user-initiated — + // it runs when someone presses "Scan page", never on a timer and never in + // the background. The host does the matching and returns only the URIs, so + // Aegis never receives page content. + api.onMessage("wcScanPage", async (_p, m) => { + fromPanel(m); + if (typeof api.scanActiveTabForUris !== "function") { + throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually."); + } + const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 }); + return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] }; + }); + // ---- WizardConnect from the page (0.8.8) -------------------------------- // // WC was built for cross-device pairing: the dapp renders a QR, a phone diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 6df30791..cdb5f425 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1892,7 +1892,7 @@ function renderConnectPane(bchWallets) { `).join("") : `
No dapps paired yet.
`; return `
-
Dapps that support Aegis directly can hand the pairing over with one click — no copying. Otherwise paste a wiz:// URI from the dapp's Connect dialog. Aegis signs every request after your approval.
+
Dapps that support Aegis hand the pairing over with one click. Otherwise open the dapp's Connect dialog and press Scan page, or paste its wiz:// code below. Aegis signs every request after your approval.
${blockedNote}
Sign with
@@ -1901,8 +1901,9 @@ function renderConnectPane(bchWallets) {
-
+
+
Paired dapps
@@ -1948,6 +1949,41 @@ function wireConnectPane() { fillPicker(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); + const scanBtn = document.getElementById("pkConnectScanBtn"); + if (scanBtn) scanBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + const msg = document.getElementById("pkConnectMsg"); msg.hidden = true; + const field = document.getElementById("pkConnectUri"); + const prev = scanBtn.textContent; + scanBtn.textContent = "Scanning…"; scanBtn.disabled = true; + try { + const res = await S.invoke("wcScanPage"); + const uris = res?.uris || []; + if (!uris.length) { + msg.textContent = res?.origin + ? `No wiz:// pairing code found on ${res.origin}. Open the dapp's Connect dialog first, then scan again.` + : "No pairing code found on the open tab."; + msg.hidden = false; + return; + } + // Fill the field rather than pairing outright: the user still picks + // which wallet signs, and still presses Connect. A scan that silently + // paired would be a click with a much larger consequence than the + // button implies. + field.value = uris[0]; + msg.textContent = uris.length > 1 + ? `Found ${uris.length} codes on ${res.origin || "the page"} — filled the first. Press Connect to pair.` + : `Found a pairing code on ${res.origin || "the page"}. Press Connect to pair.`; + msg.classList.remove("err"); + msg.hidden = false; + } catch (err) { + msg.textContent = cleanErr(err); + msg.classList.add("err"); + msg.hidden = false; + } finally { + scanBtn.textContent = prev; scanBtn.disabled = false; + } + }); document.querySelectorAll("[data-wcpick]").forEach((b) => b.addEventListener("click", async () => { const [walletId, connId] = b.dataset.wcpick.split("|"); try { state = await S.invoke("wcDisconnect", { walletId, connId }); fillPicker(); } diff --git a/main.js b/main.js index 5c4c8a2c..439a51ed 100644 --- a/main.js +++ b/main.js @@ -1860,6 +1860,59 @@ function initAddons() { // and-stitch pass; kept for a later revision. // region — run the caller-supplied overlay source in the tab, wait // for a rect (or null = cancel), then capturePage(rect). + // Back scan-page. The extraction runs IN the page and returns only the + // matched URIs — the add-on never sees the DOM. We look at anchor hrefs, + // visible text, and the handful of attributes a dapp realistically + // stashes a pairing code in (data-uri, value, title), then dedupe. + // + // WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which + // is often the ONLY thing in the DOM when a dapp renders a QR, so the + // matcher accepts the percent-encoded spelling too and decodes it. + scanTabForUris: async ({ scheme, limit }, addonId) => { + const t = activeTab(); + if (!t) throw new Error("no active tab"); + if (t.addonId || t.settings) throw new Error("open the dapp's tab first, then scan"); + const wc = t.view.webContents; + const origin = pageOriginOf(wc.getURL()); + // The regex SOURCES are built here and shipped as JSON. Assembling + // them inside the injected string instead means hand-escaping + // backslashes and quotes through two levels of literal, which is both + // easy to get wrong and unreviewable. JSON.stringify does it exactly. + // `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so + // it cannot carry regex metacharacters. + const plainSrc = `\\b${scheme}://[^\\s"'<>]{4,2048}`; + // Percent-encoded QR spelling: WIZ://%3FP%3D… + const qrSrc = `\\b${scheme}://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~$+-])+`; + const arg = JSON.stringify({ plainSrc, qrSrc, limit }); + const found = await wc.executeJavaScript(`(() => { + const { plainSrc, qrSrc, limit } = ${arg}; + const out = new Set(); + const plain = new RegExp(plainSrc, "gi"); + const qr = new RegExp(qrSrc, "gi"); + const push = (s) => { + if (!s || out.size >= limit) return; + let v = String(s).trim(); + if (v.includes("%3F") || v.includes("%3f")) { try { v = decodeURIComponent(v); } catch {} } + if (v.length <= 2048) out.add(v); + }; + const scan = (s) => { + if (!s) return; + for (const m of String(s).matchAll(plain)) push(m[0]); + for (const m of String(s).matchAll(qr)) push(m[0]); + }; + for (const a of document.querySelectorAll("a[href]")) scan(a.getAttribute("href")); + for (const el of document.querySelectorAll("[data-uri],[data-wc-uri],[value],[title]")) { + scan(el.getAttribute("data-uri")); scan(el.getAttribute("data-wc-uri")); + scan(el.getAttribute("value")); scan(el.getAttribute("title")); + } + for (const el of document.querySelectorAll("input,textarea")) scan(el.value); + scan(document.body ? document.body.innerText : ""); + return [...out].slice(0, limit); + })()`, true); + const uris = Array.isArray(found) ? found.filter((s) => typeof s === "string") : []; + console.log(`[addons] ${addonId} scanned ${origin || "tab"} for ${scheme}:// — ${uris.length} match(es)`); + return { origin, uris }; + }, captureTab: async (opts, addonId) => { // Prefer the currently-active tab, BUT if that's an add-on-owned page // (e.g. the screenshot editor is already up when the user re-picks a @@ -2692,6 +2745,13 @@ function createTab(initial, opts = {}) { } return navigateTab(id, target + rest); } + // A wiz:// click never navigates — it hands the pairing URI to the + // wallet and leaves the dapp exactly where it is. + if (parsed.protocol === "wiz:") { + e.preventDefault(); + routeWizUri(u, pageOriginOf(wc.getURL()), tab.id); + return; + } if (parsed.protocol === "bns:") return; if (isBnsHost(parsed.hostname)) { // Only intercept cross-origin navigations. Same-origin (a form submit @@ -2733,6 +2793,12 @@ function createTab(initial, opts = {}) { }); // Links that open a new tab: target="_blank", window.open, Ctrl/middle-click. wc.setWindowOpenHandler(({ url, disposition }) => { + // target="_blank" on a wiz:// link lands here rather than will-navigate. + // Route it to the wallet instead of opening a tab on an unloadable URL. + if (url && /^wiz:/i.test(url)) { + routeWizUri(url, pageOriginOf(wc.getURL()), tab.id); + return { action: "deny" }; + } if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" }); return { action: "deny" }; }); @@ -3417,6 +3483,30 @@ function pageOriginOf(url) { return u.protocol && u.host ? `${u.protocol}//${u.host}` : null; } catch { return null; } } +// wiz:// — WizardConnect pairing links. +// +// WizardConnect is a cross-device protocol: a dapp renders its pairing URI +// as a QR for a phone wallet to scan. On the same device that means copying +// a wiz:// string out of one tab and pasting it into the wallet by hand. +// When a dapp renders the URI as a link instead, we can route the click +// straight to the wallet — no copying, and no change required on the dapp +// side beyond making it an anchor, so this works for third-party dapps that +// will never adopt a Silent Mode API. +// +// The wallet still shows its own approval before anything is paired; all +// this does is carry the URI across, tagged with the origin that offered it +// so the approval can name the real site. +function routeWizUri(uri, origin, tabId) { + if (!addonHost) return false; + const u = String(uri || ""); + if (!/^wiz:/i.test(u) || u.length > 4096) return false; + if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false; + addonHost + .dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId }) + .catch((err) => console.log("[wiz] pairing failed:", err?.message || err)); + return true; +} + ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { const tab = tabForSender(e.sender); if (!tab || !addonHost) throw new Error("not a page");