theseus/addons-host.js

820 lines
45 KiB
JavaScript
Raw Normal View History

feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// Theseus add-on framework — loader + API surface.
//
// Add-ons live in <userData>/extensions/<id>/ as ordinary folders on disk. Each
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// carries an `addon.json` manifest and (per the manifest's `main` field) a
// CommonJS entry that exports `activate(api)` and optionally `deactivate()`.
// Nothing about an add-on ships in the Theseus repo or installer — drop a
// folder, restart Theseus, it's live. This is the same trust model as
// dev-mode browser extensions: the user is choosing to run local code with
// the app's full privileges.
//
// Loading is synchronous at app-ready time; there is no hot-reload. Failed
// activations are logged and skipped without breaking the app.
//
// Persistence:
// settings.disabledAddons — ids the user has toggled off
// <userData>/extensions-data/<id>.json — per-add-on kv store (api.storage)
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
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",
// request-filter: api.registerRequestFilter(fn) — fn sees every subresource
// request of the session ({url, resourceType, method,
// frameUrl, initiator, webContentsId}) and returns true to
// block it. Main never lets a filter cancel a top-level
// navigation. Also unlocks api.tabs (active tab's url/host
// and a change event) so a blocker can show per-site
// numbers. Sees every URL the browser loads: same trust
// bar as page-inject on "*://*/*".
"request-filter",
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// 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",
// context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}]
// — chrome merges these into every tab's right-click
// menu, filtered by `when` (selectionText | linkURL |
// editable | image | always). Picking one dispatches
// "context-menu" with the item id + the surrounding
// context (selection text, link URL, media info, host)
// to the add-on's onMessage handler. Add-ons that also
// declare "sidebar-panel" typically follow up with
// api.revealSidebar(panelId) to surface the result.
"context-menu-item",
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
]);
// Chrome-style match pattern → predicate. "<scheme>://<host>/<path>" where
// scheme may be "*", host may start with "*." (matches the bare host and any
// subdomain) or be "*", and path is a glob where "*" matches anything.
// bns:// (how Theseus fetches BCNR sites internally) is folded into https://
// so a pattern written the way the address bar shows it keeps working.
function compileOriginPattern(pattern) {
const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim());
if (!m) throw new Error(`bad origin pattern: ${pattern}`);
const [, scheme, host, pathGlob = "/*"] = m;
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase());
let hostRe;
if (host === "*") hostRe = "[^/]+";
else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`;
else hostRe = esc(host.toLowerCase());
const pathRe = pathGlob.split("*").map(esc).join(".*");
const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i");
return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://"));
}
function urlMatchesAny(url, matchers) {
for (const fn of matchers) { try { if (fn(url)) return true; } catch {} }
return false;
}
// Manifest field guardrails. Reject anything shape-suspicious so a bad
// addon.json can't get past the loader gate.
function validateManifest(raw, folderName) {
const m = raw && typeof raw === "object" ? raw : {};
const id = String(m.id || "").trim();
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) {
throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`);
}
const name = String(m.name || id);
const version = String(m.version || "0.0.0");
const description = String(m.description || "");
const author = String(m.author || "");
const icon = String(m.icon || "🧩");
const main = String(m.main || "index.js");
if (main.includes("..") || path.isAbsolute(main)) {
throw new Error(`addon "${id}": main must be a relative path inside the addon folder`);
}
const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : [];
for (const cap of capabilities) {
if (!KNOWN_CAPABILITIES.has(cap)) {
// Not fatal — log later. Unknown caps are silently dropped.
}
}
let pageInject = null;
if (capabilities.includes("page-inject")) {
const pi = m["page-inject"];
if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`);
const preload = String(pi.preload || "");
if (!preload || preload.includes("..") || path.isAbsolute(preload)) {
throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`);
}
const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : [];
if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`);
pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) };
}
let toolbarMenu = null;
if (capabilities.includes("toolbar-menu")) {
const tm = m["toolbar-menu"];
if (!tm || typeof tm !== "object") {
throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`);
}
const items = Array.isArray(tm.items) ? tm.items : [];
if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`);
const seen = new Set();
const cleanItems = items.map((it, idx) => {
const iid = String(it && it.id || "").trim();
if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) {
throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`);
}
if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`);
seen.add(iid);
const label = String(it.label || iid);
const itemIcon = it.icon == null ? "" : String(it.icon);
return { id: iid, label, icon: itemIcon };
});
toolbarMenu = {
title: tm.title == null ? name : String(tm.title),
icon: tm.icon == null ? icon : String(tm.icon),
items: cleanItems,
};
}
const contextMenuItems = [];
if (capabilities.includes("context-menu-item")) {
const rawItems = m["context-menu-items"];
if (!Array.isArray(rawItems) || !rawItems.length) {
throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`);
}
const seen = new Set();
const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]);
for (let idx = 0; idx < rawItems.length; idx++) {
const it = rawItems[idx];
const iid = String(it && it.id || "").trim();
if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) {
throw new Error(`addon "${id}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`);
}
if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`);
seen.add(iid);
const when = String(it.when || "always");
if (!ALLOWED_WHEN.has(when)) {
throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`);
}
contextMenuItems.push({
id: iid,
label: String(it.label || iid),
when,
icon: it.icon == null ? "" : String(it.icon),
});
}
}
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// `absorbs`: legacy add-on ids whose vault-derive namespace this add-on
// inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so
// funds derived under the old id's paths stay reachable through the new
// one. Each entry is validated as an id itself and gates vault.derive by
// (own id OR one of these) in makeApi below.
const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : [];
for (const a of absorbs) {
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) {
throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`);
}
if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`);
}
// Category: "plugin" for first-class Silent Mode components (Aegis and
// future Ariadne-as-addon) that are surfaced in Settings › Plug-ins with
// their own copy instead of the raw Extensions list. Anything else falls
// back to plain-extension rendering.
const category = m.category && ["plugin"].includes(String(m.category))
? String(m.category) : null;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category };
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
}
// 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, revealSidebar, requestFilter, tabs }) {
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
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;
this._requestFilter = requestFilter && typeof requestFilter.set === "function" ? requestFilter : null;
this._tabs = tabs && typeof tabs.active === "function" ? tabs : null;
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's
// Node; hostImport resolves them from the app tree and import()s them.
this._hostImport = typeof hostImport === "function" ? hostImport : null;
this._openTab = typeof openTab === "function" ? openTab : null;
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
// Signature: (addonId, relPath, queryString) => Promise<void>.
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
// openSettings: opens Theseus's Settings tab, optionally scrolled to a
// named section (e.g. "passwords"). Uses the same IPC route the picker
// uses for "Search settings…". Signature: (section?: string) => void.
this._openSettings = typeof openSettings === "function" ? openSettings : null;
// Panel-driven self-update: an add-on may ask the host to run the
// OTA check + verify + stage flow for itself and, if a newer signed
// build lands, restart Theseus so promoteStagedUpdates picks it up.
// Owns the entire trust chain (sig, hash, manifest match) so no
// add-on ever gets to hand-write into its own installed folder.
this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null;
this._restartApp = typeof restartApp === "function" ? restartApp : null;
// revealSidebar: opens the sidebar and switches to the given panel id.
// Signature: (addonId, panelId) => void. Add-ons use this from their
// context-menu handler to surface a result in their sidebar UI.
this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : null;
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// Session-proxy hook — injected by main so add-ons can swap the default
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
// A `null` rule clears the proxy. Kept as a callback rather than requiring
// the loader itself import electron.
this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null;
// capture-tab hooks — main captures/saves; the loader only enforces the
// manifest gate.
this._captureTab = typeof captureTab === "function" ? captureTab : null;
this._saveCapture = typeof saveCapture === "function" ? saveCapture : null;
// api.whenUiReady() plumbing — see signalUiReady().
this._uiReady = false;
this._uiReadyWaiters = [];
}
// main calls this once the browser chrome has painted. Add-ons that pull
// in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth,
// WizardConnect) gate that work on api.whenUiReady() so module evaluation
// doesn't land on the main thread while chrome.html is still trying to
// paint. Sticky: a later discoverAndActivate() resolves immediately.
signalUiReady() {
this._uiReady = true;
for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} }
}
ensureDirs() {
for (const d of [this.addonsDir, this.dataDir]) {
try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); }
}
}
// api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same
// "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from
// its own gate instead of redirecting users to Settings > Passwords.
_makeLifecycleApi(manifest) {
const requireCap = () => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)");
};
return {
status: async () => { requireCap(); return this._vaultLifecycle.status(); },
unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); },
setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); },
lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); },
};
}
// api.vault.imports namespace factory. Gated by the "vault-derive" cap
// because the two surfaces sit at the same trust tier (design §3.2). If
// main didn't wire the vaultImports shim, calls throw a clear error.
_makeImportsApi(manifest) {
const requireCap = () => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)");
};
return {
list: async () => { requireCap(); return this._vaultImports.list(); },
add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); },
remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); },
signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); },
};
}
discoverAndActivate() {
this.ensureDirs();
this._deactivateAll();
this._installed = [];
let entries = [];
try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; }
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
const folder = path.join(this.addonsDir, dirent.name);
try {
const manifest = this._readManifest(folder, dirent.name);
this._installed.push({ manifest, folder });
if (this.isDisabled(manifest.id)) {
this.log(`skipping disabled add-on ${manifest.id}`);
continue;
}
this._activateOne(manifest, folder);
} catch (e) {
this.log(`failed to load ${dirent.name}: ${e?.message || e}`);
// A manifest that parsed but whose activate() threw was already
// pushed above — replace it rather than listing the add-on twice.
const i = this._installed.findIndex((x) => x.folder === folder);
const entry = { manifest: null, folder, error: String(e?.message || e) };
if (i >= 0) this._installed[i] = entry; else this._installed.push(entry);
}
}
return this.snapshot();
}
_readManifest(folder, folderName) {
const p = path.join(folder, "addon.json");
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
return validateManifest(raw, folderName);
}
_activateOne(manifest, folder) {
const mainPath = path.join(folder, manifest.main);
// require() from a folder outside asar is fine — Electron just uses Node's
// resolver. This is where the trust decision lives: we're loading arbitrary
// JS into the main process with full API access.
let mod;
try {
// Bust the require cache so a manual reload (future feature) picks up
// edits — cheap since add-ons are small.
delete require.cache[require.resolve(mainPath)];
mod = require(mainPath);
} catch (e) {
throw new Error(`require() failed: ${e?.message || e}`);
}
if (!mod || typeof mod.activate !== "function") {
throw new Error(`main file must export an activate(api) function`);
}
const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null, tabListeners: [] };
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
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 (this._requestFilter) this._requestFilter.clear(active.manifest.id); } catch {}
for (const off of active.tabListeners || []) { try { off(); } catch {} }
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); }
catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); }
}
this._active.clear();
}
_makeApi(active) {
const { manifest, folder } = active;
const storageFile = path.join(this.dataDir, `${manifest.id}.json`);
return {
// Metadata the add-on may want to reflect on
id: manifest.id,
folder,
// Per-extension data folder (<userData>/extensions-data): where the kv
// store lives and where an add-on should keep scratch files rather
// than guessing the path from its own folder.
dataDir: this.dataDir,
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
log: (...a) => this.log(`[${manifest.id}]`, ...a),
// Persistent per-add-on storage. Small kv JSON on disk.
storage: {
get: (key, fallback = null) => {
try {
const raw = JSON.parse(fs.readFileSync(storageFile, "utf8"));
return key in raw ? raw[key] : fallback;
} catch { return fallback; }
},
set: (key, value) => {
let store = {};
try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {}
store[key] = value;
try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); }
},
all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } },
},
// Register a sidebar panel — a right-side WebContentsView that hosts
// one of the add-on's HTML pages. `page` is a path RELATIVE to the
// add-on folder. `title` shows in the sidebar tab strip. `icon` is
// a short emoji/glyph.
registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => {
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
// Namespaced id so two add-ons can't collide.
const panelId = `${manifest.id}:${id}`;
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
},
// Programmatically open the sidebar and switch to one of THIS add-on's
// panels. `panelId` is the un-namespaced id passed to registerSidebarPanel
// (e.g. "main"); host prefixes with the add-on id under the hood. Used
// by context-menu handlers to surface a result in the sidebar. No-op if
// the add-on doesn't own that panel or the host isn't wired.
revealSidebar: (panelId) => {
if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; }
const pid = String(panelId || "").trim();
const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`;
const owned = active.sidebarPanels.some((p) => p.panelId === full);
if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; }
this._revealSidebar(manifest.id, full);
},
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
// 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.
// Request filter: one function per add-on, consulted by main for every
// subresource request. Returns an unregister function.
registerRequestFilter: (fn) => {
if (typeof fn !== "function") throw new Error(`registerRequestFilter needs a function`);
if (!manifest.capabilities.includes("request-filter")) throw new Error(`add-on "${manifest.id}" must declare the "request-filter" capability in addon.json`);
if (!this._requestFilter) { this.log(`[${manifest.id}] request filter unavailable (host not wired)`); return () => {}; }
this._requestFilter.set(manifest.id, fn);
return () => { try { this._requestFilter.clear(manifest.id); } catch {} };
},
// The active tab (id, url, host) and a change event — enough for a
// per-site view, nothing about page content.
tabs: {
active: () => {
if (!manifest.capabilities.includes("request-filter")) throw new Error(`add-on "${manifest.id}" must declare the "request-filter" capability to read tabs`);
return this._tabs ? this._tabs.active() : null;
},
onChange: (cb) => {
if (!manifest.capabilities.includes("request-filter")) throw new Error(`add-on "${manifest.id}" must declare the "request-filter" capability to read tabs`);
if (!this._tabs || typeof cb !== "function") return () => {};
const off = this._tabs.onChange(cb);
active.tabListeners.push(off);
return off;
},
},
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
setSessionProxy: async (rules) => {
if (!this._setSessionProxy) {
this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`);
return;
}
if (!manifest.capabilities.includes("session-proxy")) {
throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`);
}
await this._setSessionProxy(rules, manifest.id);
},
// Resolve a module from Theseus's own dependency tree. Add-ons run with
// the app's full privileges anyway; this only saves them from shipping
// a second copy of ws / @noble / etc.
require: (name) => {
if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`);
return this._hostRequire(name);
},
// Same, for ES-module-only packages: resolves to a Promise of the
// module namespace.
import: async (name) => {
if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`);
return this._hostImport(name);
},
// Open a new Theseus tab. Two shapes:
// - api.openTab("https://…") — no capability needed
// - api.openTab("editor.html", { query: {...} }) — opens one of the
// add-on's OWN files as a full tab; requires the "open-tab" cap.
// Path is resolved inside the add-on folder and rejected if it
// escapes it (path traversal). Query is URL-encoded. The page
// loads under addon-tab-preload.js so window.silentmode.invoke()
// reaches the same handlers as a sidebar panel — main gates by
// sender URL so a page hosted anywhere else gets nothing back.
openTab: (pathOrUrl, opts) => {
const s = String(pathOrUrl || "");
// Bare http(s) URL with no opts — legacy behaviour, unchanged.
if (/^https?:\/\//i.test(s) && !opts) {
if (!this._openTab) throw new Error("openTab unavailable (host not wired)");
this._openTab(s, manifest.id);
return;
}
if (!manifest.capabilities.includes("open-tab")) {
throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`);
}
if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)");
if (!s || path.isAbsolute(s) || s.includes("..")) {
throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`);
}
let qs = "";
if (opts && opts.query && typeof opts.query === "object") {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v));
qs = usp.toString();
}
return this._openAddonTab(manifest.id, s, qs);
},
// Open Theseus's Settings tab, optionally scrolled to a named section
// (validated against a known list in main). No capability needed —
// it's the same thing the user could do from the ⋮ menu, just a
// one-click shortcut so add-ons can point users at the right place
// (e.g. Aegis's "Set up vault" gate → Passwords).
openSettings: (section) => {
if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)");
this._openSettings(typeof section === "string" ? section : "");
},
// Check the OTA channel for a newer signed build of THIS add-on and
// stage it if one is found. Returns { status, staged, current, next }
// — status matches the shared addon-updater report vocabulary
// ("up-to-date" | "staged" | "already-staged" | "fetch-failed" | …).
// The staged copy activates on the next Theseus launch, so pair with
// restartApp() when the caller wants an immediate apply. Scoped to
// the calling add-on so a plug-in can't stage updates for its
// neighbours.
checkAndStageSelfUpdate: async () => {
if (!this._checkAndStageUpdates) throw new Error("checkAndStageSelfUpdate unavailable (host not wired)");
const full = await this._checkAndStageUpdates();
const own = (full?.report || []).find((r) => r.id === manifest.id) || { status: "no-update-url" };
return {
status: own.status || "unknown",
detail: own.detail || null,
current: own.currentVer || manifest.version,
next: own.newVer || null,
staged: (full?.staged || []).find((s) => s.id === manifest.id) || null,
};
},
// Ask Theseus to relaunch (the plug-in card's "apply update" chip).
// The host asks the user first and resolves { restarted, deferred }
// — an add-on cannot restart the browser on its own.
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
restartApp: () => {
if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)");
return Promise.resolve(this._restartApp(manifest.name || manifest.id));
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
},
// Resolves once the browser chrome has painted (immediately if it
// already has). Put expensive dependency loading behind this so it
// never competes with the first frame at launch.
whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))),
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
// injected page bridges) call into the add-on with a message name +
// one JSON payload; the handler's return value goes back as the
// response. `ctx.from` is "panel" or "page"; pages also carry
// `ctx.origin` ("https://host") so the add-on can scope permissions.
onMessage: (msg, handler) => {
if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`);
active.handlers.set(msg, handler);
},
// Push an event to the add-on's own sidebar panel if it's currently
// loaded. Fire-and-forget; silently dropped when the panel is closed.
emit: (msg, payload) => {
if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload);
},
// vault-derive: a 32-byte HKDF child of the password vault's root,
// keyed by a path that MUST start with this add-on's id — or one of
// the ids it declared under `absorbs` in addon.json, so a superseding
// add-on can keep deriving the same keys as the add-on it replaced
// (funds stay reachable across the transition). Resolves only once
// the user has unlocked the vault (main polls; the await can be long).
vault: {
derive: async (purposePath) => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`);
const p = String(purposePath || "");
if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) {
throw new Error(`vault.derive: purposePath must look like "${manifest.id}/<name>"`);
}
const allowed = [manifest.id, ...(manifest.absorbs || [])];
if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) {
const list = allowed.length > 1
? `one of "${allowed.join('", "')}"`
: `"${manifest.id}"`;
throw new Error(`vault.derive: purposePath must start with ${list} + "/"`);
}
return this._vaultDerive(p, manifest.id);
},
imports: this._makeImportsApi(manifest),
lifecycle: this._makeLifecycleApi(manifest),
},
// approval-modal: ask the user. Resolves to the chosen action id, or
// "cancel" (Escape / mask click / window closed). With `checkbox` set
// and ticked, the id comes back suffixed "+<checkbox.id>"; with
// `select` {id, label, options:[{value,label}]} and a non-empty value
// chosen, "+<select.id>=<value>".
approvalModal: async (opts) => {
if (!manifest.capabilities.includes("approval-modal")) {
throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`);
}
if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`);
return this._approvalModal(opts || {}, manifest.id);
},
// capture-tab: snapshot the currently-active tab.
// opts.mode "visible" | "full" | "region" (required)
// opts.format "png" | "jpeg" (default "png")
// opts.quality 1-100 (jpeg only, default 90)
// opts.overlaySource string (region only — DOM
// code the add-on wants injected while the user
// drags a selection. Must resolve to `{x,y,w,h}`
// in CSS pixels; return null/undefined to cancel.)
// Resolves to `{ dataUrl, width, height, host, format }`.
captureTab: async (opts) => {
if (!manifest.capabilities.includes("capture-tab")) {
throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`);
}
if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`);
return this._captureTab(opts || {}, manifest.id);
},
// capture-tab: route an in-memory image into the app's downloads pipeline
// so it lands in the user's Downloads folder AND shows up in the
// download-chip list the same way any HTTP download would.
// opts.dataUrl "data:image/png;base64,…" (required)
// opts.filename filename shown in the chip (required)
// Resolves to `{ savePath }`.
saveCapture: async (opts) => {
if (!manifest.capabilities.includes("capture-tab")) {
throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`);
}
if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`);
return this._saveCapture(opts || {}, manifest.id);
},
// 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(),
};
}
// `plugin` marks surfaces of a first-class Silent Mode component (manifest
// category "plugin", e.g. Aegis): the chrome pins those to their own dock
// instead of the extensions row.
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
getSidebarPanels() {
const out = [];
for (const active of this._active.values()) {
const plugin = active.manifest.category === "plugin";
for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name });
}
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
return out;
}
// Menu declarations from every active add-on that carries a toolbar-menu
// manifest block. Chrome renders one dock button per entry, opens the
// dropdown, then dispatches "menu-select" with the picked item id.
getToolbarMenus() {
const out = [];
for (const active of this._active.values()) {
const tm = active.manifest.toolbarMenu;
if (!tm) continue;
out.push({
addonId: active.manifest.id,
title: tm.title,
icon: tm.icon,
plugin: active.manifest.category === "plugin",
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
});
}
return out;
}
// Right-click menu items declared by add-ons, filtered by the current
// context. `ctx` is what Electron's context-menu event carries:
// { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL }
// Returns [{ addonId, id, label, icon, when }]. `when` filters:
// selectionText — non-empty text is selected
// linkURL — right-clicked on a link
// editable — right-clicked inside a form control / contenteditable
// image — mediaType === "image" and srcURL is present
// always — every menu
getContextMenuItems(ctx = {}) {
const has = {
selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()),
linkURL: !!ctx.linkURL,
editable: !!ctx.isEditable,
image: ctx.mediaType === "image" && !!ctx.srcURL,
};
const out = [];
for (const active of this._active.values()) {
const items = active.manifest.contextMenuItems || [];
for (const it of items) {
if (it.when !== "always" && !has[it.when]) continue;
out.push({ addonId: active.manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when });
}
}
return out;
}
feat(theseus+aegis): WizardConnect auto-detection — wiz:// links + page scan Completes the three detection paths. The injected provider shipped in 0.8.8; these two needed host support, because nothing in the add-on API could reach the active tab's content (captureTab is pixels, not DOM). wiz:// links (main.js) A click on a wiz:// anchor is intercepted in will-navigate and in the window-open handler (target="_blank" lands there instead), and routed to the wallet with the offering page's origin attached, so the approval names the real site. The tab never navigates. This needs nothing from the dapp beyond rendering the URI as a link, so it works for third-party dapps that will never adopt a Silent Mode API. scan-page capability (addons-host.js + main.js) New capability backing api.scanActiveTabForUris({scheme, limit}). Deliberately NOT a "read the page" API: the host runs the match and returns only the URIs found, so an add-on holding this still cannot see page text, markup or form values. It sits well below page-inject on the trust ladder — it learns that a page offers a wiz:// code and nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the result count is capped. The matcher also accepts WizardConnect's QR-alphanumeric spelling (WIZ://%3FP%3D…), which is frequently the only form present when a dapp renders its pairing code as a QR, and decodes it. Verified against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and QR-decoded alike. Regex sources are built host-side and passed as JSON rather than assembled inside the injected string — hand-escaping backslashes and quotes through two levels of literal was both wrong on the first attempt and unreviewable. Aegis Declares scan-page, adds the wcScanPage handler and a "Scan page" button next to Connect. A scan fills the URI field and stops there rather than pairing outright: the user still chooses which wallet signs and still presses Connect, because a scan that silently paired would carry far more consequence than the button implies. Older hosts without the capability get a clear "update Theseus" message instead of a dead button.
2026-09-23 00:16:49 +02:00
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 };