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:
parent
e43e009f73
commit
0117986657
11 changed files with 612 additions and 2 deletions
193
addons-host.js
Normal file
193
addons-host.js
Normal 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 };
|
||||||
10
bundled-addons/notepad/addon.json
Normal file
10
bundled-addons/notepad/addon.json
Normal 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"]
|
||||||
|
}
|
||||||
13
bundled-addons/notepad/index.js
Normal file
13
bundled-addons/notepad/index.js
Normal 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");
|
||||||
|
},
|
||||||
|
};
|
||||||
100
bundled-addons/notepad/note.html
Normal file
100
bundled-addons/notepad/note.html
Normal 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>
|
||||||
19
chrome.html
19
chrome.html
|
|
@ -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
188
main.js
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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
16
sidebar-preload.js
Normal 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)),
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue