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)),
|
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
|
|
|
// Call into the add-on's activate() context: resolves with whatever the
|
|
|
|
|
// matching api.onMessage handler returned (or rejects with its error).
|
|
|
|
|
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
|
|
|
|
|
// Events the add-on pushes via api.emit while this panel is open.
|
|
|
|
|
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
|
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable
User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
2026-09-08 22:18:41 +02:00
|
|
|
// Sidebar sizing controls a panel may want (e.g. the screenshot editor
|
|
|
|
|
// wants a full-window canvas). Widen fills the window minus a thin strip
|
|
|
|
|
// for the tab area behind it; restore returns to the pre-widen width.
|
|
|
|
|
sidebar: {
|
|
|
|
|
maximize: () => ipcRenderer.invoke("sidebar-maximize"),
|
|
|
|
|
restore: () => ipcRenderer.invoke("sidebar-restore"),
|
|
|
|
|
toggleMax:() => ipcRenderer.invoke("sidebar-toggle-max"),
|
|
|
|
|
isMax: () => ipcRenderer.invoke("sidebar-is-max"),
|
|
|
|
|
onMaxChange: (cb) => ipcRenderer.on("sidebar-max-change", (_e, max) => cb(!!max)),
|
|
|
|
|
},
|
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
|
|
|
});
|
2026-08-31 16:00:34 +02:00
|
|
|
|
2026-09-06 22:05:27 +02:00
|
|
|
// Panel picker strip was here — a 32-px tab bar injected at the top of
|
|
|
|
|
// every panel to switch between registered extensions. Removed: the
|
|
|
|
|
// toolbar extension dock (per-extension buttons + puzzle dropdown at
|
|
|
|
|
// narrow widths) is the canonical switcher now, and doubling that inside
|
|
|
|
|
// the sidebar wasted vertical space and made narrow panels feel cramped.
|
2026-08-31 16:32:16 +02:00
|
|
|
|
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", () => {
|
|
|
|
|
const grip = document.createElement("div");
|
|
|
|
|
grip.setAttribute("aria-label", "Resize sidebar");
|
2026-09-09 10:50:30 +02:00
|
|
|
// A visible-at-rest separator. Fully transparent used to hide the seam
|
|
|
|
|
// between the tab area and the sidebar completely — users couldn't tell
|
|
|
|
|
// where one ended and the other began. A mid-gray at moderate alpha
|
|
|
|
|
// reads on every panel background (dark and light) without competing
|
|
|
|
|
// for attention. Hover / drag ramps to acid so the grab affordance
|
|
|
|
|
// still stands out.
|
|
|
|
|
const IDLE_BG = "rgba(140,150,170,.55)";
|
|
|
|
|
const HOVER_BG = "rgba(214,255,61,.35)";
|
|
|
|
|
const ACTIVE_BG = "rgba(214,255,61,.55)";
|
2026-08-31 16:00:34 +02:00
|
|
|
grip.style.cssText = [
|
|
|
|
|
"position:fixed", "left:0", "top:0", "bottom:0",
|
2026-09-09 10:50:30 +02:00
|
|
|
"width:2px", "cursor:col-resize", "z-index:2147483647",
|
|
|
|
|
"background:" + IDLE_BG,
|
|
|
|
|
// A wider invisible hit target sits over the visible strip so
|
|
|
|
|
// dragging still catches a 5-px slack — visible line stays a
|
|
|
|
|
// clean 2 px.
|
|
|
|
|
"box-shadow:2px 0 0 0 transparent",
|
2026-08-31 16:00:34 +02:00
|
|
|
].join(";");
|
2026-09-09 10:50:30 +02:00
|
|
|
// A subtle overlay expands the pointer-catch zone without widening
|
|
|
|
|
// the visible band. Same click-through element, wider hit box.
|
|
|
|
|
grip.style.setProperty("outline", "2px solid transparent", "important");
|
|
|
|
|
grip.style.setProperty("outline-offset", "1px", "important");
|
|
|
|
|
grip.addEventListener("mouseenter", () => { grip.style.background = HOVER_BG; });
|
|
|
|
|
grip.addEventListener("mouseleave", () => { if (!dragging) grip.style.background = IDLE_BG; });
|
2026-08-31 16:00:34 +02:00
|
|
|
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";
|
2026-09-09 10:50:30 +02:00
|
|
|
grip.style.background = ACTIVE_BG;
|
2026-08-31 16:00:34 +02:00
|
|
|
});
|
|
|
|
|
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 = "";
|
2026-09-09 10:50:30 +02:00
|
|
|
grip.style.background = IDLE_BG;
|
2026-08-31 16:00:34 +02:00
|
|
|
};
|
|
|
|
|
window.addEventListener("mouseup", stop);
|
|
|
|
|
window.addEventListener("mouseleave", stop);
|
|
|
|
|
});
|