182 lines
9.3 KiB
JavaScript
182 lines
9.3 KiB
JavaScript
|
|
// 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" });
|