From 828100e2ceb0e4358c035240a75453a9c7b4dd11 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 15:50:35 +0200 Subject: [PATCH] Theseus: warn before opening a name a blocklist flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocklist consumer existed in the resolver library and the gateway, but the browser opened a flagged name without comment. Now the indexer process reads the subscribed lists from the chain every ten minutes and hands the flags to main. A flagged name loads a warning page naming the reason, the list and the report; the user may continue, and that is remembered per name. Two gates, because content is reached two ways. loadBns shows the real interstitial. serveBns refuses with an inline page on every path that skips it: reload, back and forward, bns:// links, web app windows. The inline page has no button, since a page at the site's own origin must not be able to approve itself; only blocked.html may ask to continue, checked by file URL. Settings › Naming has the policy: warn (default), never open, or ignore the lists. The csam reason is never offered a way through. A list that cannot be read keeps the last known flags and never stops a name from resolving. The gateway put its own warning in front of flagged sites, which this browser could not get past: it fetches files itself, with no cookie jar. It now sends x-bns-policy: client and the gateway stays out of the way for a client that says it decides for itself. dev/blocklist-selftest.js runs the real protocol handler and decision functions against a scratch profile. --- blocked.html | 103 +++++++++++++++++++++++++++ bns-indexer.js | 29 ++++++++ dev/blocklist-selftest.js | 99 ++++++++++++++++++++++++++ main.js | 146 +++++++++++++++++++++++++++++++++++++- package.json | 1 + settings-preload.js | 4 ++ settings.html | 31 ++++++++ 7 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 blocked.html create mode 100644 dev/blocklist-selftest.js diff --git a/blocked.html b/blocked.html new file mode 100644 index 00000000..c189ec8c --- /dev/null +++ b/blocked.html @@ -0,0 +1,103 @@ + + + + + Warning + + + + + +
+

⚠ is flagged as dangerous

+

+
+
Reason
+
List
+
Report
+
+

The name is still registered on chain and nobody has taken it from its owner. This warning comes from a blocklist Theseus subscribes to; the flag and the report behind it are public transactions anyone can read. You can change what Theseus does with flagged names in Settings › Naming.

