From 13d0aa178a2377dc6e2a54525a95c4e2d63aa695 Mon Sep 17 00:00:00 2001
From: Local Dev
Date: Tue, 22 Sep 2026 20:43:50 +0200
Subject: [PATCH] feat(theseus/settings): Extensions as compact rows with a
detail view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
---
main.js | 26 ++++++
settings-preload.js | 2 +
settings.html | 215 ++++++++++++++++++++++++++++++++++++--------
3 files changed, 207 insertions(+), 36 deletions(-)
diff --git a/main.js b/main.js
index c0099cca..36f46317 100644
--- a/main.js
+++ b/main.js
@@ -3857,6 +3857,32 @@ ipcMain.handle("addons-reveal", (_e, folder) => {
ipcMain.handle("addons-open-dir", () => {
try { shell.openPath(addonsUserDir()); return true; } catch { return false; }
});
+// Remove an installed (non-bundled) extension: delete its folder under the
+// extensions directory and drop its prefs. Bundled add-ons are refused — a
+// deleted folder would just be reseeded on the next launch, so "turn off"
+// is the honest control for those. The per-extension data store is kept.
+ipcMain.handle("addons-remove", (_e, id) => {
+ if (!addonHost || typeof id !== "string" || !id) return { ok: false, error: "bad id" };
+ const a = addonHost.snapshot().installed.find((x) => x.id === id);
+ if (!a || !a.folder) return { ok: false, error: "not installed" };
+ const norm = path.normalize(a.folder), base = addonsUserDir();
+ if (!norm.toLowerCase().startsWith(base.toLowerCase() + path.sep)) return { ok: false, error: "not in the extensions directory" };
+ try {
+ const bundledManifest = path.join(bundledAddonsDir(), path.basename(norm), "addon.json");
+ if (fs.existsSync(bundledManifest)) return { ok: false, error: "built into Theseus — turn it off instead" };
+ } catch {}
+ try { fs.rmSync(norm, { recursive: true, force: true }); }
+ catch (e) { return { ok: false, error: e?.message || String(e) }; }
+ settings.disabledAddons = (settings.disabledAddons || []).filter((x) => x !== id);
+ settings.dockOrder = (settings.dockOrder || []).filter((k) => dockAddonId(k) !== id);
+ settings.dockHidden = (settings.dockHidden || []).filter((k) => dockAddonId(k) !== id);
+ saveSettings();
+ addonHost.discoverAndActivate();
+ if (sidebarVisible && sidebarActivePanelId && sidebarActivePanelId.startsWith(id + ":")) { sidebarActivePanelId = null; setSidebar(false); }
+ emitSidebarState();
+ console.log(`[addons] removed ${id} (${norm})`);
+ return { ok: true };
+});
// Manual "Check for updates" from Settings > Extensions. Runs the same
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
// staged dir so the UI can render "Update to — restart to apply".
diff --git a/settings-preload.js b/settings-preload.js
index 0466da98..d8f77f8b 100644
--- a/settings-preload.js
+++ b/settings-preload.js
@@ -45,6 +45,8 @@ contextBridge.exposeInMainWorld("cfg", {
listAddons: () => ipcRenderer.invoke("addons-list"),
setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled),
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
+ // Delete a non-bundled extension's folder (Settings › Extensions › ⋯ › Remove).
+ removeAddon: (id) => ipcRenderer.invoke("addons-remove", id),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
// Community extensions from theseus.x/extensions: the catalog (with what
diff --git a/settings.html b/settings.html
index d0616cb6..fbda06e2 100644
--- a/settings.html
+++ b/settings.html
@@ -131,6 +131,40 @@
.track{position:absolute;inset:0;background:#2b3444;border:1px solid var(--line);border-radius:999px;transition:.15s}
.knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#8b98a9;transition:.15s}
.sw input:checked + .track{background:rgb(from var(--acid) r g b / .25);border-color:color-mix(in srgb, var(--acid) 33%, transparent)}
+ /* Extensions: compact one-line rows (Firefox about:addons style); click a
+ row for the detail view, controls on the right stay clickable in place. */
+ .xgrp{font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px}
+ .xrow{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;
+ padding:9px 12px;margin-bottom:6px;cursor:pointer;min-height:44px}
+ .xrow:hover{background:#18202c}
+ .xrow:focus-visible{outline:2px solid color-mix(in srgb, var(--acid) 50%, transparent);outline-offset:1px}
+ .xrow .xi{width:22px;height:22px;flex:none;display:inline-grid;place-items:center;font-size:17px;line-height:1}
+ .xrow .xi img,.xhead .xi img{width:100%;height:100%;object-fit:contain;display:block}
+ .xrow .xn{flex:1;min-width:0;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .xrow .xv,.xhead .xv{color:var(--dim);font-weight:400;font-size:12.5px;margin-left:6px}
+ .xrow .xs{color:var(--dim);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
+ .xrow .xs.warn{color:#f6768a} .xrow .xs.upd{color:var(--acid-text)}
+ .xrow .xctl,.xhead .xctl{display:flex;align-items:center;gap:6px;flex:none}
+ .xbadge{display:inline-block;font-size:10px;letter-spacing:.05em;padding:1px 6px;border-radius:3px;background:rgba(214,255,61,.14);color:var(--acid-text);font-weight:700;margin-left:6px;vertical-align:middle}
+ .xbadge.off{background:rgba(255,255,255,.06);color:var(--dim)}
+ .xmore{width:30px;height:30px;border-radius:7px;border:none;background:transparent;color:var(--mut);font-size:18px;line-height:1;cursor:pointer}
+ .xmore:hover{background:#ffffff14;color:var(--ink)}
+ .xhead{display:flex;align-items:flex-start;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 14px 14px 10px}
+ .xhead .xi{width:36px;height:36px;flex:none;display:inline-grid;place-items:center;font-size:26px;line-height:1}
+ .xhead .xtitle{flex:1;min-width:0} .xhead .xtitle .t{font-weight:700;font-size:15px} .xhead .xtitle .d{color:var(--mut);font-size:13px;margin-top:3px}
+ .xback{width:32px;height:32px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--ink);font-size:18px;cursor:pointer;flex:none;margin-top:2px}
+ .xback:hover{background:#ffffff14}
+ .xdt{margin-top:10px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
+ .xdt .xr{display:flex;align-items:center;gap:16px;padding:11px 14px;border-top:1px solid var(--line);font-size:13.5px}
+ .xdt .xr:first-child{border-top:none}
+ .xdt .k{flex:0 0 160px;color:var(--mut)} .xdt .v{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--ink);display:flex;align-items:center;gap:10px;justify-content:space-between}
+ .xdt .v code{word-break:break-all}
+ .xdt .v .btn{padding:5px 10px;font-size:12px;flex:none}
+ .xperm{list-style:none;margin:0;padding:0} .xperm li{padding:9px 14px;border-top:1px solid var(--line);font-size:13.5px;display:flex;gap:10px}
+ .xperm li:first-child{border-top:none} .xperm li code{flex:0 0 150px;align-self:flex-start}
+ .xperm li span{color:var(--mut)}
+ .xdanger{border-color:rgba(246,118,138,.4);color:#f6768a;background:rgba(246,118,138,.08)} .xdanger:hover{background:rgba(246,118,138,.16)}
+ @media (prefers-color-scheme: light){ .xrow:hover{background:#eef2f6} .xmore:hover,.xback:hover{background:rgba(0,0,0,.06)} }
.sw input:checked + .track .knob{transform:translateX(20px);background:var(--acid)}
.note{color:var(--dim);font-size:12.5px;margin-top:1.4rem;border-top:1px solid var(--line);padding-top:1rem}
/* light theme (placed last so these win over the dark base rules) */
@@ -597,6 +631,7 @@
%APPDATA%\Theseus\extensions\. Drop a folder in, restart, it's live.
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.
+
Extensions ↗
@@ -612,6 +647,8 @@
owner on chain before installing — the relay can't substitute code under a trusted name. Extensions run
with full app access — treat installing one like installing an unsigned executable, and only load
extensions whose source you trust.
+
+
@@ -1299,54 +1336,160 @@
}
return '
' + msg + '
';
}
+ // ---- Extensions list: compact rows + detail view ----------------------
+ let lastAddonSnap = null; // last snapshot, so the detail view can re-render
+ let addonDetailId = null; // id shown in the detail view, or null (list)
+ const addonIconHtml = (icon) => /^data:image\//i.test(icon || "")
+ ? ''
+ : escapeHtml(icon || "•");
+ // Short status for the row: staged update or the last check's problem.
+ function addonShortStatus(a) {
+ const st = addonUpdates.staged[a.id]; if (st) return { cls: "upd", text: "↻ v" + st.version + " staged — restart to apply" };
+ const rep = addonUpdates.report[a.id]; if (!rep) return null;
+ if (["fetch-failed", "signature-invalid", "sha256-mismatch", "extract-failed", "manifest-mismatch"].includes(rep.status)) return { cls: "warn", text: "Update check failed" };
+ if (rep.status === "staged" || rep.status === "already-staged") return { cls: "upd", text: "↻ v" + (rep.newVer || "?") + " staged — restart to apply" };
+ return null;
+ }
+ const CAP_TEXT = {
+ "sidebar-panel": "Adds a panel to the sidebar.",
+ "toolbar-menu": "Adds a dropdown menu to its toolbar button.",
+ "context-menu-item": "Adds entries to the page right-click menu.",
+ "open-tab": "Can open its own pages as full tabs.",
+ "page-inject": "Runs code inside the web pages it declares — it can read and change what those pages show.",
+ "capture-tab": "Can take screenshots of the current tab.",
+ "vault-derive": "Derives its own keys from your password vault. It never sees the vault itself.",
+ "approval-modal": "Can ask you to approve an action in a dialog over the page.",
+ "session-proxy": "Can route the browser's traffic through a proxy it controls.",
+ };
function renderAddons(snap) {
+ lastAddonSnap = snap;
const items = (snap && snap.installed) || [];
if (!items.length) {
- addonsList.innerHTML = '
No extensions installed. Drop a folder into the extensions directory to install one.
';
+ addonsList.innerHTML = '
No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.
';
return;
}
- // Split into built-in (ship with Theseus, reseeded on version bump) vs
- // sideloaded (dropped in by the user). Built-in ones can still be
- // disabled — the toggle just means "load me at startup" — but they
- // can't be permanently deleted since reseedBundledAddons runs on the
- // next launch. Rendering the two groups apart makes that clear.
- const bundled = items.filter((a) => a.bundled);
- const sideloaded = items.filter((a) => !a.bundled);
- const rowFor = (a, isBundled) => {
+ const rowFor = (a) => {
if (a.error) {
- return '
' : '');
+ const enabled = items.filter((a) => !a.error && a.enabled);
+ const disabled = items.filter((a) => !a.error && !a.enabled);
+ const failed = items.filter((a) => a.error);
let html = "";
- if (bundled.length) html += sectionHeader("Built into Theseus", "Ship with every Theseus install. Disabling one hides its UI but the folder stays; a fresh install of a newer Theseus reseeds it.")
- + bundled.map((a) => rowFor(a, true)).join("");
- if (sideloaded.length) html += sectionHeader("Sideloaded", sideloaded.length && bundled.length ? "Third-party or hand-installed. Remove by deleting the folder." : "")
- + sideloaded.map((a) => rowFor(a, false)).join("");
+ if (enabled.length) html += '
Enabled
' + enabled.map(rowFor).join("");
+ if (disabled.length) html += '
Disabled
' + disabled.map(rowFor).join("");
+ if (failed.length) html += '