feat(theseus): protections live in Settings › Performance; quiet dock for Shield and Cookie Pop-ups

Shield and Cookie Pop-ups are settings more than tools, so their
switches, the cookie mode, the counters and "Update rules" now sit in
Settings › Performance under a Protections heading, driven through the
add-ons' own message handlers (Settings-only IPC). Each card opens the
add-on's panel for the per-site details, and each panel links back to
Settings. The two add-ons start hidden from the toolbar's extension
row (manifest dock:"hidden", honoured once so a user who shows them
keeps them); "Show hidden" on the row brings them back.

theseus://settings and theseus://settings/<section> are now addresses,
so any page or note can link to a Settings page.

Also: a Settings or add-on tab that the user navigates elsewhere stops
counting as that tab, otherwise "open Settings" kept focusing a tab
that no longer showed Settings.
This commit is contained in:
Local Dev 2026-09-27 20:37:30 +02:00
parent 70934a7553
commit 2230d4200c
10 changed files with 110 additions and 4 deletions

View file

@ -208,7 +208,10 @@ function validateManifest(raw, folderName) {
// back to plain-extension rendering.
const category = m.category && ["plugin"].includes(String(m.category))
? String(m.category) : null;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category };
// dock: "hidden" — the add-on starts without a toolbar button (its settings
// live in Settings); main honours it once, the user can show it later.
const dock = m.dock === "hidden" ? "hidden" : undefined;
return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock };
}
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest

View file

@ -6,6 +6,7 @@
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzRjOWVmZiIvPjxwYXRoIGQ9Ik05LjIgOS4yIDE0LjggMTQuOE0xNC44IDkuMiA5LjIgMTQuOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==",
"main": "index.js",
"dock": "hidden",
"capabilities": ["sidebar-panel", "request-filter"],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/blocker/updates.json"
}

View file

@ -193,6 +193,7 @@ async function activate(a) {
persistAllow(); pushState(); return snapshot();
});
api.onMessage("refresh-lists", () => refreshLists("manual"));
api.onMessage("open-settings", () => { api.openSettings("performance"); return true; });
// Filter first, engine second: requests made while the engine builds pass
// through (a few hundred milliseconds on first run), which beats holding

View file

@ -75,7 +75,7 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
<button class="btn quiet" id="refresh">Update now</button>
</div>
</div>
<div class="note">Shield stops requests to known tracking and advertising hosts before they leave Theseus. It does not hide leftover empty ad boxes yet. If a site misbehaves, allow it here and reload.</div>
<div class="note"><a href="#" id="toSettings" style="color:var(--blue)">Manage in Settings</a> · Shield stops requests to known tracking and advertising hosts before they leave Theseus. It does not hide leftover empty ad boxes yet. If a site misbehaves, allow it here and reload.</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
@ -115,6 +115,7 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
$("refresh").disabled = !!s.refreshing;
}
$("enabled").onclick = () => window.silentmode.invoke("set-enabled", { enabled: !(state && state.enabled) }).then(render);
$("toSettings").onclick = (e) => { e.preventDefault(); window.silentmode.invoke("open-settings"); };
$("refresh").onclick = () => { $("refresh").disabled = true; window.silentmode.invoke("refresh-lists").then((r) => { if (r && !r.ok) $("updated").textContent = "Update failed: " + (r.error || "unknown"); }); };
window.silentmode.on("state", render);
window.silentmode.invoke("state").then(render);

View file