+ +
+ + +
+
+ + + + diff --git a/bns-indexer.js b/bns-indexer.js index 1bad3556..45687f4e 100644 --- a/bns-indexer.js +++ b/bns-indexer.js @@ -237,6 +237,32 @@ function stopOwn() { publish("handback"); } +// ---- blocklists ------------------------------------------------------------- +// The lists the network subscribes to by default, read from the chain +// (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md) and sent to main as +// { type: "blocklist" }. Read here because this process is the one with a +// resolver and a (Tor-aware) electrum socket. The lists are advisory: a failed +// read is reported and main keeps the flags it had. +const BLOCKLIST_FIRST_MS = 12_000; +const BLOCKLIST_EVERY_MS = 10 * 60_000; +let blocklistBusy = false; +async function readBlocklists() { + if (blocklistBusy || !R || typeof R.buildBlocklists !== "function") return; + blocklistBusy = true; + try { + const lists = (R.NETWORK && R.NETWORK.blocklists) || []; + const idx = active() === "own" ? own.index : chain && chain.index; + const flags = lists.length ? await R.buildBlocklists({ lists, index: idx || null, WebSocket: currentWS(), directIP: true }) : new Map(); + port.postMessage({ + type: "blocklist", ok: true, at: Date.now(), + lists: lists.map((l) => ({ name: l.name || null, address: R.blocklistAddress(l, idx || null) })), + flags: [...flags.values()].map((f) => ({ name: f.name, reason: f.reason, reportTxid: f.reportTxid, txid: f.txid, height: f.height, evidence: f.evidence || null, source: f.source })), + }); + } catch (e) { + port.postMessage({ type: "blocklist", ok: false, error: e?.message || String(e) }); + } finally { blocklistBusy = false; } +} + // ---- boot ------------------------------------------------------------------- async function init(m) { cfg = m; @@ -285,6 +311,8 @@ async function init(m) { if (!checked.length) startOwn("no Ariadne indexer installed"); setTimeout(checkHealth, LAUNCH_GRACE_MS); setInterval(checkHealth, CHECK_EVERY_MS); + setTimeout(readBlocklists, BLOCKLIST_FIRST_MS); + setInterval(readBlocklists, BLOCKLIST_EVERY_MS); } let initP = null; @@ -320,6 +348,7 @@ port.on("message", async (e) => { // when it is live, else the own one; allowed before "go". case "poll": reply(await poll()); break; case "rebuild": await initP; reply(await startOwn(ownReason || "rebuild requested").rebuild()); break; + case "blocklist": await initP; await readBlocklists(); reply(true); break; default: reply(false, "unknown request"); } } catch (err) { reply(false, err?.message || String(err)); } diff --git a/dev/blocklist-selftest.js b/dev/blocklist-selftest.js new file mode 100644 index 00000000..51887108 --- /dev/null +++ b/dev/blocklist-selftest.js @@ -0,0 +1,99 @@ +// Blocklist harness — exercises the blocklist policy through the REAL +// serveBns handler and the real decision functions in main.js, without +// starting the app (so no updater, no Ariadne, no real profile). +// +// set THESEUS_USER_DATA= +// npx electron dev/blocklist-selftest.js [name] (default: hello.bch) +// +// The flags normally arrive from the indexer process, which reads the lists +// from the chain; here the harness delivers them through the same message +// handler. It checks: +// 1. an unflagged name is served as before +// 2. a flagged name gets the inline warning from the protocol handler (403) +// 3. "continue anyway" is remembered per name, and only for a flagged name +// 4. policy "refuse" ignores that choice; reason "csam" never offers one; +// policy "off" ignores the lists +// 5. blocked.html renders the flag and shows/hides "continue" as told +// Prints a JSON report and exits 0 only if every check passed. +process.env.THESEUS_NO_AUTOSTART = "1"; +if (!process.env.THESEUS_USER_DATA) { console.log("refusing to run without THESEUS_USER_DATA (it would use the real profile)"); process.exit(2); } +const { app, BrowserWindow, protocol } = require("electron"); +const { serveBns, _blocklistTest: B } = require("../main.js"); +const fs = require("fs"); +const path = require("path"); + +app.disableHardwareAcceleration(); +app.commandLine.appendSwitch("disable-gpu"); +app.commandLine.appendSwitch("no-sandbox"); + +const name = (process.argv[2] || "hello.bch").toLowerCase(); +const outDir = path.join(__dirname, "..", "dev-out"); +const watchdog = setTimeout(() => { console.log("WATCHDOG: timed out"); try { app.exit(3); } catch {} }, 90000); +const checks = []; +const check = (what, ok, detail) => { checks.push({ what, ok: !!ok, ...(detail !== undefined ? { detail } : {}) }); }; +const FLAG = (reason) => ({ name, reason, reportTxid: "ab".repeat(32), txid: "cd".repeat(32), height: 1, source: "test-list.x" }); + +app.whenReady().then(async () => { + protocol.handle("bns", serveBns); + const win = new BrowserWindow({ width: 1000, height: 700, show: false, webPreferences: { offscreen: true } }); + const wc = win.webContents; + const titleOf = async (url) => { try { await wc.loadURL(url); } catch {} await new Promise((r) => setTimeout(r, 1500)); return wc.getTitle(); }; + const statusOf = async (url) => (await serveBns(new Request(url))).status; + const WARN = `Warning: ${name} is flagged`; + + // 1. unflagged + const plainTitle = await titleOf(`bns://${name}/`); + check("unflagged name is served as before", plainTitle !== WARN && (await statusOf(`bns://${name}/`)) !== 403, plainTitle); + check("no decision without a flag", B.decision({ name }, name) === null); + + // 2. flagged + B.setFlags([FLAG("malware")]); + const d = B.decision({ name }, name); + check("flagged name: decision offers continue under the default policy", d && d.canContinue === true && d.flag.reason === "malware"); + check("a subdomain is governed by its parent's flag", !!B.decision({ name }, "www." + name)); + check("protocol handler answers 403 for a flagged name", (await statusOf(`bns://${name}/`)) === 403); + check("…and for its subresources", (await statusOf(`bns://${name}/style.css`)) === 403); + check("the inline warning is what renders", (await titleOf(`bns://${name}/`)) === WARN); + const inline = await wc.executeJavaScript("document.body.innerHTML"); + check("the inline warning has no way to approve itself", !/block-choose| ({ search: new URLSearchParams({ host: name, name, reason: "phishing", list: "test-list.x", report: "ab".repeat(32), resturl: "/", proceed }).toString() }); + await wc.loadFile(path.join(__dirname, "..", "blocked.html"), page("1")); + await new Promise((r2) => setTimeout(r2, 800)); + const withGo = await wc.executeJavaScript(`({ title: document.title, name: document.getElementById("name").textContent, why: document.getElementById("why").textContent, go: !document.getElementById("go").hidden, refused: !document.getElementById("refused").hidden, list: document.getElementById("list").textContent })`); + check("blocked.html shows the flag", withGo.title === WARN && withGo.name === name && /imitates another site/.test(withGo.why) && withGo.list === "test-list.x", withGo); + check("blocked.html offers continue when told it may", withGo.go === true && withGo.refused === false); + fs.mkdirSync(outDir, { recursive: true }); + try { fs.writeFileSync(path.join(outDir, "blocked.png"), (await wc.capturePage()).toPNG()); } catch {} + await wc.loadFile(path.join(__dirname, "..", "blocked.html"), page("0")); + await new Promise((r2) => setTimeout(r2, 800)); + const noGo = await wc.executeJavaScript(`({ go: !document.getElementById("go").hidden, refused: !document.getElementById("refused").hidden })`); + check("blocked.html hides continue when refused", noGo.go === false && noGo.refused === true); + + const failed = checks.filter((x) => !x.ok); + console.log(JSON.stringify({ name, passed: checks.length - failed.length, failed: failed.length, checks }, null, 1)); + clearTimeout(watchdog); + app.exit(failed.length ? 1 : 0); +}); diff --git a/main.js b/main.js index f21c0b51..80623adf 100644 --- a/main.js +++ b/main.js @@ -506,6 +506,11 @@ const SETTINGS_DEFAULTS = { // "icann-first" — ICANN wins collisions; BCNR fills gaps. // "soft" — "Open with…" prompt on collision, remembered per name/TLD. collisionPolicy: "bcnr-first", + // Names flagged by a subscribed blocklist (PROTOCOL-ADDENDUM-blocklist.md): + // "warn" — a warning page first; the user may continue, per name (default) + // "refuse" — never opened + // "off" — the lists are ignored + blocklistPolicy: "warn", // Add-on framework: ids the user has explicitly turned off. Installed but // disabled add-ons are still discovered — they just never activate. disabledAddons: [], @@ -878,6 +883,7 @@ const SETTINGS_ONLY = new Set([ "addons-list", "addons-open-dir", "addons-reload", "addons-remove", "addons-reveal", "addons-set-enabled", "ariadne-get-policy", "ariadne-get-status", "ariadne-install", "ariadne-set-policy", "ariadne-set-source", "ariadne-state", "ariadne-toggle", "ariadne-uninstall", "ariadne-update", + "blocklist-reset", "blocklist-set-policy", "clear-browsing-data", "collision-reset", "collision-set-policy", "password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove", "password-setup", "password-status", "password-unlock", "password-update", @@ -893,7 +899,7 @@ const SETTINGS_ONLY = new Set([ // only its own window may drive it. const HERMES_ONLY = new Set(["hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", "hermes-inbox", "hermes-send"]); const SETTINGS_SHARED = new Set([ - "add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state", + "add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "blocklist-state", "collision-state", "remove-engine", "settings-get", "settings-set", "toggle-tor", ]); function isSettingsPage(sender) { @@ -1023,6 +1029,65 @@ function rememberCollision(host, tld, choice, remember) { if (remember !== "no") saveCollisions(); } +// ---- Blocklists (userData/blocklist.json) -------------------------------- +// A subscribed list can flag a name as dangerous. That is advice, not a chain +// rule: the name is still registered, and what happens next is this browser's +// policy and the user's call. See Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md. +// +// The flags come from the indexer process, which reads each list from the +// chain (bns-indexer.js). "Continue anyway" is remembered per name. +let blockFlags = new Map(); // registered name -> { name, reason, reportTxid, source, ... } +let blocklistInfo = { lists: [], at: 0, ok: null, error: null }; +let blockOverrides = { byName: {} }; +const blocklistFile = () => path.join(app.getPath("userData"), "blocklist.json"); +function loadBlockOverrides() { + try { if (fs.existsSync(blocklistFile())) blockOverrides = { byName: {}, ...JSON.parse(fs.readFileSync(blocklistFile(), "utf8")) }; } + catch (e) { console.error("blocklist overrides load failed:", e.message); } +} +function saveBlockOverrides() { + try { fs.writeFileSync(blocklistFile(), JSON.stringify(blockOverrides, null, 2)); } catch (e) { console.error("blocklist overrides save failed:", e.message); } +} +// The registered name a host belongs to: a subdomain is governed by its parent. +function blockKeyFor(entry, host) { + if (entry && entry.name) return String(entry.name).toLowerCase(); + return String(host || "").toLowerCase().split(".").filter(Boolean).slice(-2).join("."); +} +// null = open it normally. Otherwise the flag, and whether the user may go on. +// Reason "csam" is never offered a way through, whatever the policy. +function blockDecision(entry, host) { + const policy = settings.blocklistPolicy || "warn"; + if (policy === "off" || !blockFlags.size) return null; + const flag = blockFlags.get(blockKeyFor(entry, host)); + if (!flag) return null; + const canContinue = policy === "warn" && flag.reason !== "csam"; + if (canContinue && blockOverrides.byName[flag.name]) return null; + return { flag, canContinue }; +} +function rememberBlockOverride(name) { + const key = String(name || "").toLowerCase(); + const flag = blockFlags.get(key); + // Only for a name that IS flagged, and only where continuing is on offer. + if (!flag || flag.reason === "csam" || (settings.blocklistPolicy || "warn") !== "warn") return false; + blockOverrides.byName[key] = { at: new Date().toISOString(), reason: flag.reason, report: flag.reportTxid }; + saveBlockOverrides(); + return true; +} +// What the protocol handler answers for a flagged name reached without the +// interstitial (a reload, a link, a web app window). Inline, because a +// protocol response cannot load one of our own pages — and so with no button: +// a page at this origin must not be able to approve itself. +function blockedInlineHtml(flag, host) { + const esc = (v) => String(v || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + return `Warning: ${esc(flag.name)} is flagged + +

