From 0117986657249e7c9db4bc25e93f5b642f7d497f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 31 Aug 2026 13:51:08 +0200 Subject: [PATCH] Theseus: add-on framework MVP + Notepad reference add-on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New subsystem for extending Theseus with folders on disk. Each add-on lives at /addons// 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 /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 /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). --- addons-host.js | 193 ++++++++++++++++++++++++++++++ bundled-addons/notepad/addon.json | 10 ++ bundled-addons/notepad/index.js | 13 ++ bundled-addons/notepad/note.html | 100 ++++++++++++++++ chrome.html | 19 +++ main.js | 188 ++++++++++++++++++++++++++++- package.json | 6 + preload.js | 4 + settings-preload.js | 6 + settings.html | 59 ++++++++- sidebar-preload.js | 16 +++ 11 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 addons-host.js create mode 100644 bundled-addons/notepad/addon.json create mode 100644 bundled-addons/notepad/index.js create mode 100644 bundled-addons/notepad/note.html create mode 100644 sidebar-preload.js diff --git a/addons-host.js b/addons-host.js new file mode 100644 index 0000000..b5e3bfb --- /dev/null +++ b/addons-host.js @@ -0,0 +1,193 @@ +// Theseus add-on framework — loader + API surface. +// +// Add-ons live in /addons// 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 +// /addons-data/.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 }; diff --git a/bundled-addons/notepad/addon.json b/bundled-addons/notepad/addon.json new file mode 100644 index 0000000..48f99ee --- /dev/null +++ b/bundled-addons/notepad/addon.json @@ -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"] +} diff --git a/bundled-addons/notepad/index.js b/bundled-addons/notepad/index.js new file mode 100644 index 0000000..270ef87 --- /dev/null +++ b/bundled-addons/notepad/index.js @@ -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"); + }, +}; diff --git a/bundled-addons/notepad/note.html b/bundled-addons/notepad/note.html new file mode 100644 index 0000000..d4f164c --- /dev/null +++ b/bundled-addons/notepad/note.html @@ -0,0 +1,100 @@ + + + + +Notepad + + + +
+
📝 Notepad
+
saved
+
+ +
+
0 chars · 0 words
+ +
+ + + + diff --git a/chrome.html b/chrome.html index d477449..7957af3 100644 --- a/chrome.html +++ b/chrome.html @@ -42,6 +42,7 @@ 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:hover { background: var(--line2); color: var(--ink); } + .ic.active { background: rgba(214,255,61,.12); color: var(--acid); } .ic:active { background: var(--line); } .ic:disabled { opacity: .28; cursor: default; background: transparent; } /* downloads button — a small badge sits over the icon when there's activity */ @@ -201,6 +202,9 @@ + @@ -313,6 +317,21 @@ T.getDownloads && T.getDownloads().then(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) ---- let current = { url: "", title: "" }, bookmarks = []; function renderBookmarks() { diff --git a/main.js b/main.js index e39c852..e3dc7f3 100644 --- a/main.js +++ b/main.js @@ -214,6 +214,9 @@ const SETTINGS_DEFAULTS = { // "icann-first" — ICANN wins collisions; BCNR fills gaps. // "soft" — "Open with…" prompt on collision, remembered per name/TLD. 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 // 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. let linkStatus, linkStatusVisible = false; 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// 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 // (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 @@ -1161,7 +1225,12 @@ function layout() { const { width, height } = win.getContentBounds(); chrome.setBounds({ x: 0, y: 0, width, 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(); positionEnginePicker(); positionDownloads(); @@ -1250,6 +1319,38 @@ function showLinkStatus(url) { linkStatus.setVisible(true); linkStatusVisible = true; 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) { if (!pwFillPop) return; if (show) { @@ -1673,6 +1774,10 @@ function createWindow() { win.contentView.addChildView(linkStatus); linkStatus.webContents.loadFile("link-status.html"); 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", () => { const saved = settings.restoreSession ? loadSession() : []; if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); @@ -1821,6 +1926,86 @@ ipcMain.handle("move-tab", (_e, id, targetId, place) => { emitTabs(); }); 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 (`:`) — 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 /addons//...) ------------ +// 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 // that happens to see the API shape (home-preload exposes it on every tab) // can't drive them. @@ -2723,6 +2908,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) { } catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); } protocol.handle("bns", serveBns); installDownloadTracker(); + initAddons(); createWindow(); // 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 diff --git a/package.json b/package.json index 584a49a..3085712 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "home-preload.js", "error.html", "error-preload.js", + "sidebar-preload.js", + "addons-host.js", "link-status.html", "link-status-preload.js", "collision.html", @@ -91,6 +93,10 @@ { "from": "build/AriadneResolver-Setup-0.1.0.exe", "to": "AriadneResolver-Setup-0.1.0.exe" + }, + { + "from": "bundled-addons", + "to": "bundled-addons" } ], "win": { diff --git a/preload.js b/preload.js index f024d3d..770af42 100644 --- a/preload.js +++ b/preload.js @@ -50,4 +50,8 @@ contextBridge.exposeInMainWorld("theseus", { getDownloads: () => ipcRenderer.invoke("downloads-get"), toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect), 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)), }); diff --git a/settings-preload.js b/settings-preload.js index bb8f202..9ff3787 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -31,4 +31,10 @@ contextBridge.exposeInMainWorld("cfg", { collisionState: () => ipcRenderer.invoke("collision-state"), setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p), 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"), }); diff --git a/settings.html b/settings.html index b992ab3..3ca415c 100644 --- a/settings.html +++ b/settings.html @@ -148,6 +148,7 @@ Registries Performance Privacy + Add-ons
@@ -496,12 +497,29 @@
There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).
+ + + diff --git a/sidebar-preload.js b/sidebar-preload.js new file mode 100644 index 0000000..a69c346 --- /dev/null +++ b/sidebar-preload.js @@ -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 /addons//), 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)), +});