Theseus: add-on framework MVP + Notepad reference add-on

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).
This commit is contained in:
Local Dev 2026-08-31 13:51:08 +02:00
parent e43e009f73
commit 0117986657
11 changed files with 612 additions and 2 deletions

193
addons-host.js Normal file
View file

@ -0,0 +1,193 @@
// 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 };

View file

@ -0,0 +1,10 @@
{
"id": "notepad",
"name": "Notepad",
"version": "0.1.0",
"description": "Right-sidebar notes. Text lives on this machine only, autosaves as you type.",
"author": "Silent Mode",
"icon": "📝",
"main": "index.js",
"capabilities": ["sidebar-panel"]
}

View file

@ -0,0 +1,13 @@
// Notepad — reference add-on. Registers one sidebar panel; the panel's
// HTML does all the actual work via window.silentmode.storage.
module.exports = {
activate(api) {
api.registerSidebarPanel({
id: "main",
title: "Notepad",
icon: "📝",
page: "note.html",
});
api.log("registered notepad panel");
},
};

View file

@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notepad</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); }
header .t { font-weight: 600; letter-spacing: .2px; display: flex; gap: 8px; align-items: center; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; font-variant-numeric: tabular-nums; }
textarea {
flex: 1; width: 100%; padding: 14px 16px; border: none; outline: none; resize: none;
background: transparent; color: var(--ink);
font: 13.5px/1.65 ui-monospace, "Cascadia Code", Consolas, monospace;
}
textarea::placeholder { color: var(--dim); }
footer { padding: 6px 14px 10px; border-top: 1px solid var(--line);
color: var(--dim); font-size: 11px; display: flex; justify-content: space-between; }
footer .l { display: flex; gap: 10px; }
footer .l b { color: var(--mut); font-weight: 500; }
footer button { border: none; background: transparent; color: var(--mut);
cursor: pointer; font: inherit; padding: 0; }
footer button:hover { color: var(--acid); }
</style>
</head>
<body>
<header>
<div class="t"><span class="em">📝</span> <span>Notepad</span></div>
<div class="m" id="status">saved</div>
</header>
<textarea id="pad" placeholder="Notes. Autosaves as you type."
spellcheck="false" autofocus></textarea>
<footer>
<div class="l"><b><span id="cchars">0</span></b> chars · <b><span id="cwords">0</span></b> words</div>
<button id="clear" title="Clear all notes">clear</button>
</footer>
<script>
const pad = document.getElementById("pad");
const status = document.getElementById("status");
const cchars = document.getElementById("cchars");
const cwords = document.getElementById("cwords");
function updateCounts() {
const v = pad.value;
cchars.textContent = v.length;
cwords.textContent = v.trim() ? v.trim().split(/\s+/).length : 0;
}
// Load previous text on open.
(async () => {
try {
const text = await window.silentmode.storage.get("text", "");
pad.value = text || "";
updateCounts();
} catch (e) { console.warn("load failed:", e); }
})();
// Debounced autosave.
let saveTimer = null;
pad.addEventListener("input", () => {
updateCounts();
status.textContent = "saving…";
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await window.silentmode.storage.set("text", pad.value);
status.textContent = "saved";
} catch (e) {
status.textContent = "save failed";
console.warn("save failed:", e);
}
}, 400);
});
document.getElementById("clear").addEventListener("click", async () => {
if (!pad.value) return;
if (!confirm("Clear all notes?")) return;
pad.value = "";
updateCounts();
try { await window.silentmode.storage.set("text", ""); status.textContent = "saved"; } catch {}
pad.focus();
});
</script>
</body>
</html>

View file

