Ship Theseus 0.2.2 972f6209 (Extensions rename + draggable sidebar + session-proxy)

Setup    972f6209639122f32f032d5f2f9fc5a4808e0d4a810f88ae38ec9a1275ac52ac
Portable a9771f054ec36b9aff6ddee958cecf7e84cdacd4d7932aa8385c445aab4d29be

User-visible rename: the Settings tab and its labels say "Extensions"
now instead of "Add-ons". Internal identifiers (disabledAddons, the
addons/ folder, IPC channels, capability strings) stay put — code
churn wasn't worth it, and users only see the user-facing text.

Draggable sidebar. sidebar-preload.js now injects a 5px grip strip
along the LEFT edge of every panel document. mousedown+mousemove
streams delta-x px to main via sidebar-drag IPC; main clamps to
[200, 800] and debounces a save to settings.sidebarWidth. Width is
restored on next launch. The default is still 340. Faint acid-green
highlight on hover so the affordance is discoverable.

New extension capability: session-proxy. An extension whose addon.json
declares "session-proxy" gets api.setSessionProxy(rules) which routes
to session.defaultSession.setProxy — the same primitive Tor already
uses under the hood. Rules can be a string ("socks5://host:port") or
an object matching Electron's setProxy shape; null clears. The
capability is opt-in: an extension without the declaration gets an
error if it tries to call setSessionProxy. This is the framework
surface a private 3-VPS relay extension would build on (extension
folder stays on the operator's disk only; nothing about it appears in
the public build).

Deployed: scp installers + manifest + tools/ + releases/ pages to
VPS, sia-upload of both trees, verified HEAD 200 and manifest 0.2.2.
This commit is contained in:
Local Dev 2026-08-31 16:00:34 +02:00
parent 1a6b631d98
commit ee0548fec9
6 changed files with 117 additions and 15 deletions

View file

@ -22,7 +22,7 @@ const path = require("node:path");
// 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"]);
const KNOWN_CAPABILITIES = new Set(["sidebar-panel", "session-proxy"]);
// Manifest field guardrails. Reject anything shape-suspicious so a bad
// addon.json can't get past the loader gate.
@ -53,13 +53,19 @@ function validateManifest(raw, folderName) {
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`.
class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger }) {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy }) {
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: [...] }
// Session-proxy hook — injected by main so add-ons can swap the default
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
// A `null` rule clears the proxy. Kept as a callback rather than requiring
// the loader itself import electron.
this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null;
}
ensureDirs() {
@ -160,6 +166,23 @@ class AddonHost {
active.sidebarPanels.push({ panelId, title, icon, pageFile: abs, addonId: manifest.id });
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);
},
// Swap the default session's proxy. Rules follow Chromium's proxy
// format ("socks5://1.2.3.4:1080" for a single SOCKS server,
// "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.).
// Pass null to clear. Add-ons that opt into this capability are
// fully replacing the browser's outgoing network path — they're
// the trust boundary while active. Same-signature as the built-in
// Tor toggle uses under the hood.
setSessionProxy: async (rules) => {
if (!this._setSessionProxy) {
this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`);
return;
}
if (!manifest.capabilities.includes("session-proxy")) {
throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`);
}
await this._setSessionProxy(rules, manifest.id);
},
};
}

View file

@ -202,7 +202,7 @@
<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>
</button>
<button class="ic" id="sidebarbtn" title="Toggle add-on sidebar" hidden>
<button class="ic" id="sidebarbtn" title="Toggle extension 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>

43
main.js
View file

@ -217,6 +217,9 @@ const SETTINGS_DEFAULTS = {
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons: [],
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340,
};
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
@ -230,6 +233,10 @@ const settingsFile = () => path.join(app.getPath("userData"), "settings.json");
function loadSettings() {
try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; }
catch (e) { console.error("settings load failed:", e.message); }
// Restore the persisted sidebar width so the first-open of a session
// uses whatever the user left it at last time.
const w = Number(settings.sidebarWidth) || SIDEBAR_W_DEFAULT;
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, w));
}
function saveSettings() {
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
@ -1141,7 +1148,11 @@ let linkStatusW = 100, linkStatusH = 22;
// 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;
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user.
const SIDEBAR_W_MIN = 200, SIDEBAR_W_MAX = 800, SIDEBAR_W_DEFAULT = 340;
let sidebarW = SIDEBAR_W_DEFAULT;
// 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;
@ -1175,6 +1186,20 @@ function initAddons() {
dataDir: addonsDataDir(),
isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id),
logger: (...a) => console.log("[addons]", ...a),
// Session-proxy capability. Add-ons that declare "session-proxy" in
// their manifest can call api.setSessionProxy(rules) to swap
// Chromium's outbound network path. Same primitive Tor uses.
setSessionProxy: async (rules, addonId) => {
const ses = session.defaultSession;
if (rules == null || rules === "") {
console.log(`[addons] [${addonId}] clearing session proxy`);
try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); }
return;
}
const opts = typeof rules === "string" ? { proxyRules: rules } : rules;
console.log(`[addons] [${addonId}] setting session proxy:`, opts.proxyRules || JSON.stringify(opts));
try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); }
},
});
addonHost.discoverAndActivate();
const snap = addonHost.snapshot();
@ -1227,7 +1252,7 @@ function layout() {
const bodyH = Math.max(0, height - CHROME_H);
// 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 sideW = sidebarVisible ? sidebarW : 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 });
@ -1931,6 +1956,20 @@ ipcMain.handle("go-home", () => loadHome(activeId));
// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no
// coercion, main matches it verbatim.
ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible; });
// Drag events stream in from sidebar-preload while the user is holding the
// grip. Delta is px per mousemove; we clamp, layout, and debounce the save.
let _sidebarSaveTimer = null;
ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
const d = Number(deltaPx) || 0;
const next = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarW + d));
if (next === sidebarW) return sidebarW;
sidebarW = next;
layout();
settings.sidebarWidth = sidebarW;
clearTimeout(_sidebarSaveTimer);
_sidebarSaveTimer = setTimeout(saveSettings, 400);
return sidebarW;
});
ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; });
ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; });
ipcMain.handle("sidebar-state", () => ({

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.2.1",
"version": "0.2.2",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",

View file

@ -148,7 +148,7 @@
<a data-sec="naming">Registries</a>
<a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a>
<a data-sec="addons">Add-ons</a>
<a data-sec="addons">Extensions</a>
</nav>
<div class="content">
<!-- GENERAL -->
@ -500,19 +500,19 @@
<!-- 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
<h1>Extensions</h1>
<p class="lede">Small modules that add capabilities to Theseus. Extensions 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
Bundled reference extensions (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>
<button id="addonsOpenDir" class="btn" type="button">Open extensions 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>
<div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable.
Only load extensions whose source you trust.</div>
</section>
</div>
</div>
@ -985,7 +985,7 @@
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>';
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Drop a folder into the extensions directory to install one.</div>';
return;
}
addonsList.innerHTML = items.map((a) => {
@ -1009,7 +1009,7 @@
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); }
catch (e) { addonsList.textContent = "Failed to load extensions: " + (e?.message || e); }
}
document.getElementById("addonsReload").addEventListener("click", async () => {
await C.reloadAddons(); loadAddons();

View file

@ -14,3 +14,43 @@ contextBridge.exposeInMainWorld("silentmode", {
// suspend expensive work when hidden.
onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)),
});
// 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");
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);
});