diff --git a/addons-host.js b/addons-host.js index b5e3bfb..a5f0fef 100644 --- a/addons-host.js +++ b/addons-host.js @@ -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 + // 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); + }, }; } diff --git a/chrome.html b/chrome.html index 7957af3..056870a 100644 --- a/chrome.html +++ b/chrome.html @@ -202,7 +202,7 @@ - diff --git a/main.js b/main.js index 7f055a0..e0da04d 100644 --- a/main.js +++ b/main.js @@ -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 (`:`) — 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", () => ({ diff --git a/package.json b/package.json index a00ee1f..66a59ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/settings.html b/settings.html index 3ca415c..a11f8cd 100644 --- a/settings.html +++ b/settings.html @@ -148,7 +148,7 @@ Registries Performance Privacy - Add-ons + Extensions
@@ -500,19 +500,19 @@
@@ -985,7 +985,7 @@ function renderAddons(snap) { const items = (snap && snap.installed) || []; if (!items.length) { - addonsList.innerHTML = '
No add-ons installed. Drop a folder into the add-ons directory to install one.
'; + addonsList.innerHTML = '
No extensions installed. Drop a folder into the extensions directory to install one.
'; 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(); diff --git a/sidebar-preload.js b/sidebar-preload.js index a69c346..ede86cc 100644 --- a/sidebar-preload.js +++ b/sidebar-preload.js @@ -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); +});