⚠ ${esc(flag.name)} is flagged as dangerous

+

A blocklist Theseus subscribes to reports this name for ${esc(flag.reason)}.

+

List ${esc(flag.source)} · report ${esc(flag.reportTxid)}

+

To decide for yourself, type ${esc(host)} in the address bar: Theseus shows the full warning there, with the option to continue where one exists.

`; +} + function emitEngines() { try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {} if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {} @@ -1849,6 +1914,15 @@ function onIndexerMessage(m) { indexBuiltAt = m.builtAt; } else if (m.type === "status") { bnsIndexStatus = m; + } else if (m.type === "blocklist") { + // A failed read keeps the last good flags: an unreadable list must not + // silently lift a warning, and must never stop a name from resolving. + if (m.ok) { + blockFlags = new Map((m.flags || []).map((f) => [String(f.name).toLowerCase(), f])); + blocklistInfo = { lists: m.lists || [], at: m.at || Date.now(), ok: true, error: null }; + } else { + blocklistInfo = { ...blocklistInfo, ok: false, error: m.error || "unreadable" }; + } } else if (m.type === "reply") { const settle = indexerPending.get(m.id); if (settle) { indexerPending.delete(m.id); settle(!!m.ok); } @@ -2019,6 +2093,13 @@ async function serveBns(request) { // one that answers "not registered" has already evicted it. if (!rec || (rec.gen !== indexGen && !(rec.entry.provisional && !sharedIndex))) { try { await resolveHost(host); rec = entries.get(host); } catch {} } if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); + // A flagged name the user has not chosen to continue to. loadBns shows the + // real interstitial; this catches every way of reaching bns:// that does + // not pass through it (reload, back/forward, links, web app windows). + { + const block = blockDecision(rec.entry, host); + if (block) return new Response(blockedInlineHtml(block.flag, host), { status: 403, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }); + } const r = rec.entry.records; // Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the // registry (see resolver-web `normalizeName`). `ip` semantics apply to the @@ -2081,7 +2162,9 @@ async function serveBns(request) { // Secret-free: fetch Sia content from the public gateway (it holds the // keys and owns the subfolder mapping) instead of signing S3 requests // with credentials that must never ship in a public build. - const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, {}); + // "x-bns-policy: client": this browser has applied its own blocklist + // policy (above), so the gateway does not put its warning page in the way. + const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, { headers: { "x-bns-policy": "client" } }); const ct = up.contentType && up.contentType !== "application/octet-stream" ? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath); let body = up.buffer; @@ -4247,6 +4330,18 @@ function createTab(initial, opts = {}) { } return navigateTab(id, target + rest); } + // "Continue anyway" from the blocklist warning. Same rule as above: only + // our own page may ask, or any site could approve itself. + if (parsed.protocol === "bns:" && parsed.hostname === "block-choose") { + e.preventDefault(); + if (!isAppPage(wc, "blocked.html")) return; + const p = parsed.searchParams; + const target = String(p.get("host") || "").toLowerCase(); + const rest = p.get("resturl") || "/"; + if (!target) return; + if (p.get("choice") === "continue") rememberBlockOverride(p.get("name") || target); + return navigateTab(id, target + rest); + } // A wiz:// click never navigates — it hands the pairing URI to the // wallet and leaves the dapp exactly where it is. if (parsed.protocol === "wiz:") { @@ -4842,6 +4937,23 @@ async function loadBns(t, id, host, rest, tld) { if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); } } + // ---- Blocklist --------------------------------------------------------- + // A subscribed list flags this name: show the warning instead of the site. + // The page posts "continue" back through bns://block-choose/ (will-navigate). + const block = blockDecision(entry, host); + if (block) { + setLoading(t, false); + const q = new URLSearchParams({ + host, name: block.flag.name, reason: block.flag.reason, list: block.flag.source || "", + report: block.flag.reportTxid || "", resturl: rest, proceed: block.canContinue ? "1" : "0", + }).toString(); + await t.view.webContents.loadFile(path.join(__dirname, "blocked.html"), { search: q }); + t.prov = { host, kind: "resolving", tld, registry }; + if (id === activeId) pushNav(t.prov); + emitTabs(); + return; + } + await t.view.webContents.loadURL(`bns://${host}${rest}`); // Source badge must mirror what serveBns actually picks — subdomain-with-ip // routes via the parent's server, not via Sia. See serveBns for the rule. @@ -6793,6 +6905,22 @@ ipcMain.handle("collision-set-policy", (_e, p) => { return settings.collisionPolicy; }); ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; }); +// Blocklists: what the subscribed lists flag, the policy, and the names the +// user chose to continue to. +ipcMain.handle("blocklist-state", () => ({ + policy: settings.blocklistPolicy || "warn", + lists: blocklistInfo.lists, + readAt: blocklistInfo.at, + ok: blocklistInfo.ok, + error: blocklistInfo.error, + flagged: [...blockFlags.values()].map((f) => ({ name: f.name, reason: f.reason, source: f.source, reportTxid: f.reportTxid })), + overrides: Object.keys(blockOverrides.byName), +})); +ipcMain.handle("blocklist-set-policy", (_e, p) => { + if (["warn", "refuse", "off"].includes(p)) { settings.blocklistPolicy = p; saveSettings(); } + return settings.blocklistPolicy; +}); +ipcMain.handle("blocklist-reset", () => { blockOverrides = { byName: {} }; saveBlockOverrides(); return true; }); // Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe // as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge"). // Turning them off means non-Theseus browsers stop resolving BCDN names on @@ -8513,6 +8641,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { loadBookmarks(); loadHistory(); loadCollisions(); + loadBlockOverrides(); loadWalletPermissions(); applyPermissions(); applyEmbedCookieShim(); @@ -8626,4 +8755,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); } -module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow }; +module.exports = { + serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow, + // dev/blocklist-selftest.js: lets the harness stand in for the indexer + // process (which is what normally delivers the flags) and read decisions. + _blocklistTest: { + setFlags: (flags) => onIndexerMessage({ type: "blocklist", ok: true, at: Date.now(), lists: [], flags }), + setPolicy: (p) => { settings.blocklistPolicy = p; }, + decision: (entry, host) => blockDecision(entry, host), + remember: (name) => rememberBlockOverride(name), + forget: () => { blockOverrides = { byName: {} }; }, + }, +}; diff --git a/package.json b/package.json index 42f332a3..0fe8e66b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "link-status.html", "link-status-preload.js", "collision.html", + "blocked.html", "collision-preload.js", "messages.html", "messages-preload.js", diff --git a/settings-preload.js b/settings-preload.js index c51408d4..97fd9503 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("cfg", { collisionState: () => ipcRenderer.invoke("collision-state"), setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p), resetCollisions: () => ipcRenderer.invoke("collision-reset"), + // Blocklists: flagged names, the policy, and the "continue anyway" choices + blocklistState: () => ipcRenderer.invoke("blocklist-state"), + setBlocklistPolicy: (p) => ipcRenderer.invoke("blocklist-set-policy", p), + resetBlocklist: () => ipcRenderer.invoke("blocklist-reset"), // Add-ons management (Settings > Add-ons tab). listAddons: () => ipcRenderer.invoke("addons-list"), setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled), diff --git a/settings.html b/settings.html index 064955cf..48966758 100644 --- a/settings.html +++ b/settings.html @@ -346,6 +346,17 @@
+
+
Flagged names
+
A blocklist is a public, on-chain list of names reported as dangerous: phishing, impersonation, malware, financial scams. A flagged name is still registered; this decides what Theseus does before opening one.
+
+ + + +
+
+
+

