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).
2026-08-31 13:51:08 +02:00
|
|
|
// 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)),
|
|
|
|
|
});
|
2026-08-31 16:00:34 +02:00
|
|
|
|
2026-08-31 16:32:16 +02:00
|
|
|
// -- Panel picker strip -----------------------------------------------------
|
|
|
|
|
// Injected at the top of every panel document so the user can switch between
|
|
|
|
|
// registered extension panels without a settings round-trip. Only appears
|
|
|
|
|
// when 2+ panels exist. Main pushes the panel list here via IPC on the
|
|
|
|
|
// initial sidebar-state event.
|
|
|
|
|
async function installPickerStrip() {
|
|
|
|
|
let state;
|
|
|
|
|
try { state = await ipcRenderer.invoke("sidebar-state"); } catch { return; }
|
|
|
|
|
const panels = (state && state.panels) || [];
|
|
|
|
|
if (panels.length < 2) return;
|
|
|
|
|
const strip = document.createElement("div");
|
|
|
|
|
strip.setAttribute("aria-label", "Extension panels");
|
|
|
|
|
strip.style.cssText = [
|
|
|
|
|
"position:fixed", "top:0", "left:5px", "right:0", "height:32px",
|
|
|
|
|
"display:flex", "align-items:stretch", "gap:1px",
|
|
|
|
|
"background:rgba(11,14,20,.85)", "backdrop-filter:blur(8px)",
|
|
|
|
|
"border-bottom:1px solid rgba(255,255,255,.10)",
|
|
|
|
|
"z-index:2147483646", "user-select:none",
|
|
|
|
|
"font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif",
|
|
|
|
|
].join(";");
|
|
|
|
|
const active = state.active;
|
|
|
|
|
for (const p of panels) {
|
|
|
|
|
const tab = document.createElement("button");
|
|
|
|
|
tab.type = "button";
|
|
|
|
|
const isActive = p.panelId === active;
|
|
|
|
|
tab.style.cssText = [
|
|
|
|
|
"flex:1", "min-width:0", "padding:0 10px", "border:0",
|
|
|
|
|
"background:" + (isActive ? "rgba(214,255,61,.10)" : "transparent"),
|
|
|
|
|
"color:" + (isActive ? "#d6ff3d" : "#8b98a9"),
|
|
|
|
|
"cursor:pointer",
|
|
|
|
|
"display:flex", "align-items:center", "gap:6px", "justify-content:center",
|
|
|
|
|
"overflow:hidden", "text-overflow:ellipsis", "white-space:nowrap",
|
|
|
|
|
"font:inherit",
|
|
|
|
|
].join(";");
|
|
|
|
|
tab.innerHTML = '<span>' + (p.icon || "🧩") + '</span><span>' + p.title.replace(/</g, "<") + '</span>';
|
|
|
|
|
tab.addEventListener("click", () => ipcRenderer.invoke("sidebar-open", p.panelId));
|
|
|
|
|
strip.appendChild(tab);
|
|
|
|
|
}
|
|
|
|
|
document.body.appendChild(strip);
|
|
|
|
|
// Push the panel body down so the 32-px strip doesn't overlap the content.
|
|
|
|
|
// box-sizing:border-box keeps 100%-tall panels from overflowing when we
|
|
|
|
|
// add the padding.
|
|
|
|
|
const style = document.createElement("style");
|
|
|
|
|
style.textContent = "html,body{box-sizing:border-box}body{padding-top:33px!important}";
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 16:00:34 +02:00
|
|
|
// Sidebar resize grip. Injected into every panel automatically so panel
|
|
|
|
|
// authors don't have to reinvent it. A thin strip along the LEFT edge
|
|
|
|
|
// (the boundary between the tab area and the sidebar) accepts mousedown
|
|
|
|
|
// and streams drag deltas to main until mouseup. Main clamps the width
|
|
|
|
|
// to [200, 800] and persists it in settings.sidebarWidth.
|
|
|
|
|
window.addEventListener("DOMContentLoaded", () => {
|
2026-08-31 16:32:16 +02:00
|
|
|
installPickerStrip();
|
2026-08-31 16:00:34 +02:00
|
|
|
const grip = document.createElement("div");
|
|
|
|
|
grip.setAttribute("aria-label", "Resize sidebar");
|
|
|
|
|
grip.style.cssText = [
|
|
|
|
|
"position:fixed", "left:0", "top:0", "bottom:0",
|
|
|
|
|
"width:5px", "cursor:col-resize", "z-index:2147483647",
|
|
|
|
|
"background:transparent",
|
|
|
|
|
].join(";");
|
|
|
|
|
// A faint highlight on hover so the affordance is visible.
|
|
|
|
|
grip.addEventListener("mouseenter", () => { grip.style.background = "rgba(214,255,61,.20)"; });
|
|
|
|
|
grip.addEventListener("mouseleave", () => { if (!dragging) grip.style.background = "transparent"; });
|
|
|
|
|
document.body.appendChild(grip);
|
|
|
|
|
let dragging = false;
|
|
|
|
|
grip.addEventListener("mousedown", (e) => {
|
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragging = true;
|
|
|
|
|
document.body.style.userSelect = "none";
|
|
|
|
|
grip.style.background = "rgba(214,255,61,.35)";
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("mousemove", (e) => {
|
|
|
|
|
if (!dragging) return;
|
|
|
|
|
// Moving cursor LEFT = grow sidebar width. movementX is negative left.
|
|
|
|
|
if (e.movementX !== 0) ipcRenderer.invoke("sidebar-drag", -e.movementX);
|
|
|
|
|
});
|
|
|
|
|
const stop = () => {
|
|
|
|
|
if (!dragging) return;
|
|
|
|
|
dragging = false;
|
|
|
|
|
document.body.style.userSelect = "";
|
|
|
|
|
grip.style.background = "transparent";
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("mouseup", stop);
|
|
|
|
|
window.addEventListener("mouseleave", stop);
|
|
|
|
|
});
|