Theseus: community extensions run in their own sandboxed process
An extension was require()d into the browser's main process. Its declared capabilities bound only an honest one: there it could load electron, hook the unlock prompt for the master password, read the vault files and the wallet's store, call the OS keystore, and reach every tab. Extensions that do not ship with Theseus now run in a separate process, in Node mode under Node's permission model: read access to their own folder, write access to a scratch folder, no child processes, no workers, no native add-ons, no Electron, and none of the browser's memory. They reach the browser only through an allow-listed message API that calls the same functions, with the same capability checks, as before. Built-in add-ons are unchanged and stay in-process. For extension authors: host calls return promises (tabs.active included); api.require / api.import are gone, so dependencies must be bundled; registerRequestFilter and registerSiteRoute are not offered; the store is handed over at start and written through, so storage.get stays synchronous. An approval raised while handling a page message is still tied to that page's tab. If the sandbox cannot start, the extension does not run. The channel is JSON with tagged bytes: the binary IPC format depends on the exact V8 build on both ends.
This commit is contained in:
parent
bf92548267
commit
7cc66ce680
2 changed files with 364 additions and 2 deletions
185
addons-host.js
185
addons-host.js
|
|
@ -30,6 +30,46 @@ const path = require("node:path");
|
||||||
// How long a call waits for an async activate() to settle before it is
|
// How long a call waits for an async activate() to settle before it is
|
||||||
// delivered anyway.
|
// delivered anyway.
|
||||||
const READY_WAIT_MS = 5000;
|
const READY_WAIT_MS = 5000;
|
||||||
|
// What a sandboxed (community) extension may ask of the host. Each name is a
|
||||||
|
// path on the api object _makeApi builds, so its capability check still runs.
|
||||||
|
// Absent on purpose: require/import (host modules), registerRequestFilter and
|
||||||
|
// registerSiteRoute (take a function), vault.imports / vault.pin and the
|
||||||
|
// lifecycle calls that handle the master password, and storage (the store is
|
||||||
|
// handed over at start and written through its own message).
|
||||||
|
const SANDBOX_API = new Set([
|
||||||
|
"emit", "registerSidebarPanel", "revealSidebar", "openTab", "openSettings", "setSessionProxy",
|
||||||
|
"captureTab", "saveCapture", "scanActiveTabForUris", "approvalModal",
|
||||||
|
"checkAndStageSelfUpdate", "applySelfUpdate", "restartApp", "startAtLaunch", "whenUiReady",
|
||||||
|
"tabs.active", "vault.derive", "vault.requestUnlock", "vault.lifecycle.status",
|
||||||
|
]);
|
||||||
|
// Messages cross the process boundary as JSON (the binary 'advanced' channel
|
||||||
|
// format is tied to the exact V8 build on both ends). Bytes and BigInts are
|
||||||
|
// tagged so they arrive as what they were. Same pair as in the runner.
|
||||||
|
function wireEnc(v, depth = 0) {
|
||||||
|
if (depth > 40) throw new Error("value too deeply nested");
|
||||||
|
if (v === null || v === undefined) return v === undefined ? undefined : null;
|
||||||
|
if (typeof v === "bigint") return { __wire: "big", v: v.toString() };
|
||||||
|
if (typeof v === "function" || typeof v === "symbol") return undefined;
|
||||||
|
if (typeof v !== "object") return v;
|
||||||
|
if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") };
|
||||||
|
if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") };
|
||||||
|
if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; });
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function wireDec(v) {
|
||||||
|
if (v === null || typeof v !== "object") return v;
|
||||||
|
if (Array.isArray(v)) return v.map(wireDec);
|
||||||
|
if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64"));
|
||||||
|
if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v);
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(v)) out[k] = wireDec(v[k]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const SANDBOX_CALL_MS = 120000;
|
||||||
|
const SANDBOX_STORE_VALUE_MAX = 4 * 1024 * 1024;
|
||||||
|
|
||||||
// Extension points the framework understands. Extending this list means also
|
// Extension points the framework understands. Extending this list means also
|
||||||
// teaching main.js and (typically) the chrome renderer about the new point.
|
// teaching main.js and (typically) the chrome renderer about the new point.
|
||||||
|
|
@ -323,6 +363,10 @@ class AddonHost {
|
||||||
// isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus,
|
// isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus,
|
||||||
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
|
// and which legacy ids those absorb. Gate `absorbs` in vault.derive.
|
||||||
this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null;
|
this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null;
|
||||||
|
// Extensions that are not built in run in a sandboxed process unless the
|
||||||
|
// host explicitly opts out (sandboxExecPath overrides the binary; tests).
|
||||||
|
this._sandboxCommunity = arguments[0].sandboxCommunity !== false;
|
||||||
|
this._sandboxExecPath = typeof arguments[0].sandboxExecPath === "string" ? arguments[0].sandboxExecPath : null;
|
||||||
this._isReservedId = typeof arguments[0].isReservedId === "function" ? arguments[0].isReservedId : null;
|
this._isReservedId = typeof arguments[0].isReservedId === "function" ? arguments[0].isReservedId : null;
|
||||||
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
||||||
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
||||||
|
|
@ -589,7 +633,10 @@ class AddonHost {
|
||||||
// resolver. This is where the trust decision lives: we're loading arbitrary
|
// resolver. This is where the trust decision lives: we're loading arbitrary
|
||||||
// JS into the main process with full API access.
|
// JS into the main process with full API access.
|
||||||
let mod;
|
let mod;
|
||||||
try {
|
const holder = { active: null };
|
||||||
|
const sandboxed = this._shouldSandbox(manifest);
|
||||||
|
if (sandboxed) mod = this._sandboxModule(manifest, folder, mainPath, holder);
|
||||||
|
else try {
|
||||||
// Bust the require cache so a manual reload picks up edits — cheap since
|
// Bust the require cache so a manual reload picks up edits — cheap since
|
||||||
// add-ons are small. When the version changed (a hot-applied update) the
|
// add-ons are small. When the version changed (a hot-applied update) the
|
||||||
// whole folder goes, or the new index.js would run against the previous
|
// whole folder goes, or the new index.js would run against the previous
|
||||||
|
|
@ -608,7 +655,8 @@ class AddonHost {
|
||||||
throw new Error(`main file must export an activate(api) function`);
|
throw new Error(`main file must export an activate(api) function`);
|
||||||
}
|
}
|
||||||
// Declared panels are registered up front; activate() may still add more.
|
// Declared panels are registered up front; activate() may still add more.
|
||||||
const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() };
|
const active = { manifest, folder, exports: mod, sandboxed, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() };
|
||||||
|
holder.active = active;
|
||||||
if (manifest.pageInject) {
|
if (manifest.pageInject) {
|
||||||
// Read the inject source once at activation. It's shipped to every
|
// Read the inject source once at activation. It's shipped to every
|
||||||
// matching tab's preload verbatim, so a syntax error surfaces in the
|
// matching tab's preload verbatim, so a syntax error surfaces in the
|
||||||
|
|
@ -644,6 +692,139 @@ class AddonHost {
|
||||||
this.log(`activated ${manifest.id} v${manifest.version}`);
|
this.log(`activated ${manifest.id} v${manifest.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- sandbox for extensions that do not ship with Theseus -----------------
|
||||||
|
// A built-in add-on is code we publish and it runs in this process. Anything
|
||||||
|
// else — a community extension from the catalog — runs in its own process
|
||||||
|
// under Node's permission model (see lib/addon-sandbox-runner.cjs) and
|
||||||
|
// reaches the browser only through the allow-listed calls below. In this
|
||||||
|
// process an extension could load `electron`, watch the unlock prompt, and
|
||||||
|
// read the vault, the wallet's store and every tab, whatever its manifest
|
||||||
|
// said. If the sandbox cannot be started the extension does not run.
|
||||||
|
_shouldSandbox(manifest) {
|
||||||
|
return this._sandboxCommunity && !!this._isFirstPartyId && !this._isFirstPartyId(manifest.id);
|
||||||
|
}
|
||||||
|
_sandboxRunnerFile() {
|
||||||
|
if (this._runnerFile) return this._runnerFile;
|
||||||
|
// Copied out to a real file each launch: the packaged app keeps lib/
|
||||||
|
// inside app.asar, which a process in Node mode under the permission
|
||||||
|
// model cannot be pointed at.
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, "lib", "addon-sandbox-runner.cjs"), "utf8");
|
||||||
|
const dir = path.join(this.dataDir, ".sandbox");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = path.join(dir, "runner.cjs");
|
||||||
|
fs.writeFileSync(file, src);
|
||||||
|
return (this._runnerFile = file);
|
||||||
|
}
|
||||||
|
_sandboxModule(manifest, folder, mainPath, holder) {
|
||||||
|
const { fork } = require("node:child_process");
|
||||||
|
const id = manifest.id;
|
||||||
|
const log = (...a) => this.log(`[${id}]`, ...a);
|
||||||
|
let child = null, nextId = 0, activated = null;
|
||||||
|
const waiting = new Map(); // call id -> { resolve, reject, timer }
|
||||||
|
const callScopes = new Map(); // call id -> pageCallCtx store while a page call is in flight
|
||||||
|
const failAll = (why) => { for (const w of waiting.values()) { clearTimeout(w.timer); w.reject(new Error(why)); } waiting.clear(); callScopes.clear(); };
|
||||||
|
const send = (m) => { try { if (child && child.connected) child.send(wireEnc(m)); } catch {} };
|
||||||
|
const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined });
|
||||||
|
|
||||||
|
const activate = (api) => new Promise((resolve, reject) => {
|
||||||
|
const runner = this._sandboxRunnerFile();
|
||||||
|
const scratch = path.join(this.dataDir, ".sandbox", "scratch", id);
|
||||||
|
fs.mkdirSync(scratch, { recursive: true });
|
||||||
|
const env = { ELECTRON_RUN_AS_NODE: "1" };
|
||||||
|
for (const k of ["SystemRoot", "windir", "TEMP", "TMP", "LANG", "TZ"]) if (process.env[k]) env[k] = process.env[k];
|
||||||
|
try {
|
||||||
|
child = fork(runner, [], {
|
||||||
|
execPath: this._sandboxExecPath || process.execPath,
|
||||||
|
execArgv: ["--permission", `--allow-fs-read=${runner}`, `--allow-fs-read=${folder}`, `--allow-fs-read=${scratch}`, `--allow-fs-write=${scratch}`],
|
||||||
|
cwd: folder, env, serialization: "json", windowsHide: true,
|
||||||
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||||
|
});
|
||||||
|
} catch (e) { reject(new Error(`sandbox could not start: ${e?.message || e}`)); return; }
|
||||||
|
activated = { resolve, reject };
|
||||||
|
const startTimer = setTimeout(() => { if (activated) { activated = null; try { child.kill(); } catch {} reject(new Error("sandbox did not answer")); } }, 20000);
|
||||||
|
if (startTimer.unref) startTimer.unref();
|
||||||
|
let tabsOff = null;
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
clearTimeout(startTimer);
|
||||||
|
try { if (tabsOff) tabsOff(); } catch {}
|
||||||
|
failAll("the extension's process ended");
|
||||||
|
if (activated) { activated.reject(new Error(`sandbox exited before activation (code ${code})`)); activated = null; }
|
||||||
|
else if (holder.active && this._active.get(id) === holder.active) log(`sandboxed process exited (code ${code})`);
|
||||||
|
});
|
||||||
|
child.on("error", (e) => log("sandbox error:", e?.message || e));
|
||||||
|
child.on("message", async (raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return;
|
||||||
|
const m = wireDec(raw);
|
||||||
|
const active = holder.active;
|
||||||
|
if (m.t === "ready") {
|
||||||
|
let store = {};
|
||||||
|
try { store = api.storage.all() || {}; } catch {}
|
||||||
|
send({ t: "activate", manifest: { id, name: manifest.name, version: manifest.version, capabilities: manifest.capabilities }, folder, mainPath, scratchDir: scratch, store, features: api.features });
|
||||||
|
} else if (m.t === "activated") {
|
||||||
|
clearTimeout(startTimer);
|
||||||
|
const a = activated; activated = null;
|
||||||
|
if (!a) return;
|
||||||
|
if (m.ok) a.resolve(); else a.reject(new Error(m.error?.message || "activate() failed"));
|
||||||
|
} else if (m.t === "handler") {
|
||||||
|
if (!active || typeof m.name !== "string" || !m.name) return;
|
||||||
|
// A stub in this process forwards each call to the handler that
|
||||||
|
// lives in the extension's process.
|
||||||
|
active.handlers.set(m.name, (payload, ctx) => new Promise((res, rej) => {
|
||||||
|
const cid = ++nextId;
|
||||||
|
const scope = pageCallCtx.getStore();
|
||||||
|
if (scope) callScopes.set(cid, scope);
|
||||||
|
const timer = setTimeout(() => { waiting.delete(cid); callScopes.delete(cid); rej(new Error(`"${m.name}" timed out`)); }, SANDBOX_CALL_MS);
|
||||||
|
if (timer.unref) timer.unref();
|
||||||
|
waiting.set(cid, { resolve: res, reject: rej, timer });
|
||||||
|
send({ t: "call", id: cid, name: m.name, payload, ctx: ctx ? { from: ctx.from, origin: ctx.origin, tabId: ctx.tabId } : {} });
|
||||||
|
}));
|
||||||
|
} else if (m.t === "res") {
|
||||||
|
const w = waiting.get(m.id);
|
||||||
|
if (!w) return;
|
||||||
|
waiting.delete(m.id); callScopes.delete(m.id); clearTimeout(w.timer);
|
||||||
|
if (m.ok) w.resolve(m.value);
|
||||||
|
else { const e = new Error(m.error?.message || "extension call failed"); if (m.error?.code != null) e.code = m.error.code; w.reject(e); }
|
||||||
|
} else if (m.t === "api") {
|
||||||
|
// The only way the extension reaches the browser. Not on the list:
|
||||||
|
// not callable. On the list: the same function, with the same
|
||||||
|
// capability checks, an in-process add-on would have called.
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
if (typeof m.path !== "string" || !SANDBOX_API.has(m.path)) throw new Error(`"${m.path}" is not available to sandboxed extensions`);
|
||||||
|
const fn = m.path.split(".").reduce((o, k) => (o ? o[k] : undefined), api);
|
||||||
|
if (typeof fn !== "function") throw new Error(`"${m.path}" is not available`);
|
||||||
|
const args = Array.isArray(m.args) ? m.args : [];
|
||||||
|
const scope = m.callId != null ? callScopes.get(m.callId) : null;
|
||||||
|
const value = await (scope ? pageCallCtx.run(scope, () => fn(...args)) : fn(...args));
|
||||||
|
out = { t: "res", id: m.id, ok: true, value: value === undefined ? null : value };
|
||||||
|
} catch (e) { out = { t: "res", id: m.id, ok: false, error: errOut(e) }; }
|
||||||
|
try { send(out); } catch { send({ t: "res", id: m.id, ok: false, error: { message: "result could not be passed to the extension" } }); }
|
||||||
|
} else if (m.t === "storage.set") {
|
||||||
|
try {
|
||||||
|
const size = m.value == null ? 0 : JSON.stringify(m.value).length;
|
||||||
|
if (size > SANDBOX_STORE_VALUE_MAX) throw new Error("value too large");
|
||||||
|
if (String(m.key).startsWith("__") && m.key !== "__pageInjectPolicy") throw new Error("reserved key");
|
||||||
|
api.storage.set(String(m.key), m.value == null ? null : m.value);
|
||||||
|
} catch (e) { log(`storage.set("${m.key}") refused: ${e?.message || e}`); }
|
||||||
|
} else if (m.t === "log") {
|
||||||
|
log(...(Array.isArray(m.args) ? m.args.map((x) => String(x).slice(0, 2000)) : []));
|
||||||
|
} else if (m.t === "tabs.watch") {
|
||||||
|
if (tabsOff) return;
|
||||||
|
try { tabsOff = api.tabs.onChange((t) => send({ t: "tabs", data: t })); } catch (e) { log("tabs.onChange refused:", e?.message || e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const deactivate = () => {
|
||||||
|
failAll("the extension was stopped");
|
||||||
|
const c = child; child = null;
|
||||||
|
if (!c) return;
|
||||||
|
try { if (c.connected) c.send(wireEnc({ t: "deactivate" })); } catch {}
|
||||||
|
const t = setTimeout(() => { try { c.kill(); } catch {} }, 1500);
|
||||||
|
if (t.unref) t.unref();
|
||||||
|
};
|
||||||
|
return { activate, deactivate };
|
||||||
|
}
|
||||||
|
|
||||||
// Tear down every active add-on before a re-discover so long-lived state
|
// Tear down every active add-on before a re-discover so long-lived state
|
||||||
// (sockets, timers) from a previous activation doesn't pile up.
|
// (sockets, timers) from a previous activation doesn't pile up.
|
||||||
_deactivateAll() {
|
_deactivateAll() {
|
||||||
|
|
|
||||||
181
lib/addon-sandbox-runner.cjs
Normal file
181
lib/addon-sandbox-runner.cjs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// Runs ONE community extension outside the browser process.
|
||||||
|
//
|
||||||
|
// An extension used to be require()d into Electron's main process, where it
|
||||||
|
// could load `electron`, hook the unlock prompt, read the vault files and
|
||||||
|
// the wallet's store, or shell out to the OS keystore — its declared
|
||||||
|
// capabilities only bound an honest extension. Extensions that do not ship
|
||||||
|
// with Theseus now run here instead: a separate process started in Node mode
|
||||||
|
// under Node's permission model, allowed to read nothing but its own folder
|
||||||
|
// (and write nothing but its own scratch folder), with no child processes,
|
||||||
|
// no workers, no native add-ons and no Electron. Everything it can do to the
|
||||||
|
// browser goes through the message channel below, and the host answers only
|
||||||
|
// the calls on its allow-list, with the same capability checks as before.
|
||||||
|
//
|
||||||
|
// The `api` object keeps the in-process shape where it can. Differences an
|
||||||
|
// extension author will meet:
|
||||||
|
// - host calls return promises (tabs.active() included);
|
||||||
|
// - api.require / api.import are gone — bundle your dependencies;
|
||||||
|
// - registerRequestFilter and registerSiteRoute are not offered (both hand
|
||||||
|
// the host a function it would have to call synchronously);
|
||||||
|
// - vault.imports, vault.pin and vault.lifecycle.unlock/setup/lock are
|
||||||
|
// built-in only, as they already were.
|
||||||
|
"use strict";
|
||||||
|
const { AsyncLocalStorage } = require("node:async_hooks");
|
||||||
|
const callScope = new AsyncLocalStorage(); // which page call a host request belongs to
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const pending = new Map(); // id -> { resolve, reject }
|
||||||
|
const handlers = new Map(); // message name -> fn(payload, ctx)
|
||||||
|
const tabListeners = new Set();
|
||||||
|
let mod = null;
|
||||||
|
|
||||||
|
// Messages cross the process boundary as JSON (the binary 'advanced' channel
|
||||||
|
// format is tied to the exact V8 build on both ends). Bytes and BigInts are
|
||||||
|
// tagged so they arrive as what they were.
|
||||||
|
function wireEnc(v, depth = 0) {
|
||||||
|
if (depth > 40) throw new Error("value too deeply nested");
|
||||||
|
if (v === null || v === undefined) return v === undefined ? undefined : null;
|
||||||
|
if (typeof v === "bigint") return { __wire: "big", v: v.toString() };
|
||||||
|
if (typeof v === "function" || typeof v === "symbol") return undefined;
|
||||||
|
if (typeof v !== "object") return v;
|
||||||
|
if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") };
|
||||||
|
if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") };
|
||||||
|
if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; });
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function wireDec(v) {
|
||||||
|
if (v === null || typeof v !== "object") return v;
|
||||||
|
if (Array.isArray(v)) return v.map(wireDec);
|
||||||
|
if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64"));
|
||||||
|
if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v);
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(v)) out[k] = wireDec(v[k]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const send = (m) => { try { process.send(wireEnc(m)); } catch { /* host is gone */ } };
|
||||||
|
const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined });
|
||||||
|
const plain = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
||||||
|
|
||||||
|
function hostCall(path, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++seq;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
const scope = callScope.getStore();
|
||||||
|
send({ t: "api", id, path, args, callId: scope ? scope.callId : null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const builtInOnly = (what) => () => Promise.reject(new Error(`${what} is reserved for built-in add-ons`));
|
||||||
|
const notOffered = (what, why) => () => { throw new Error(`${what} is not available to sandboxed extensions — ${why}`); };
|
||||||
|
|
||||||
|
function makeApi(init) {
|
||||||
|
const store = init.store && typeof init.store === "object" ? init.store : {};
|
||||||
|
const call = (path) => (...args) => hostCall(path, args);
|
||||||
|
return {
|
||||||
|
id: init.manifest.id,
|
||||||
|
folder: init.folder,
|
||||||
|
dataDir: init.scratchDir,
|
||||||
|
features: Object.freeze({ ...(init.features || {}), sandboxed: true }),
|
||||||
|
log: (...a) => send({ t: "log", args: a.map((x) => (typeof x === "string" ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())) }),
|
||||||
|
// The store is this extension's own key/value file. It is handed over
|
||||||
|
// whole at start and written through, so get() stays synchronous.
|
||||||
|
storage: {
|
||||||
|
get: (key, fallback = null) => (Object.prototype.hasOwnProperty.call(store, key) && store[key] != null ? plain(store[key]) : fallback),
|
||||||
|
set: (key, value) => {
|
||||||
|
const k = String(key);
|
||||||
|
if (value === null || value === undefined) delete store[k]; else store[k] = plain(value);
|
||||||
|
send({ t: "storage.set", key: k, value: value === undefined ? null : plain(value) });
|
||||||
|
},
|
||||||
|
all: () => plain(store),
|
||||||
|
},
|
||||||
|
onMessage: (name, fn) => {
|
||||||
|
if (typeof name !== "string" || !name || typeof fn !== "function") throw new Error("onMessage needs (name, fn)");
|
||||||
|
handlers.set(name, fn);
|
||||||
|
send({ t: "handler", name });
|
||||||
|
},
|
||||||
|
emit: (msg, payload) => { hostCall("emit", [String(msg), payload]).catch(() => {}); },
|
||||||
|
registerSidebarPanel: (spec) => { hostCall("registerSidebarPanel", [spec]).catch((e) => send({ t: "log", args: ["registerSidebarPanel: " + e.message] })); },
|
||||||
|
revealSidebar: (panelId) => { hostCall("revealSidebar", [panelId]).catch(() => {}); },
|
||||||
|
openTab: call("openTab"),
|
||||||
|
openSettings: call("openSettings"),
|
||||||
|
setSessionProxy: call("setSessionProxy"),
|
||||||
|
captureTab: call("captureTab"),
|
||||||
|
saveCapture: call("saveCapture"),
|
||||||
|
scanActiveTabForUris: call("scanActiveTabForUris"),
|
||||||
|
approvalModal: call("approvalModal"),
|
||||||
|
checkAndStageSelfUpdate: call("checkAndStageSelfUpdate"),
|
||||||
|
applySelfUpdate: call("applySelfUpdate"),
|
||||||
|
restartApp: call("restartApp"),
|
||||||
|
startAtLaunch: (on) => { hostCall("startAtLaunch", [!!on]).catch(() => {}); },
|
||||||
|
whenUiReady: call("whenUiReady"),
|
||||||
|
tabs: {
|
||||||
|
active: call("tabs.active"),
|
||||||
|
onChange: (cb) => {
|
||||||
|
if (typeof cb !== "function") return () => {};
|
||||||
|
tabListeners.add(cb);
|
||||||
|
send({ t: "tabs.watch" });
|
||||||
|
return () => tabListeners.delete(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
derive: call("vault.derive"),
|
||||||
|
requestUnlock: call("vault.requestUnlock"),
|
||||||
|
lifecycle: {
|
||||||
|
status: call("vault.lifecycle.status"),
|
||||||
|
unlock: builtInOnly("vault.lifecycle.unlock"),
|
||||||
|
setup: builtInOnly("vault.lifecycle.setup"),
|
||||||
|
lock: builtInOnly("vault.lifecycle.lock"),
|
||||||
|
},
|
||||||
|
imports: { list: builtInOnly("vault.imports"), add: builtInOnly("vault.imports"), remove: builtInOnly("vault.imports"), signer: builtInOnly("vault.imports") },
|
||||||
|
pin: { status: builtInOnly("vault.pin"), unlock: builtInOnly("vault.pin"), set: builtInOnly("vault.pin"), clear: builtInOnly("vault.pin") },
|
||||||
|
},
|
||||||
|
registerRequestFilter: notOffered("registerRequestFilter", "the host would have to call into the extension synchronously for every request"),
|
||||||
|
registerSiteRoute: notOffered("registerSiteRoute", "site routes are for built-in add-ons"),
|
||||||
|
require: notOffered("api.require", "bundle the modules you need inside the extension folder"),
|
||||||
|
import: notOffered("api.import", "bundle the modules you need inside the extension folder"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("message", async (raw) => {
|
||||||
|
if (!raw || typeof raw !== "object") return;
|
||||||
|
const m = wireDec(raw);
|
||||||
|
if (m.t === "res") {
|
||||||
|
const p = pending.get(m.id);
|
||||||
|
if (!p) return;
|
||||||
|
pending.delete(m.id);
|
||||||
|
if (m.ok) p.resolve(m.value);
|
||||||
|
else { const e = new Error(m.error && m.error.message || "host call failed"); if (m.error && m.error.code != null) e.code = m.error.code; p.reject(e); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.t === "activate") {
|
||||||
|
try {
|
||||||
|
mod = require(m.mainPath);
|
||||||
|
if (!mod || typeof mod.activate !== "function") throw new Error("main file must export an activate(api) function");
|
||||||
|
await mod.activate(makeApi(m));
|
||||||
|
send({ t: "activated", ok: true });
|
||||||
|
} catch (e) { send({ t: "activated", ok: false, error: errOut(e) }); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.t === "call") {
|
||||||
|
const fn = handlers.get(m.name);
|
||||||
|
if (!fn) { send({ t: "res", id: m.id, ok: false, error: { message: `no handler for "${m.name}"` } }); return; }
|
||||||
|
try {
|
||||||
|
const value = await callScope.run({ callId: m.id }, () => fn(m.payload, m.ctx || {}));
|
||||||
|
send({ t: "res", id: m.id, ok: true, value: value === undefined ? null : value });
|
||||||
|
} catch (e) { send({ t: "res", id: m.id, ok: false, error: errOut(e) }); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.t === "tabs") { for (const cb of tabListeners) { try { cb(m.data); } catch {} } return; }
|
||||||
|
if (m.t === "deactivate") {
|
||||||
|
try { if (mod && typeof mod.deactivate === "function") await mod.deactivate(); } catch {}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// An extension's stray rejection or exception must not take its process down
|
||||||
|
// silently — say so in the host log and keep serving.
|
||||||
|
process.on("uncaughtException", (e) => send({ t: "log", args: ["uncaught exception: " + ((e && e.stack) || e)] }));
|
||||||
|
process.on("unhandledRejection", (e) => send({ t: "log", args: ["unhandled rejection: " + ((e && e.message) || e)] }));
|
||||||
|
process.on("disconnect", () => process.exit(0));
|
||||||
|
send({ t: "ready" });
|
||||||
Loading…
Add table
Reference in a new issue