@ -42,6 +42,7 @@
color: var(--mut); background: transparent; border: none; padding: 0; } color: var(--mut); background: transparent; border: none; padding: 0; }
.ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } .ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ic:hover { background: var(--line2); color: var(--ink); } .ic:hover { background: var(--line2); color: var(--ink); }
.ic.active { background: rgba(214,255,61,.12); color: var(--acid); }
.ic:active { background: var(--line); } .ic:active { background: var(--line); }
.ic:disabled { opacity: .28; cursor: default; background: transparent; } .ic:disabled { opacity: .28; cursor: default; background: transparent; }
/* downloads button — a small badge sits over the icon when there's activity */ /* downloads button — a small badge sits over the icon when there's activity */
@ -201,6 +202,9 @@
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg> <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg>
<span class="dlbadge" id="dlbadge" hidden>0</span> <span class="dlbadge" id="dlbadge" hidden>0</span>
</button> </button>
<button class="ic" id="sidebarbtn" title="Toggle add-on sidebar" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M10 3 L10 13"/></svg>
</button>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span> <span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button> <button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button> <button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button>
@ -313,6 +317,21 @@
T.getDownloads && T.getDownloads().then(renderDownloads); T.getDownloads && T.getDownloads().then(renderDownloads);
T.onDownloads && T.onDownloads(renderDownloads); T.onDownloads && T.onDownloads(renderDownloads);
// ---- add-on sidebar toggle ----
// Only surfaces the button when at least one add-on has registered a
// sidebar panel; a fresh install with no add-ons keeps the toolbar clean.
const sideBtn = $("sidebarbtn");
function renderSidebarState(d) {
if (!d) return;
const has = Array.isArray(d.panels) && d.panels.length > 0;
sideBtn.hidden = !has;
sideBtn.classList.toggle("active", !!d.visible);
if (has && d.panels[0]) sideBtn.title = "Toggle " + d.panels[0].title;
}
sideBtn.onclick = () => T.toggleSidebar && T.toggleSidebar();
T.sidebarState && T.sidebarState().then(renderSidebarState);
T.onSidebarState && T.onSidebarState(renderSidebarState);
// ---- favorites bar (new-tab page only) ---- // ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "" }, bookmarks = []; let current = { url: "", title: "" }, bookmarks = [];
function renderBookmarks() { function renderBookmarks() {

188
main.js
View file

@ -214,6 +214,9 @@ const SETTINGS_DEFAULTS = {
// "icann-first" — ICANN wins collisions; BCNR fills gaps. // "icann-first" — ICANN wins collisions; BCNR fills gaps.
// "soft" — "Open with…" prompt on collision, remembered per name/TLD. // "soft" — "Open with…" prompt on collision, remembered per name/TLD.
collisionPolicy: "bcnr-first", collisionPolicy: "bcnr-first",
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons: [],
}; };
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update // Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC. // in every renderer (chrome, settings, popover, page views) with no per-view IPC.
@ -1132,6 +1135,67 @@ const PWF_W = 280; let pwfH = 80;
// tab; the pill auto-sizes to its text. // tab; the pill auto-sizes to its text.
let linkStatus, linkStatusVisible = false; let linkStatus, linkStatusVisible = false;
let linkStatusW = 100, linkStatusH = 22; let linkStatusW = 100, linkStatusH = 22;
// Add-on sidebar — one right-anchored WebContentsView that hosts an add-on's
// registered panel HTML. First registered panel wins for the MVP; a tab
// strip / picker for multiple panels lands in a later rev. Sidebar loads
// nothing until the user actively opens it, so the perf cost of an unused
// add-on is nil.
let sidebar, sidebarVisible = false, sidebarActivePanelId = null;
const SIDEBAR_W = 340;
// The add-on host is the single point of truth for what's installed and
// active. Populated by initAddons() at app-ready time.
let addonHost = null;
const { AddonHost } = require("./addons-host.js");
function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); }
function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); }
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
// addons directory the first time we see them missing. Users can then edit,
// disable, or delete them — the framework treats bundled and user add-ons
// identically, no special path handling.
function seedBundledAddons() {
const dst = addonsUserDir();
try { fs.mkdirSync(dst, { recursive: true }); } catch {}
const src = bundledAddonsDir();
if (!fs.existsSync(src)) return;
let entries = [];
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const target = path.join(dst, e.name);
if (fs.existsSync(target)) continue; // never overwrite user copies
try { fs.cpSync(path.join(src, e.name), target, { recursive: true }); }
catch (err) { console.warn(`[addons] seed ${e.name} failed:`, err?.message); }
}
}
function initAddons() {
seedBundledAddons();
addonHost = new AddonHost({
addonsDir: addonsUserDir(),
dataDir: addonsDataDir(),
isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id),
logger: (...a) => console.log("[addons]", ...a),
});
addonHost.discoverAndActivate();
const snap = addonHost.snapshot();
console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled, ${snap.sidebarPanels.length} sidebar panels`);
}
// Given a webContents sender URL, work out which add-on folder it lives in.
// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch
// its own store.
function addonIdForSender(sender) {
try {
const u = new URL(sender.getURL());
if (u.protocol !== "file:") return null;
const filePath = decodeURIComponent(u.pathname).replace(/^\/+/, "");
const norm = filePath.replace(/\\/g, "/");
const dirNorm = addonsUserDir().replace(/\\/g, "/").replace(/\/+$/, "");
if (!norm.toLowerCase().startsWith(dirNorm.toLowerCase() + "/")) return null;
const rest = norm.slice(dirNorm.length + 1);
const first = rest.split("/")[0];
return first || null;
} catch { return null; }
}
// In-memory download list. Not persisted: closing the browser clears history // In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped). // (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
@ -1161,7 +1225,12 @@ function layout() {
const { width, height } = win.getContentBounds(); const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H }); chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H); const bodyH = Math.max(0, height - CHROME_H);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH }); // Sidebar (when visible) claims a fixed slice on the right; the tab views
// shrink to fit alongside it. When hidden, tabs get the full width.
const sideW = sidebarVisible ? SIDEBAR_W : 0;
const tabW = Math.max(0, width - sideW);
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 });
positionPopover(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
@ -1250,6 +1319,38 @@ function showLinkStatus(url) {
linkStatus.setVisible(true); linkStatusVisible = true; linkStatus.setVisible(true); linkStatusVisible = true;
try { linkStatus.webContents.send("link-status-url", s); } catch {} try { linkStatus.webContents.send("link-status-url", s); } catch {}
} }
// Open (or close) the sidebar. Loading the panel HTML is lazy — the first
// open triggers loadFile; subsequent opens just flip visibility.
function toggleSidebar() { setSidebar(!sidebarVisible); }
function setSidebar(show, panelId) {
if (!sidebar) return;
const panels = addonHost ? addonHost.getSidebarPanels() : [];
if (show && panels.length === 0) {
// No add-on offers a sidebar panel — silently ignore. Settings surfaces
// the "install one" path.
return;
}
if (show) {
const wantId = panelId || sidebarActivePanelId || panels[0].panelId;
const panel = panels.find((p) => p.panelId === wantId) || panels[0];
if (sidebarActivePanelId !== panel.panelId) {
sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
}
sidebarVisible = true;
sidebar.setVisible(true);
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
layout();
try { sidebar.webContents.send("sidebar-visibility", true); } catch {}
try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels }); } catch {}
} else {
sidebarVisible = false;
sidebar.setVisible(false);
layout();
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels }); } catch {}
}
}
function showPwFill(show, matches) { function showPwFill(show, matches) {
if (!pwFillPop) return; if (!pwFillPop) return;
if (show) { if (show) {
@ -1673,6 +1774,10 @@ function createWindow() {
win.contentView.addChildView(linkStatus); win.contentView.addChildView(linkStatus);
linkStatus.webContents.loadFile("link-status.html"); linkStatus.webContents.loadFile("link-status.html");
linkStatus.setVisible(false); linkStatus.setVisible(false);
// Add-on sidebar host. Doesn't loadFile until an add-on panel is opened.
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
win.contentView.addChildView(sidebar);
sidebar.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();
@ -1821,6 +1926,86 @@ ipcMain.handle("move-tab", (_e, id, targetId, place) => {
emitTabs(); emitTabs();
}); });
ipcMain.handle("go-home", () => loadHome(activeId)); ipcMain.handle("go-home", () => loadHome(activeId));
// --- Add-on framework -------------------------------------------------------
// Sidebar toggle + panel switching, driven from the chrome toolbar. `panelId`
// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no
// coercion, main matches it verbatim.
ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible; });
ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; });
ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; });
ipcMain.handle("sidebar-state", () => ({
visible: sidebarVisible,
active: sidebarActivePanelId,
panels: addonHost ? addonHost.getSidebarPanels() : [],
}));
// Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
// add-ons activate immediately and newly-disabled ones drop out — no
// restart required.
ipcMain.handle("addons-set-enabled", (_e, id, enabled) => {
if (!id || typeof id !== "string") return false;
const disabled = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []);
if (enabled) disabled.delete(id); else disabled.add(id);
settings.disabledAddons = [...disabled];
saveSettings();
// Rebuild the host so state matches settings.
if (addonHost) addonHost.discoverAndActivate();
// Sidebar may need to close if its current panel came from an add-on we
// just disabled.
const panels = addonHost ? addonHost.getSidebarPanels() : [];
if (sidebarVisible && sidebarActivePanelId && !panels.find((p) => p.panelId === sidebarActivePanelId)) {
sidebarActivePanelId = null;
setSidebar(false);
}
return true;
});
// Reveal an add-on's folder in the OS file manager — the primary way users
// edit / uninstall add-ons.
ipcMain.handle("addons-reveal", (_e, folder) => {
if (typeof folder !== "string" || !folder) return false;
const norm = path.normalize(folder);
const base = addonsUserDir();
if (!norm.toLowerCase().startsWith(base.toLowerCase())) return false; // don't leak arbitrary paths
try { shell.showItemInFolder(norm); return true; } catch { return false; }
});
ipcMain.handle("addons-open-dir", () => {
try { shell.openPath(addonsUserDir()); return true; } catch { return false; }
});
ipcMain.handle("addons-reload", () => {
if (!addonHost) return false;
addonHost.discoverAndActivate();
return true;
});
// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------
// 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
// touch its own store; any file:// outside addons/ returns nothing.
ipcMain.handle("addon-storage-get", (e, key, fallback) => {
const id = addonIdForSender(e.sender);
if (!id) return fallback ?? null;
try {
const raw = JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8"));
return key in raw ? raw[key] : (fallback ?? null);
} catch { return fallback ?? null; }
});
ipcMain.handle("addon-storage-set", (e, key, value) => {
const id = addonIdForSender(e.sender);
if (!id) return false;
if (typeof key !== "string" || key.length > 128) return false;
const file = path.join(addonsDataDir(), id + ".json");
let store = {};
try { store = JSON.parse(fs.readFileSync(file, "utf8")); } catch {}
store[key] = value;
try { fs.mkdirSync(addonsDataDir(), { recursive: true }); fs.writeFileSync(file, JSON.stringify(store)); return true; }
catch (err) { console.warn(`[addons] storage.set failed for ${id}:`, err?.message); return false; }
});
ipcMain.handle("addon-storage-all", (e) => {
const id = addonIdForSender(e.sender);
if (!id) return {};
try { return JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8")); }
catch { return {}; }
});
// Error-page actions. All origin-gated to error.html so a third-party page // Error-page actions. All origin-gated to error.html so a third-party page
// that happens to see the API shape (home-preload exposes it on every tab) // that happens to see the API shape (home-preload exposes it on every tab)
// can't drive them. // can't drive them.
@ -2723,6 +2908,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
} 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();
initAddons();
createWindow(); createWindow();
// Multi-source BNS warm-up so the first .bch page opens near-instantly and // Multi-source BNS warm-up so the first .bch page opens near-instantly and
// stays fresh for as long as the browser is running. Every source runs in // stays fresh for as long as the browser is running. Every source runs in

View file

@ -49,6 +49,8 @@
"home-preload.js", "home-preload.js",
"error.html", "error.html",
"error-preload.js", "error-preload.js",
"sidebar-preload.js",
"addons-host.js",
"link-status.html", "link-status.html",
"link-status-preload.js", "link-status-preload.js",
"collision.html", "collision.html",
@ -91,6 +93,10 @@
{ {
"from": "build/AriadneResolver-Setup-0.1.0.exe", "from": "build/AriadneResolver-Setup-0.1.0.exe",
"to": "AriadneResolver-Setup-0.1.0.exe" "to": "AriadneResolver-Setup-0.1.0.exe"
},
{
"from": "bundled-addons",
"to": "bundled-addons"
} }
], ],
"win": { "win": {

View file

@ -50,4 +50,8 @@ contextBridge.exposeInMainWorld("theseus", {
getDownloads: () => ipcRenderer.invoke("downloads-get"), getDownloads: () => ipcRenderer.invoke("downloads-get"),
toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect), toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect),
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)), onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
// Add-on sidebar toggle in the toolbar.
toggleSidebar: () => ipcRenderer.invoke("sidebar-toggle"),
sidebarState: () => ipcRenderer.invoke("sidebar-state"),
onSidebarState: (cb) => ipcRenderer.on("sidebar-state", (_e, d) => cb(d)),
}); });

View file

@ -31,4 +31,10 @@ contextBridge.exposeInMainWorld("cfg", {
collisionState: () => ipcRenderer.invoke("collision-state"), collisionState: () => ipcRenderer.invoke("collision-state"),
setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p), setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p),
resetCollisions: () => ipcRenderer.invoke("collision-reset"), resetCollisions: () => ipcRenderer.invoke("collision-reset"),
// Add-ons management (Settings > Add-ons tab).
listAddons: () => ipcRenderer.invoke("addons-list"),
setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled),
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
}); });

View file

@ -148,6 +148,7 @@
<a data-sec="naming">Registries</a> <a data-sec="naming">Registries</a>
<a data-sec="performance">Performance</a> <a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a> <a data-sec="privacy">Privacy</a>
<a data-sec="addons">Add-ons</a>
</nav> </nav>
<div class="content"> <div class="content">
<!-- GENERAL --> <!-- GENERAL -->
@ -496,12 +497,29 @@
</div> </div>
<div class="note">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</div> <div class="note">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</div>
</section> </section>
<!-- ADD-ONS -->
<section id="addons" hidden>
<h1>Add-ons</h1>
<p class="lede">Small modules that add capabilities to Theseus. Add-ons live as folders under
<code style="background:transparent;border:none;padding:0" id="addonsPathHint">%APPDATA%\Theseus Navigator\addons\</code>. Drop a folder in, restart, it's live.
Bundled reference add-ons (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.</p>
<div class="row" style="justify-content:flex-end;gap:8px">
<button id="addonsReload" class="btn" type="button">Reload</button>
<button id="addonsOpenDir" class="btn" type="button">Open add-ons folder</button>
</div>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2>
<div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div>
<div class="note">Add-ons run with full app access — treat installing one like installing an unsigned executable.
Only load add-ons whose source you trust.</div>
</section>
</div> </div>
</div> </div>
<script> <script>
const C = window.cfg; const C = window.cfg;
// sidebar navigation // sidebar navigation
const sections = ["general", "search", "passwords", "naming", "performance", "privacy"]; const sections = ["general", "search", "passwords", "naming", "performance", "privacy", "addons"];
function showSection(sec) { function showSection(sec) {
if (!sections.includes(sec)) return; if (!sections.includes(sec)) return;
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec)); document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
@ -961,6 +979,45 @@
pwRefresh(); pwRefresh();
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh); document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
}); });
// ---- Add-ons management ----
const addonsList = document.getElementById("addonsList");
function renderAddons(snap) {
const items = (snap && snap.installed) || [];
if (!items.length) {
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No add-ons installed. Drop a folder into the add-ons directory to install one.</div>';
return;
}
addonsList.innerHTML = items.map((a) => {
if (a.error) {
return '<div class="row"><div class="txt"><div class="t">⚠ Load failed <span style="color:var(--dim);font-weight:400">' + escapeHtml(a.folder) + '</span></div><div class="d" style="color:#f6768a">' + escapeHtml(a.error) + '</div></div><div><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button></div></div>';
}
const caps = (a.capabilities || []).length ? '<span style="color:var(--dim);font-size:11.5px;margin-left:8px">' + a.capabilities.map(escapeHtml).join(", ") + '</span>' : "";
return '<div class="row"><div class="txt"><div class="t">' + a.icon + ' ' + escapeHtml(a.name) + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div></div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>';
}).join("");
addonsList.querySelectorAll('input[data-toggle]').forEach((cb) => {
cb.addEventListener("change", async () => {
await C.setAddonEnabled(cb.dataset.toggle, cb.checked);
loadAddons();
});
});
addonsList.querySelectorAll('button[data-reveal]').forEach((btn) => {
btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal));
});
}
function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); }
function escapeAttr(s) { return escapeHtml(s); }
async function loadAddons() {
try { renderAddons(await C.listAddons()); }
catch (e) { addonsList.textContent = "Failed to load add-ons: " + (e?.message || e); }
}
document.getElementById("addonsReload").addEventListener("click", async () => {
await C.reloadAddons(); loadAddons();
});
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", loadAddons);
// Populate on first paint so the tab is ready when the user clicks in.
loadAddons();
</script> </script>
</body> </body>
</html> </html>

16
sidebar-preload.js Normal file
View file

@ -0,0 +1,16 @@
// Preload shared by every add-on sidebar panel. Exposes a small, safe
// surface to the add-on's HTML. Main-side handlers derive the add-on
// identity from the sender's URL (the panel is always loaded from
// somewhere inside <userData>/addons/<id>/), so a random page that
// happens to see the API shape can't touch another add-on's storage.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("silentmode", {
storage: {
get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback),
set: (key, value) => ipcRenderer.invoke("addon-storage-set", key, value),
all: () => ipcRenderer.invoke("addon-storage-all"),
},
// Ask main which panel is currently visible — panels may want to
// suspend expensive work when hidden.
onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)),
});