feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities

Three opt-in capabilities for add-ons, plus the plumbing they need:

- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
  vault is unlocked with a 32-byte HKDF child of the vault root under
  "silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
  preload asks main (sync, against the committed URL) which add-on bridges
  apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
  checkbox}) shows a consent overlay over the tab area (approval.html);
  resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
  messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
This commit is contained in:
Local Dev 2026-09-06 02:33:26 +02:00
parent 5450813fe2
commit ffeda26345
7 changed files with 435 additions and 9 deletions

35
addon-inject-preload.js Normal file
View file

@ -0,0 +1,35 @@
// Session-wide preload that runs every page-inject add-on's bridge script in
// the isolated world of tabs whose URL matches the add-on's declared origin
// patterns. Registered via session.defaultSession.setPreloads in main.js
// alongside bcnr-preload.js.
//
// The decision of WHICH scripts apply is made in main against the sender's
// committed URL, not against anything the page can influence. Each script
// gets a `theseus` object scoped to its add-on id:
// theseus.contextBridge — expose an API into the page's main world
// theseus.invoke(msg, payload) — call the add-on's onMessage(msg) handler
// theseus.origin — the page origin main will show the user
// plus a `require` that only resolves "electron" so scripts written in the
// ordinary preload idiom keep working.
const { contextBridge, ipcRenderer } = require("electron");
let injections = [];
try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } catch {}
for (const inj of injections) {
const id = String(inj.id);
const theseus = Object.freeze({
id,
origin: inj.origin,
contextBridge,
invoke: (msg, payload) => ipcRenderer.invoke("addon-page-msg", id, String(msg), payload),
});
const scopedRequire = (name) => {
if (name === "electron") return { contextBridge };
throw new Error(`addon inject scripts may only require("electron") — got ${name}`);
};
try {
new Function("theseus", "require", inj.source)(theseus, scopedRequire);
} catch (e) {
console.warn(`[theseus] add-on "${id}" page-inject failed:`, e?.message || e);
}
}

View file