@ -6,6 +6,7 @@
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iOSIgZmlsbD0iI2Y1YTUyNCIvPjxjaXJjbGUgY3g9IjkiIGN5PSI5IiByPSIxLjQiIGZpbGw9IiM3YzRhMWQiLz48Y2lyY2xlIGN4PSIxNSIgY3k9IjEwIiByPSIxLjIiIGZpbGw9IiM3YzRhMWQiLz48Y2lyY2xlIGN4PSIxMSIgY3k9IjE0LjUiIHI9IjEuMyIgZmlsbD0iIzdjNGExZCIvPjxwYXRoIGQ9Ik0xNS41IDE0LjUgbDUgNSBNMjAuNSAxNC41IGwtNSA1IiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=",
"main": "index.js",
"dock": "hidden",
"capabilities": ["sidebar-panel", "page-inject"],
"page-inject": {
"preload": "inject.js",

View file

@ -88,6 +88,7 @@ async function activate(a) {
});
// From the panel.
api.onMessage("state", () => snapshot());
api.onMessage("open-settings", () => { api.openSettings("performance"); return true; });
api.onMessage("set-enabled", (p) => { enabled = !!(p && p.enabled); api.storage.set("enabled", enabled); pushState(); return snapshot(); });
api.onMessage("set-mode", (p) => { mode = p && p.mode === "optIn" ? "optIn" : "optOut"; api.storage.set("mode", mode); pushState(); return snapshot(); });
api.onMessage("skip-site", (p) => {

View file

@ -69,7 +69,7 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
</div>
</div>
<div class="note" id="rules"></div>
<div class="note">Built on DuckDuckGo's autoconsent rules for hundreds of consent dialogs, with a fallback that looks for reject buttons on unknown ones. Some banners still need a click; if one keeps reappearing, exclude the site here.</div>
<div class="note"><a href="#" id="toSettings" style="color:var(--warn)">Manage in Settings</a> · Built on DuckDuckGo's autoconsent rules for hundreds of consent dialogs, with a fallback that looks for reject buttons on unknown ones. Some banners still need a click; if one keeps reappearing, exclude the site here.</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
@ -95,6 +95,7 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
}
const b = $("siteBtn"); if (b) b.onclick = () => window.silentmode.invoke("skip-site", { host: s.site.host, skipped: !s.site.skipped }).then(render);
}
$("toSettings").onclick = (e) => { e.preventDefault(); window.silentmode.invoke("open-settings"); };
$("enabled").onclick = () => window.silentmode.invoke("set-enabled", { enabled: !(state && state.enabled) }).then(render);
$("modes").addEventListener("change", (e) => { if (e.target.name === "mode") window.silentmode.invoke("set-mode", { mode: e.target.value }).then(render); });
window.silentmode.on("state", render);

45
main.js
View file