About

Theseus Navigator is free software
@@ -1746,6 +1757,26 @@ C.resetCollisions().then(refreshCollisions); }; + // ---- Naming section: blocklists ---- + function refreshBlocklist() { + C.blocklistState().then((b) => { + document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => { r.checked = (r.value === b.policy); }); + const lists = (b.lists || []).map((l) => l.name || l.address).join(", ") || "none"; + const n = (b.flagged || []).length, o = (b.overrides || []).length; + const read = b.ok === null ? "not read yet" + : b.ok ? `read ${new Date(b.readAt).toLocaleTimeString()}` + : `could not be read (${b.error || "unreachable"}); the last known flags stay in force`; + document.getElementById("blkSummary").textContent = + `Subscribed: ${lists} — ${read}. Flagged now: ${n} name${n === 1 ? "" : "s"}. ` + + `You chose to continue to ${o} name${o === 1 ? "" : "s"}.`; + }).catch(() => {}); + } + refreshBlocklist(); + document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => { + r.addEventListener("change", () => { if (r.checked) C.setBlocklistPolicy(r.value).then(refreshBlocklist); }); + }); + document.getElementById("resetBlocklist").onclick = () => { C.resetBlocklist().then(refreshBlocklist); }; + // ---- Developer tools dock position -------------------------------------- // The active radio reflects the current setting; changing it just calls // C.set — the F12 handler in main.js reads settings.devToolsDock each