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 '
⚠ Load failed ' + escapeHtml(a.folder) + '
' + escapeHtml(a.error) + '
'; + return '
⚠Load failed ' + escapeHtml(a.folder) + '' + escapeHtml(a.error) + '
'; } - const caps = (a.capabilities || []).length ? '' + a.capabilities.map(escapeHtml).join(", ") + '' : ""; - const builtInBadge = isBundled - ? 'BUILT-IN' - : ""; - // Manifest icons may be an emoji ("🛡"), a "•" fallback, or a - // data: URI to a branded SVG/PNG — render as for URIs, - // as escaped text otherwise so the emoji case keeps working. - const iconHtml = /^data:image\//i.test(a.icon || "") - ? '' - : escapeHtml(a.icon || "•"); - return '
' + iconHtml + ' ' + escapeHtml(a.name) + builtInBadge + ' v' + escapeHtml(a.version) + '' + caps + '
' + escapeHtml(a.description || "") + (a.author ? ' — ' + escapeHtml(a.author) + '' : '') + '
' + updateLineFor(a) + '
'; + const s = addonShortStatus(a); + return '
' + + '' + addonIconHtml(a.icon) + '' + + '' + escapeHtml(a.name) + (a.bundled ? 'BUILT-IN' : '') + '' + escapeHtml(a.version) + '' + + (s ? '' + escapeHtml(s.text) + '' : '') + + '' + + '' + + '
'; }; - const sectionHeader = (label, hint) => - '
' + escapeHtml(label) + '
' - + (hint ? '
' + escapeHtml(hint) + '
' : ''); + 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 += '
Failed to load
' + failed.map(rowFor).join(""); addonsList.innerHTML = html; - addonsList.querySelectorAll('input[data-toggle]').forEach((cb) => { - cb.addEventListener("change", async () => { - await C.setAddonEnabled(cb.dataset.toggle, cb.checked); - loadAddons(); - }); - }); - addonsList.querySelectorAll('button[data-reveal]').forEach((btn) => { - btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal)); + wireAddonControls(addonsList); + addonsList.querySelectorAll('.xrow[data-id]').forEach((row) => { + const open = () => { if (row.dataset.id) openAddonDetail(row.dataset.id); }; + row.addEventListener("click", (e) => { if (e.target.closest(".xctl")) return; open(); }); + row.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && !e.target.closest(".xctl")) { e.preventDefault(); open(); } }); + row.addEventListener("contextmenu", (e) => { e.preventDefault(); if (row.dataset.id) openAddonMenu(row.dataset.id, e.clientX, e.clientY); }); }); + if (addonDetailId) renderAddonDetail(); } + // Toggle / ⋯ / Show folder — shared by the list rows and the detail header. + function wireAddonControls(root) { + root.querySelectorAll('input[data-toggle]').forEach((cb) => { + cb.addEventListener("change", async () => { await C.setAddonEnabled(cb.dataset.toggle, cb.checked); loadAddons(); }); + }); + root.querySelectorAll('button[data-reveal]').forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); C.revealAddon(btn.dataset.reveal); })); + root.querySelectorAll('button[data-more]').forEach((btn) => btn.addEventListener("click", (e) => { + e.stopPropagation(); const r = btn.getBoundingClientRect(); openAddonMenu(btn.dataset.more, r.right, r.bottom + 4, true); + })); + } + const addonById = (id) => ((lastAddonSnap && lastAddonSnap.installed) || []).find((a) => a.id === id) || null; + // Floating menu (same look as the search-engine row menu). alignRight + // anchors the menu's right edge at x — for the ⋯ button. + let addonMenuEl = null; + function closeAddonMenu() { if (addonMenuEl) { addonMenuEl.remove(); addonMenuEl = null; } } + function openAddonMenu(id, x, y, alignRight) { + closeAddonMenu(); + const a = addonById(id); if (!a) return; + const items = [ + { label: a.enabled ? "Turn off" : "Turn on", act: () => C.setAddonEnabled(a.id, !a.enabled).then(loadAddons) }, + { label: "Details", act: () => openAddonDetail(a.id) }, + { label: "Show folder", act: () => C.revealAddon(a.folder) }, + ]; + if (!a.bundled) items.push({ label: "Remove…", danger: true, act: () => removeAddon(a) }); + const m = document.createElement("div"); + m.className = "ctxmenu"; + m.innerHTML = items.map((it, i) => '
' + escapeHtml(it.label) + '
').join(""); + document.body.appendChild(m); + const rect = m.getBoundingClientRect(); + const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; + const left = alignRight ? x - rect.width : x; + m.style.left = Math.max(6, Math.min(left, vw - rect.width - 6)) + "px"; + m.style.top = Math.min(y, vh - rect.height - 6) + "px"; + m.querySelectorAll(".mi").forEach((el) => el.onclick = () => { closeAddonMenu(); items[Number(el.dataset.i)].act(); }); + addonMenuEl = m; + setTimeout(() => { + const off = (ev) => { if (!m.contains(ev.target)) { closeAddonMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + const esc = (ev) => { if (ev.key === "Escape") { closeAddonMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } }; + document.addEventListener("mousedown", off); + document.addEventListener("keydown", esc); + }, 0); + } + async function removeAddon(a) { + if (!confirm('Remove "' + a.name + '"?\n\nIts folder under the extensions directory is deleted. Data it kept in its own store stays until you delete it from the extensions folder.')) return; + if (!C.removeAddon) { C.revealAddon(a.folder); return; } + const r = await C.removeAddon(a.id); + if (!r || !r.ok) alert("Could not remove: " + (r && r.error || "unknown error")); + if (addonDetailId === a.id) closeAddonDetail(); + loadAddons(); + } + // Detail view: replaces the list in place (back arrow returns). + function openAddonDetail(id) { addonDetailId = id; renderAddonDetail(); } + function closeAddonDetail() { + addonDetailId = null; + document.getElementById("addonDetail").hidden = true; + document.getElementById("addonsMain").hidden = false; + } + function renderAddonDetail() { + const a = addonById(addonDetailId); + const box = document.getElementById("addonDetail"); + if (!a) { closeAddonDetail(); return; } + document.getElementById("addonsMain").hidden = true; + box.hidden = false; + const caps = (a.capabilities || []); + const kind = a.bundled ? "Built into Theseus — ships with every release; turning it off hides it, a newer Theseus reseeds it" : "Installed extension — remove it from the ⋯ menu"; + const upd = updateLineFor(a).replace("margin-top:4px", "margin:0") || '
' + (a.bundled ? "Updates arrive with Theseus releases or its signed channel." : "Checked with the rest at Check for updates.") + '
'; + box.innerHTML = + '
' + + '' + addonIconHtml(a.icon) + '' + + '
' + escapeHtml(a.name) + (a.bundled ? 'BUILT-IN' : '') + (a.enabled ? '' : 'OFF') + '' + escapeHtml(a.version) + '
' + + '
' + escapeHtml(a.description || "No description.") + '
' + + '' + + '
' + + '
' + + '
Updates' + upd + '
' + + '
Author' + escapeHtml(a.author || "—") + '
' + + '
Version' + escapeHtml(a.version) + '
' + + '
Type' + escapeHtml(kind) + '
' + + '
Folder' + escapeHtml(a.folder) + '
' + + '
' + + '

Permissions

' + + '
' + (caps.length + ? '
    ' + caps.map((c) => '
  • ' + escapeHtml(c) + '' + escapeHtml(CAP_TEXT[c] || "Framework capability.") + '
  • ').join("") + '
' + : '
No special capabilities — it only uses the basic add-on API.
') + + '
' + + '
Every extension runs inside Theseus with the same access as the browser itself; these entries are the extension points it declared, not a sandbox.
' + + (a.bundled ? '' : '
'); + document.getElementById("xback").onclick = closeAddonDetail; + const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a); + wireAddonControls(box); + } + document.addEventListener("keydown", (e) => { if (e.key === "Escape" && addonDetailId && !addonMenuEl) closeAddonDetail(); }); // Discovery of new community extensions happens at theseus.x/extensions — // Settings only shows what's installed on this machine. The install button // on that page invokes the same "addons-install-community" IPC and Theseus