@ -393,6 +393,7 @@ const SETTINGS_DEFAULTS = {
// buttons hidden from the toolbar via the dock's right-click menu.
dockOrder: [],
dockHidden: [],
dockAutoHidden: [], // add-ons already started hidden once (manifest dock:"hidden")
// 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,
@ -2616,7 +2617,25 @@ function sidebarStatePayload() {
},
};
}
// Add-ons whose manifest says dock:"hidden" — Shield and Cookie Pop-ups,
// whose settings live under Settings › Performance and whose panel is for
// the details — start hidden from the toolbar dock. Done once per add-on,
// so a user who shows the button keeps it.
function autoHideQuietDock() {
if (!addonHost) return;
const seen = new Set(Array.isArray(settings.dockAutoHidden) ? settings.dockAutoHidden : []);
const hidden = new Set(Array.isArray(settings.dockHidden) ? settings.dockHidden : []);
let changed = false;
for (const a of addonHost.getInstalled()) {
const id = a.manifest && a.manifest.id;
if (!id || a.manifest.dock !== "hidden" || seen.has(id)) continue;
for (const k of dockKeys()) if (dockAddonId(k) === id) hidden.add(k);
seen.add(id); changed = true;
}
if (changed) { settings.dockHidden = [...hidden]; settings.dockAutoHidden = [...seen]; saveSettings(); }
}
function emitSidebarState() {
try { autoHideQuietDock(); } catch {}
try { chrome?.webContents.send("sidebar-state", sidebarStatePayload()); } catch {}
}
function setSidebar(show, panelId) {
@ -3151,6 +3170,15 @@ function createTab(initial, opts = {}) {
});
// A new document means a new (or no) web-app manifest; the chip follows.
wc.on("did-navigate", () => { tab.webapp = null; });
// A Settings (or add-on) tab the user navigates elsewhere is an ordinary tab
// from then on; otherwise openSettingsTab keeps focusing a tab that no
// longer shows Settings.
wc.on("did-navigate", () => {
if (!tab.settings && !tab.addonId) return;
let u = ""; try { u = wc.getURL() || ""; } catch {}
if (/^file:/i.test(u)) return;
tab.settings = false; tab.addonId = null; tab.prov = null;
});
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-navigate", () => { if (tab.id === activeId) notifyTabChange(); });
@ -3527,6 +3555,10 @@ async function navigateTab(id, input) {
// theseus://extensions/install/<id> typed or pasted into the address bar.
const installId = installLinkId(q);
if (installId) { installExtensionWithConsent(installId, null); return; }
// theseus://settings, theseus://settings/<section>: a linkable address for
// every Settings page.
const settingsLink = /^theseus:\/\/settings(?:\/([a-z0-9-]{1,32}))?\/?$/i.exec(q);
if (settingsLink) { openSettingsTab(settingsLink[1] || ""); return; }
// Local paths open as files — never BCNR, never a search.
const fileUrl = localFileUrl(q);
if (fileUrl) return loadLocalFile(t, id, fileUrl);
@ -4877,6 +4909,19 @@ function openSettingsTab(section) {
createTab(null, { settings: true, settingsSection: slug });
}
ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section));
// Settings › Performance drives Shield and Cookie Pop-ups through their
// add-ons' own message handlers; only the Settings tab may call this.
const isSettingsSender = (e) => tabs.some((t) => t.settings && t.view?.webContents === e.sender);
ipcMain.handle("addon-invoke", (e, id, msg, payload) => {
if (!isSettingsSender(e)) throw new Error("addon-invoke: settings only");
if (!addonHost) throw new Error("no add-on host");
return addonHost.dispatch(String(id || ""), String(msg || ""), payload, { from: "settings" });
});
ipcMain.handle("settings-open-panel", (e, panelId) => {
if (!isSettingsSender(e)) throw new Error("settings-open-panel: settings only");
setSidebar(true, String(panelId || ""));
return true;
});
ipcMain.handle("toggle-site-info", (_e, rect) => {
if (popVisible) return showPopover(false);
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };

View file

@ -49,6 +49,10 @@ contextBridge.exposeInMainWorld("cfg", {
removeAddon: (id) => ipcRenderer.invoke("addons-remove", id),
// Install a staged signed update now (Settings › Extensions › "Update to vX"); no id = every staged extension.
applyStagedAddons: (id) => ipcRenderer.invoke("addons-apply-staged", typeof id === "string" ? id : undefined),
// Settings › Performance › Protections: talk to an add-on's own message
// handlers (Shield, Cookie Pop-ups) and open its panel for the details.
addonInvoke: (id, msg, payload) => ipcRenderer.invoke("addon-invoke", String(id || ""), String(msg || ""), payload),
openPanel: (panelId) => ipcRenderer.invoke("settings-open-panel", String(panelId || "")),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
// Community extensions from theseus.x/extensions: the catalog (with what

View file

@ -31,6 +31,10 @@
.row .txt{flex:1}
.row .t{font-weight:600}
.row .d{color:var(--mut);font-size:13px;margin-top:2px}
.row .acts{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
.btn.small{padding:5px 10px;font-size:12px}
.btn.ghost{background:transparent;border-color:var(--line)}
h2.sub{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin:22px 0 8px}
/* control column — mode select + optional value field on the same row so the
dropdown menus don't get cut off underneath, wraps only when narrow */
.ctl{display:flex;flex-direction:row;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;flex:none;max-width:60%}
@ -421,11 +425,25 @@
<!-- PERFORMANCE -->
<section id="performance" hidden>
<h1>Performance</h1>
<p class="lede">Keep Theseus light on resources.</p>
<p class="lede">Keep Theseus light on resources, and keep trackers, ads and cookie banners out of your way.</p>
<div class="row">
<div class="txt"><div class="t">Throttle inactive tabs</div><div class="d">Background and inactive tabs use far less CPU. Recommended.</div></div>
<label class="sw"><input type="checkbox" id="backgroundThrottle"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Protections</h2>
<div class="row" id="shieldCard" hidden>
<div class="txt"><div class="t">Shield — block trackers and ads</div>
<div class="d">Requests to known tracking and advertising hosts never leave Theseus. <span id="shieldStats"></span></div>
<div class="acts"><button class="btn small" id="shieldUpdate">Update rules</button><button class="btn small ghost" id="shieldPanel">Open panel</button></div></div>
<label class="sw"><input type="checkbox" id="shieldOn"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" id="consentCard" hidden>
<div class="txt"><div class="t">Cookie pop-ups</div>
<div class="d">Consent banners are answered for you before they get in the way. <span id="consentStats"></span></div>
<div class="acts"><select id="consentMode"><option value="optOut">Reject all but essentials</option><option value="optIn">Just make it go away</option></select><button class="btn small ghost" id="consentPanel">Open panel</button></div></div>
<label class="sw"><input type="checkbox" id="consentOn"><span class="track"><span class="knob"></span></span></label>
</div>
<p class="note">Both also sit in the toolbar's extension row if you want them there: right-click the row and choose "Show hidden".</p>
</section>
<!-- PRIVACY -->
<section id="privacy" hidden>
@ -677,6 +695,36 @@
if (initSec) showSection(initSec);
} catch {}
// Protections (Performance): Shield + Cookie Pop-ups, driven through the
// add-ons' own message handlers. A card hides when its add-on is off.
(function () {
const inv = (id, msg, p) => (C.addonInvoke ? C.addonInvoke(id, msg, p) : Promise.reject(new Error("unavailable")));
const el = (i) => document.getElementById(i);
async function refresh() {
try {
const s = await inv("blocker", "state");
el("shieldOn").checked = !!s.enabled;
const L = s.lists || {};
el("shieldStats").textContent = `${Number(s.blockedTotal || 0).toLocaleString()} blocked since install · rules ${L.source === "online" && L.updatedAt ? "updated " + new Date(L.updatedAt).toLocaleDateString() : "bundled"}${s.refreshing ? " · refreshing…" : ""}`;
el("shieldCard").hidden = false;
} catch { el("shieldCard").hidden = true; }
try {
const s = await inv("consent", "state");
el("consentOn").checked = !!s.enabled; el("consentMode").value = s.mode || "optOut";
el("consentStats").textContent = `${Number(s.handledTotal || 0).toLocaleString()} pop-ups answered since install.`;
el("consentCard").hidden = false;
} catch { el("consentCard").hidden = true; }
}
el("shieldOn").addEventListener("change", () => inv("blocker", "set-enabled", { enabled: el("shieldOn").checked }).then(refresh, refresh));
el("shieldUpdate").addEventListener("click", () => { el("shieldUpdate").disabled = true; inv("blocker", "refresh-lists").catch(() => {}).then(() => { el("shieldUpdate").disabled = false; refresh(); }); });
el("shieldPanel").addEventListener("click", () => C.openPanel && C.openPanel("blocker:main"));
el("consentOn").addEventListener("change", () => inv("consent", "set-enabled", { enabled: el("consentOn").checked }).then(refresh, refresh));
el("consentMode").addEventListener("change", () => inv("consent", "set-mode", { mode: el("consentMode").value }).then(refresh, refresh));
el("consentPanel").addEventListener("click", () => C.openPanel && C.openPanel("consent:main"));
document.querySelector('.side a[data-sec="performance"]').addEventListener("click", refresh);
if (C.onFocusSection) C.onFocusSection((sec) => { if (sec === "performance") refresh(); });
refresh();
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
C.get().then((s) => {