@ -22,7 +22,41 @@ const path = require("node:path");
// teaching main.js and (typically) the chrome renderer about the new point. // teaching main.js and (typically) the chrome renderer about the new point.
// Right now only sidebar panels are wired — future rev adds toolbar-chip, // Right now only sidebar panels are wired — future rev adds toolbar-chip,
// proxy, page-inject, etc. // proxy, page-inject, etc.
const KNOWN_CAPABILITIES = new Set(["sidebar-panel", "session-proxy"]); const KNOWN_CAPABILITIES = new Set([
"sidebar-panel", "session-proxy",
// vault-derive: api.vault.derive(purposePath) — HKDF child of the password
// vault's root, namespaced under the add-on id.
// page-inject: manifest["page-inject"] = { preload, origins } — the add-on's
// preload source runs in the isolated world of every tab whose
// URL matches one of the origin patterns.
// approval-modal: api.approvalModal({...}) — user-facing consent dialog over
// the active tab, resolved by main.
"vault-derive", "page-inject", "approval-modal",
]);
// Chrome-style match pattern → predicate. "<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 // Manifest field guardrails. Reject anything shape-suspicious so a bad
// addon.json can't get past the loader gate. // addon.json can't get past the loader gate.
@ -47,19 +81,40 @@ function validateManifest(raw, folderName) {
// Not fatal — log later. Unknown caps are silently dropped. // Not fatal — log later. Unknown caps are silently dropped.
} }
} }
return { id, name, version, description, author, icon, main, capabilities }; let pageInject = null;
if (capabilities.includes("page-inject")) {
const pi = m["page-inject"];
if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`);
const preload = String(pi.preload || "");
if (!preload || preload.includes("..") || path.isAbsolute(preload)) {
throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`);
}
const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : [];
if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`);
pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) };
}
return { id, name, version, description, author, icon, main, capabilities, pageInject };
} }
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`. // of the app queries via `getActive()` / `getInstalled()`.
class AddonHost { class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy }) { constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire }) {
this.addonsDir = addonsDir; this.addonsDir = addonsDir;
this.dataDir = dataDir; this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false); this.isDisabled = isDisabled || (() => false);
this.log = logger || ((...a) => console.log("[addons]", ...a)); this.log = logger || ((...a) => console.log("[addons]", ...a));
this._installed = []; // [{ manifest, folder, error? }] this._installed = []; // [{ manifest, folder, error? }]
this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...] } this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject }
// Capability hooks injected by main. Each is (args..., addonId) so main
// can log/gate per add-on. Missing hook = capability unavailable.
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
// Add-ons live outside the app's node_modules tree, so a bare require()
// from their folder can't see Theseus's deps (ws, @noble/*, …). Main
// hands us its own require so add-ons can share the bundled tree.
this._hostRequire = typeof hostRequire === "function" ? hostRequire : null;
// Session-proxy hook — injected by main so add-ons can swap the default // Session-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). // session's proxy rules (e.g. a "route everything through my VPS" add-on).
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void> // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
@ -76,6 +131,7 @@ class AddonHost {
discoverAndActivate() { discoverAndActivate() {
this.ensureDirs(); this.ensureDirs();
this._deactivateAll();
this._installed = []; this._installed = [];
let entries = []; let entries = [];
try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; }
@ -121,7 +177,17 @@ class AddonHost {
if (!mod || typeof mod.activate !== "function") { if (!mod || typeof mod.activate !== "function") {
throw new Error(`main file must export an activate(api) function`); throw new Error(`main file must export an activate(api) function`);
} }
const active = { manifest, folder, exports: mod, sidebarPanels: [] }; 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); const api = this._makeApi(active);
try { mod.activate(api); } try { mod.activate(api); }
catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); }
@ -129,6 +195,16 @@ class AddonHost {
this.log(`activated ${manifest.id} v${manifest.version}`); 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) { _makeApi(active) {
const { manifest, folder } = active; const { manifest, folder } = active;
const storageFile = path.join(this.dataDir, `${manifest.id}.json`); const storageFile = path.join(this.dataDir, `${manifest.id}.json`);
@ -183,9 +259,87 @@ class AddonHost {
} }
await this._setSessionProxy(rules, manifest.id); await this._setSessionProxy(rules, manifest.id);
}, },
// Resolve a module from Theseus's own dependency tree. Add-ons run with
// the app's full privileges anyway; this only saves them from shipping
// a second copy of ws / @noble / etc.
require: (name) => {
if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`);
return this._hostRequire(name);
},
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
// injected page bridges) call into the add-on with a message name +
// one JSON payload; the handler's return value goes back as the
// response. `ctx.from` is "panel" or "page"; pages also carry
// `ctx.origin` ("https://host") so the add-on can scope permissions.
onMessage: (msg, handler) => {
if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`);
active.handlers.set(msg, handler);
},
// Push an event to the add-on's own sidebar panel if it's currently
// loaded. Fire-and-forget; silently dropped when the panel is closed.
emit: (msg, payload) => {
if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload);
},
// vault-derive: a 32-byte HKDF child of the password vault's root,
// keyed by a path that MUST start with this add-on's id so one add-on
// can never ask for another's material. Resolves only once the user
// has unlocked the vault (main polls; the await can be long).
vault: {
derive: async (purposePath) => {
if (!manifest.capabilities.includes("vault-derive")) {
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
}
if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`);
const p = String(purposePath || "");
if (!p.startsWith(manifest.id + "/") || /[^a-z0-9/._-]/i.test(p) || p.includes("..")) {
throw new Error(`vault.derive: purposePath must look like "${manifest.id}/<name>"`);
}
return this._vaultDerive(p, manifest.id);
},
},
// approval-modal: ask the user. Resolves to the chosen action id, or
// "cancel" (Escape / mask click / window closed). With `checkbox` set
// and ticked, the id comes back suffixed "+<checkbox.id>".
approvalModal: async (opts) => {
if (!manifest.capabilities.includes("approval-modal")) {
throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`);
}
if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`);
return this._approvalModal(opts || {}, manifest.id);
},
}; };
} }
// Route a message to an add-on's registered handler. Callers (main) have
// already established WHO is asking; `ctx` carries that provenance.
async dispatch(id, msg, payload, ctx) {
const active = this._active.get(id);
if (!active) throw new Error(`add-on "${id}" is not active`);
const handler = active.handlers.get(String(msg));
if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`);
return handler(payload, ctx || {});
}
hasHandler(id, msg) {
const active = this._active.get(id);
return !!(active && active.handlers.has(String(msg)));
}
// Inject scripts that apply to a tab URL — [{ id, source }].
injectionsFor(url) {
const out = [];
for (const active of this._active.values()) {
if (active.inject && urlMatchesAny(url, active.inject.matchers)) {
out.push({ id: active.manifest.id, source: active.inject.source });
}
}
return out;
}
// Does this add-on's page-inject declaration cover the URL? Used to gate
// page → add-on IPC so a non-matching page can't spoof a matching one.
pageAllowed(id, url) {
const active = this._active.get(id);
return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers));
}
// Read-only views for the rest of the app. // Read-only views for the rest of the app.
snapshot() { snapshot() {
return { return {
@ -213,4 +367,4 @@ class AddonHost {
isActive(id) { return this._active.has(id); } isActive(id) { return this._active.has(id); }
} }
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest }; module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };

9
approval-preload.js Normal file
View file

@ -0,0 +1,9 @@
// Preload for the add-on approval overlay (approval.html). Main pushes one
// request at a time via `approval-show`; the page answers with the chosen
// action id through `approval-pick`. Nothing else is exposed — the overlay
// is a pure consent surface.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("approval", {
onShow: (cb) => ipcRenderer.on("approval-show", (_e, req) => cb(req)),
pick: (reqId, action, checked) => ipcRenderer.invoke("approval-pick", reqId, action, !!checked),
});

