New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
193 lines
8 KiB
JavaScript
193 lines
8 KiB
JavaScript
// Theseus add-on framework — loader + API surface.
|
|
//
|
|
// Add-ons live in <userData>/addons/<id>/ as ordinary folders on disk. Each
|
|
// carries an `addon.json` manifest and (per the manifest's `main` field) a
|
|
// CommonJS entry that exports `activate(api)` and optionally `deactivate()`.
|
|
// Nothing about an add-on ships in the Theseus repo or installer — drop a
|
|
// folder, restart Theseus, it's live. This is the same trust model as
|
|
// dev-mode browser extensions: the user is choosing to run local code with
|
|
// the app's full privileges.
|
|
//
|
|
// Loading is synchronous at app-ready time; there is no hot-reload. Failed
|
|
// activations are logged and skipped without breaking the app.
|
|
//
|
|
// Persistence:
|
|
// settings.disabledAddons — ids the user has toggled off
|
|
// <userData>/addons-data/<id>.json — per-add-on kv store (api.storage)
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
// Extension points the framework understands. Extending this list means also
|
|
// teaching main.js and (typically) the chrome renderer about the new point.
|
|
// Right now only sidebar panels are wired — future rev adds toolbar-chip,
|
|
// proxy, page-inject, etc.
|
|
const KNOWN_CAPABILITIES = new Set(["sidebar-panel"]);
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
return { id, name, version, description, author, icon, main, capabilities };
|
|
}
|
|
|
|
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
|
// of the app queries via `getActive()` / `getInstalled()`.
|
|
class AddonHost {
|
|
constructor({ addonsDir, dataDir, isDisabled, logger }) {
|
|
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: [...] }
|
|
}
|
|
|
|
ensureDirs() {
|
|
for (const d of [this.addonsDir, this.dataDir]) {
|
|
try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); }
|
|
}
|
|
}
|
|
|
|
discoverAndActivate() {
|
|
this.ensureDirs();
|
|
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}`);
|
|
this._installed.push({ manifest: null, folder, error: String(e?.message || e) });
|
|
}
|
|
}
|
|
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: [] };
|
|
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}`);
|
|
}
|
|
|
|
_makeApi(active) {
|
|
const { manifest, folder } = active;
|
|
const storageFile = path.join(this.dataDir, `${manifest.id}.json`);
|
|
return {
|
|
// Metadata the add-on may want to reflect on
|
|
id: manifest.id,
|
|
folder,
|
|
log: (...a) => this.log(`[${manifest.id}]`, ...a),
|
|
// Persistent per-add-on storage. Small kv JSON on disk.
|
|
storage: {
|
|
get: (key, fallback = null) => {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(storageFile, "utf8"));
|
|
return key in raw ? raw[key] : fallback;
|
|
} catch { return fallback; }
|
|
},
|
|
set: (key, value) => {
|
|
let store = {};
|
|
try { store = JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch {}
|
|
store[key] = value;
|
|
try { fs.writeFileSync(storageFile, JSON.stringify(store)); } catch (e) { this.log(`[${manifest.id}] storage.set failed:`, e?.message); }
|
|
},
|
|
all: () => { try { return JSON.parse(fs.readFileSync(storageFile, "utf8")); } catch { return {}; } },
|
|
},
|
|
// Register a sidebar panel — a right-side WebContentsView that hosts
|
|
// one of the add-on's HTML pages. `page` is a path RELATIVE to the
|
|
// add-on folder. `title` shows in the sidebar tab strip. `icon` is
|
|
// a short emoji/glyph.
|
|
registerSidebarPanel: ({ id, title, icon = manifest.icon, page }) => {
|
|
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
|
|
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));
|
|
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
|
|
// Namespaced id so two add-ons can't collide.
|
|
const panelId = `${manifest.id}:${id}`;
|
|
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
|
|
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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 ?? [],
|
|
folder,
|
|
enabled: manifest?.id ? this._active.has(manifest.id) : false,
|
|
error: error || null,
|
|
})),
|
|
sidebarPanels: this.getSidebarPanels(),
|
|
};
|
|
}
|
|
getSidebarPanels() {
|
|
const out = [];
|
|
for (const active of this._active.values()) out.push(...active.sidebarPanels);
|
|
return out;
|
|
}
|
|
getInstalled() { return this._installed.slice(); }
|
|
isActive(id) { return this._active.has(id); }
|
|
}
|
|
|
|
module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest };
|