90
approval.html Normal file
View file

@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Approval</title>
<style>
:root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
@media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: grid; place-items: start center; padding-top: 48px; }
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px 14px; width: min(460px, calc(100vw - 32px));
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
.who .addon { color: var(--mut); }
.title { font-size: 15px; font-weight: 650; letter-spacing: .1px; margin: 0 0 10px; }
.origin { display: inline-flex; align-items: center; gap: 8px; max-width: 100%;
background: var(--surface2); border: 1px solid rgba(214,255,61,.35); color: var(--acid);
border-radius: 8px; padding: 6px 10px; margin-bottom: 12px;
font: 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
.origin .lbl { color: var(--dim); font: 11px system-ui, sans-serif; white-space: nowrap; }
.body { white-space: pre-wrap; color: var(--ink); margin-bottom: 12px; }
.rows { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin-bottom: 12px;
background: var(--surface2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
.rows .k { color: var(--dim); font-size: 12px; white-space: nowrap; }
.rows .v { word-break: break-all; }
.rows .v.mono { font: 12.5px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
.rows .v.strong { font-weight: 650; font-size: 14px; }
label.chk { display: flex; align-items: center; gap: 8px; color: var(--mut); font-size: 12.5px; margin-bottom: 12px; cursor: pointer; }
.pact { display: flex; gap: 6px; justify-content: flex-end; }
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
.pbtn:hover { border-color: rgba(214,255,61,.35); }
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn.danger { background: var(--danger); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn:focus-visible { outline: 2px solid rgba(214,255,61,.6); outline-offset: 1px; }
</style>
</head>
<body>
<script>
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
let current = null;
function finish(action, checked) {
if (!current) return;
const { reqId } = current;
current = null;
document.body.innerHTML = "";
window.approval.pick(reqId, action, checked);
}
window.approval.onShow((req) => {
current = req;
const rows = Array.isArray(req.rows) ? req.rows : [];
const actions = Array.isArray(req.actions) && req.actions.length ? req.actions : [{ id: "ok", label: "OK", primary: true }];
const hasCancel = actions.some((a) => a.id === "cancel");
document.body.innerHTML =
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>
<h1 class="title">${esc(req.title || "Approve?")}</h1>
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
${rows.length ? `<div class="rows">${rows.map((r) => `<div class="k">${esc(r.label)}</div><div class="v${r.mono ? " mono" : ""}${r.strong ? " strong" : ""}">${esc(r.value)}</div>`).join("")}</div>` : ""}
${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"> ${esc(req.checkbox.label || "Always allow")}</label>` : ""}
<div class="pact">
${hasCancel ? "" : `<button class="pbtn" type="button" data-id="cancel">Cancel</button>`}
${actions.map((a) => `<button class="pbtn${a.primary ? " primary" : ""}${a.danger ? " danger" : ""}" type="button" data-id="${esc(a.id)}">${esc(a.label || a.id)}</button>`).join("")}
</div>
</div></div>`;
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish("cancel", false); });
document.querySelectorAll("button[data-id]").forEach((b) => {
b.addEventListener("click", () => finish(b.dataset.id, !!document.getElementById("chk")?.checked));
});
// Focus the non-destructive default so Enter never blindly approves a
// spend; the user has to tab or click onto the primary action.
const first = document.querySelector('button[data-id="cancel"]') || document.querySelector("button");
setTimeout(() => first && first.focus(), 0);
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") finish("cancel", false); });
</script>
</body>
</html>

136
main.js
View file

@ -1337,6 +1337,30 @@ function initAddons() {
} }
try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); } try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); }
}, },
// vault-derive capability. Resolves once the vault is unlocked (the
// user types the master password at boot or later in Settings) with a
// 32-byte HKDF child of the vault's root. The vault never persists the
// BIP-39 seed — only per-purpose roots — so add-on material hangs off
// the passwords root under an "addons/" info label: recoverable from
// the same mnemonic on any device, and a derived password can't be
// walked back to it (HKDF is one-way).
vaultDerive: async (purposePath, addonId) => {
if (!fs.existsSync(vaultFile())) throw new Error("password vault is not set up");
while (!vaultState) await new Promise((r) => setTimeout(r, 500));
const v = await loadVaultLib();
const wc = require("node:crypto").webcrypto;
const key = await wc.subtle.importKey("raw", v.hexToBytes(vaultState.purposeRoot), "HKDF", false, ["deriveBits"]);
const info = new TextEncoder().encode(`silentmode/addons/${purposePath}`);
console.log(`[addons] [${addonId}] vault.derive ${purposePath}`);
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
},
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
emitToPanel: (addonId, msg, payload) => {
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return;
try { sidebar.webContents.send("addon-event", msg, payload); } catch {}
},
hostRequire: (name) => require(name),
}); });
addonHost.discoverAndActivate(); addonHost.discoverAndActivate();
const snap = addonHost.snapshot(); const snap = addonHost.snapshot();
@ -1393,6 +1417,9 @@ function layout() {
const tabW = Math.max(0, width - sideW); const tabW = Math.max(0, width - sideW);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH }); if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH });
// Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted.
if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
positionPopover(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
@ -1976,6 +2003,13 @@ function createWindow() {
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
win.contentView.addChildView(sidebar); win.contentView.addChildView(sidebar);
sidebar.setVisible(false); sidebar.setVisible(false);
// Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
try { approvalPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(approvalPop);
approvalPop.webContents.loadFile("approval.html");
approvalPop.setVisible(false);
chrome.webContents.once("did-finish-load", () => { chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : []; const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
@ -2248,6 +2282,100 @@ ipcMain.handle("addons-reload", () => {
addonHost.discoverAndActivate(); addonHost.discoverAndActivate();
return true; return true;
}); });
// --- Add-on messaging + capabilities -----------------------------------------
// Panel → add-on: the sidebar panel's file:// URL tells us which add-on it
// belongs to (same gate as storage). The add-on's onMessage handler runs in
// main and its return value is the response.
ipcMain.handle("addon-msg", async (e, msg, payload) => {
const id = addonIdForSender(e.sender);
if (!id || !addonHost) throw new Error("not an add-on panel");
return addonHost.dispatch(id, String(msg), payload, { from: "panel" });
});
// Page → add-on: only a real tab whose committed URL matches the add-on's
// page-inject origins may talk to it, and only through messages the add-on
// registered. Origin is "<scheme>://<host>" (bns:// shown as https://).
function tabForSender(sender) { return tabs.find((t) => t.view.webContents === sender) || null; }
function pageOriginOf(url) {
try {
const u = new URL(String(url).replace(/^bns:\/\//i, "https://"));
return u.protocol && u.host ? `${u.protocol}//${u.host}` : null;
} catch { return null; }
}
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
const tab = tabForSender(e.sender);
if (!tab || !addonHost) throw new Error("not a page");
const url = e.sender.getURL();
const id = String(addonId || "");
if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`);
const origin = pageOriginOf(url);
if (!origin) throw new Error("opaque origin");
return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id });
});
// Synchronous — the inject preload has to know what to run before the page's
// own scripts start. Decided against the sender's committed URL; the href the
// preload reports is only logged when it disagrees.
ipcMain.on("addon-inject-scripts", (e, href) => {
e.returnValue = [];
const tab = tabForSender(e.sender);
if (!tab || !addonHost) return;
const url = e.sender.getURL();
if (!url || url.startsWith("file:")) return;
if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`);
const origin = pageOriginOf(url);
e.returnValue = addonHost.injectionsFor(url).map((x) => ({ ...x, origin }));
});
// Approval overlay. One request at a time; later callers queue behind the
// visible one so two dapps can't race each other for the same click.
let approvalPop = null;
const approvalQueue = [];
let approvalCurrent = null; // { reqId, resolve }
let approvalSeq = 0;
function pumpApproval() {
if (approvalCurrent || !approvalQueue.length || !approvalPop) return;
const next = approvalQueue.shift();
approvalCurrent = next;
try {
approvalPop.webContents.send("approval-show", next.req);
approvalPop.setVisible(true);
try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {}
layout();
approvalPop.webContents.focus();
} catch (err) {
approvalCurrent = null;
next.resolve("cancel");
console.warn("[addons] approval show failed:", err?.message);
}
}
function showApprovalModal(opts, addonId) {
const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
const req = {
reqId: ++approvalSeq,
addonId,
addonName: a ? a.manifest.name : addonId,
title: String(opts.title || "Approve?"),
body: opts.body == null ? "" : String(opts.body),
origin: opts.origin == null ? "" : String(opts.origin),
rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [],
actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [],
checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null,
};
return new Promise((resolve) => {
approvalQueue.push({ req, resolve });
pumpApproval();
});
}
ipcMain.handle("approval-pick", (e, reqId, action, checked) => {
if (!approvalPop || e.sender !== approvalPop.webContents) return false;
if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;
const cur = approvalCurrent;
approvalCurrent = null;
approvalPop.setVisible(false);
let result = String(action || "cancel");
if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id;
cur.resolve(result);
pumpApproval();
return true;
});
// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------ // --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------
// Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main // Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main
// derives the add-on id from the sender's file:// URL so a page can only // derives the add-on id from the sender's file:// URL so a page can only
@ -3270,10 +3398,12 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
// called before any tab is created; whenReady runs before createWindow(). // called before any tab is created; whenReady runs before createWindow().
try { try {
const bcnrPreload = path.join(__dirname, "bcnr-preload.js"); const bcnrPreload = path.join(__dirname, "bcnr-preload.js");
// Add-on page-inject bridges ride the same session-wide slot; the
// preload asks main which (if any) apply to the tab it runs in.
const injectPreload = path.join(__dirname, "addon-inject-preload.js");
const existing = session.defaultSession.getPreloads(); const existing = session.defaultSession.getPreloads();
if (!existing.includes(bcnrPreload)) { const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p));
session.defaultSession.setPreloads([...existing, bcnrPreload]); if (wanted.length) session.defaultSession.setPreloads([...existing, ...wanted]);
}
} catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); } } catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); }
protocol.handle("bns", serveBns); protocol.handle("bns", serveBns);
installDownloadTracker(); installDownloadTracker();

View file

@ -50,6 +50,9 @@
"error.html", "error.html",
"error-preload.js", "error-preload.js",
"sidebar-preload.js", "sidebar-preload.js",
"approval-preload.js",
"approval.html",
"addon-inject-preload.js",
"addons-host.js", "addons-host.js",
"link-status.html", "link-status.html",
"link-status-preload.js", "link-status-preload.js",

View file

@ -13,6 +13,11 @@ contextBridge.exposeInMainWorld("silentmode", {
// Ask main which panel is currently visible — panels may want to // Ask main which panel is currently visible — panels may want to
// suspend expensive work when hidden. // suspend expensive work when hidden.
onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)), onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)),
// Call into the add-on's activate() context: resolves with whatever the
// matching api.onMessage handler returned (or rejects with its error).
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
// Events the add-on pushes via api.emit while this panel is open.
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
}); });
// -- Panel picker strip ----------------------------------------------------- // -- Panel picker strip -----------------------------------------------------