Collision modes (BCNR/ICANN) + root TLD cert + VPS electrum-source indexer

Theseus soft-mode UX: 'Open with...' modal on collision, per-name/per-TLD
overrides, live per-tab switcher in the site-info popover, and a Naming section
in Settings for policy + reset. Backed by an on-chain root TLD certificate
(tlds.bch) that resolver-web.js discovers via fetchBcnrTlds()/isBcnrNativeTld().

Companion pieces:
- Argus/src/indexer/ELECTRUM-SOURCE-README.md — the featherweight VPS variant
  (no BCHN node, no Fulcrum) now live as bns-indexer.service.
- Argus/DESIGN-root-tld-cert.md — clarified: NOT a governance workflow, just
  ordinary key management (single wallet MVP -> 2-of-3 multisig). List gates
  registration / surgical NRPT / soft-mode classifier — never resolution.
- ROADMAP-IDEAS.md — recorded SiaGit/GitHub.sia + user-friendly Sia UI ideas.

Full spec: Argus/DESIGN-collision-modes.md (already tracked).
This commit is contained in:
Local Dev 2026-08-02 11:39:00 +02:00
parent 14fabac7fc
commit d0db4ac495
9 changed files with 568 additions and 54 deletions

7
collision-preload.js Normal file
View file

@ -0,0 +1,7 @@
// Minimal, sandboxed IPC bridge for the "Open with…" collision prompt window.
// Only exposes the exact call the page needs: send the user's choice back to
// the main process (which awaits it via ipcMain.once).
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("collisionApi", {
choose(host, payload) { ipcRenderer.send(`collision-choose:${host}`, payload); },
});

94
collision.html Normal file
View file

@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Open with…</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: dark light;
--bg:#0f1116; --fg:#e5e7eb; --muted:#9ca3af; --border:#2a2f3a;
--btn:#1f2430; --btn-hover:#2a3040; --accent:#f5c518; --icann:#4c9eff;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f7f7f8; --fg:#111827; --muted:#6b7280; --border:#e5e7eb; --btn:#fff; --btn-hover:#f0f2f5; }
}
* { box-sizing: border-box; }
html, body { margin:0; height:100%; }
body { background:var(--bg); color:var(--fg); font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; display:flex; flex-direction:column; padding:16px 18px; }
h1 { margin:0 0 4px; font-size:15px; font-weight:600; }
.sub { color:var(--muted); font-size:12.5px; margin-bottom:14px; }
.host { color:var(--fg); font-weight:600; }
.choices { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px; }
.choice { border:1px solid var(--border); border-radius:8px; padding:12px; cursor:pointer; background:var(--btn); text-align:left; color:var(--fg); }
.choice:hover { background:var(--btn-hover); }
.choice .icon { font-size:20px; }
.choice .title { font-weight:600; margin:6px 0 2px; }
.choice .desc { font-size:11.5px; color:var(--muted); }
.choice.bcnr .icon, .choice.bcnr .title { color:var(--accent); }
.choice.icann .icon, .choice.icann .title { color:var(--icann); }
fieldset { border:0; padding:0; margin:0 0 10px; }
fieldset .row { display:flex; align-items:center; gap:8px; padding:4px 0; font-size:12.5px; color:var(--muted); }
fieldset .row input { accent-color:var(--accent); }
.actions { display:flex; justify-content:flex-end; gap:8px; margin-top:auto; }
button.cancel { background:transparent; color:var(--muted); border:0; padding:8px 12px; cursor:pointer; }
button.cancel:hover { color:var(--fg); }
kbd { border:1px solid var(--border); border-radius:3px; padding:0 4px; font:11px monospace; color:var(--muted); }
</style>
</head>
<body>
<h1><span class="host" id="host"></span> exists in two registries</h1>
<div class="sub">Choose which one to open. You can change this later in Settings.</div>
<div class="choices">
<button class="choice bcnr" data-choice="bcnr" title="Open the on-chain name">
<div class="icon"></div>
<div class="title">Bitcoin Cash Name Registry</div>
<div class="desc">On-chain registration (BCNR). Owned by the holder of a name certificate on the Bitcoin Cash Blockchain.</div>
</button>
<button class="choice icann" data-choice="icann" title="Open the ICANN-served address">
<div class="icon">🌐</div>
<div class="title">ICANN / IANA</div>
<div class="desc">DNS service coordinated by the ICANN. The registry served by the incumbent root.</div>
</button>
</div>
<fieldset>
<div class="row"><input type="radio" name="remember" id="rem-no" value="no" checked><label for="rem-no">Ask again</label></div>
<div class="row"><input type="radio" name="remember" id="rem-name" value="name"><label for="rem-name">Always use this choice for <span class="host" id="host2"></span></label></div>
<div class="row"><input type="radio" name="remember" id="rem-tld" value="tld"><label for="rem-tld">Always use this choice for all <span id="tld2"></span> names</label></div>
</fieldset>
<div class="actions">
<button class="cancel" id="cancel">Cancel <kbd>Esc</kbd></button>
</div>
<script>
const qs = new URLSearchParams(location.search);
const host = qs.get("host") || ""; const tld = qs.get("tld") || "";
document.getElementById("host").textContent = host;
document.getElementById("host2").textContent = host;
document.getElementById("tld").textContent = "." + tld;
document.getElementById("tld2").textContent = "." + tld;
document.title = "Open " + host + " with…";
function remember() {
const r = document.querySelector('input[name="remember"]:checked');
return r ? r.value : "no";
}
for (const btn of document.querySelectorAll(".choice")) {
btn.addEventListener("click", () => {
window.collisionApi.choose(host, { choice: btn.dataset.choice, remember: remember() });
});
}
document.getElementById("cancel").addEventListener("click", () => {
window.collisionApi.choose(host, { choice: "cancel", remember: "no" });
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") window.collisionApi.choose(host, { choice: "cancel", remember: "no" });
if (e.key === "1") window.collisionApi.choose(host, { choice: "bcnr", remember: remember() });
if (e.key === "2") window.collisionApi.choose(host, { choice: "icann", remember: remember() });
});
</script>
</body>
</html>

214
main.js
View file

@ -4,7 +4,7 @@
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme } = require("electron");
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell } = require("electron");
const path = require("path");
const http = require("http");
const https = require("https");
@ -21,27 +21,29 @@ const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
const RESOLVER = app.isPackaged
? path.join(RES_DIR, "resolver-web.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
// Built-in search engines. Users can also add their own (settings.customEngines,
// Built-in engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own).
// fav = the domain to load a real favicon from; sym = emoji fallback.
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
// The picker + settings render each kind in its own section; kind stays a
// display-only grouping (URL routing is the same for both).
const SEARCH_ENGINES = {
duckduckgo: { name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
brave: { name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
yandex: { name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
ecosia: { name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
mojeek: { name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
searxng: { name: "SearXNG", sym: "🧩", fav: "searx.be", url: (q) => "https://searx.be/search?q=" + encodeURIComponent(q) },
wikipedia: { name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
perplexity: { name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
// AI / LLM answer engines (accept a URL query and answer it directly).
chatgpt: { name: "ChatGPT", sym: "🤖", fav: "chatgpt.com", url: (q) => "https://chatgpt.com/?q=" + encodeURIComponent(q) },
claude: { name: "Claude", sym: "✳️", fav: "claude.ai", url: (q) => "https://claude.ai/new?q=" + encodeURIComponent(q) },
phind: { name: "Phind", sym: "🧑‍💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
you: { name: "You.com", sym: "🟣", fav: "you.com", url: (q) => "https://you.com/search?q=" + encodeURIComponent(q) + "&tbm=youchat" },
duckduckgo: { kind: "search", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { kind: "search", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
brave: { kind: "search", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { kind: "search", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { kind: "search", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
yandex: { kind: "search", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
ecosia: { kind: "search", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
mojeek: { kind: "search", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
searxng: { kind: "search", name: "SearXNG", sym: "🧩", fav: "searx.be", url: (q) => "https://searx.be/search?q=" + encodeURIComponent(q) },
wikipedia: { kind: "search", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
perplexity: { kind: "llm", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
phind: { kind: "llm", name: "Phind", sym: "🧑‍💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
};
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
@ -55,9 +57,9 @@ function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).inc
// enabledEngines; custom engines are always enabled.
function allEngines() {
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), builtin: true, enabled: isEnabled(id) }));
({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", builtin: true, enabled: isEnabled(id) }));
for (const c of settings.customEngines || [])
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), builtin: false, enabled: true });
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", builtin: false, enabled: true });
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
const order = settings.engineOrder || [];
return list.slice().sort((a, b) => {
@ -518,7 +520,7 @@ async function ensureIndex(force = false) {
if (!force && sharedIndex && Date.now() - indexBuiltAt < INDEX_TTL) return sharedIndex;
if (indexBuilding) return indexBuilding; // dedupe concurrent builds
indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool })
.then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); return idx; })
.then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; })
.finally(() => { indexBuilding = null; });
// If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return (sharedIndex && !force) ? sharedIndex : indexBuilding;
@ -584,6 +586,13 @@ const POP_W = 360; let popH = 210; // popH is updated to fit the popover's conte
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
const EP_W = 250; let epH = 320;
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
const DL_W = 340; let dlH = 240;
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id);
@ -591,9 +600,17 @@ const activeTab = () => tabById(activeId);
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
// Enrich prov with a "collision candidate?" flag so the popover switcher can
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
function decorate(prov) {
if (!prov || !prov.tld) return prov;
return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) };
}
function pushNav(prov) {
chrome?.webContents.send("nav", prov);
if (popVisible) popover?.webContents.send("site-info", prov);
const p = decorate(prov);
chrome?.webContents.send("nav", p);
if (popVisible) popover?.webContents.send("site-info", p);
}
function layout() {
@ -604,6 +621,7 @@ function layout() {
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH });
positionPopover();
positionEnginePicker();
positionDownloads();
}
function positionPopover() {
if (!popover) return;
@ -619,7 +637,7 @@ function showPopover(show) {
win.contentView.removeChildView(popover);
win.contentView.addChildView(popover);
popover.setVisible(true); popVisible = true;
popover.webContents.send("site-info", activeTab()?.prov || { kind: "home" });
popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" });
} else { popover.setVisible(false); popVisible = false; }
}
function positionEnginePicker() {
@ -638,6 +656,66 @@ function showEnginePicker(show) {
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
} else { enginePicker.setVisible(false); epVisible = false; }
}
function positionDownloads() {
if (!downloadsPop) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(dlPos.x, width - DL_W - 6));
downloadsPop.setBounds({ x, y: dlPos.y, width: DL_W, height: dlH });
}
function showDownloads(show) {
if (!downloadsPop) return;
if (show) {
positionDownloads();
win.contentView.removeChildView(downloadsPop);
win.contentView.addChildView(downloadsPop);
downloadsPop.setVisible(true); dlVisible = true;
downloadsPop.webContents.send("downloads", downloadsPublic());
} else { downloadsPop.setVisible(false); dlVisible = false; }
}
// Public view of a download — no DownloadItem refs leak to the renderer.
const downloadsPublic = () => downloads.map((d) => ({ ...d }));
function emitDownloads() {
const pub = downloadsPublic();
try { chrome?.webContents.send("downloads", pub); } catch {}
if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {}
}
// Attach the will-download listener to the SHARED default session. Every tab's
// WebContents inherits it, so we catch downloads regardless of which tab
// initiated them (including anchor clicks with `download`, form posts serving
// attachments, and manual save-as gestures).
function installDownloadTracker() {
session.defaultSession.on("will-download", (_e, item /*, wc */) => {
const id = nextDlId++;
const rec = {
id,
filename: item.getFilename(),
url: item.getURL(),
mime: item.getMimeType(),
total: item.getTotalBytes() || 0,
received: 0,
state: "progressing", // progressing | paused | completed | cancelled | interrupted
savePath: "",
startedAt: Date.now(),
};
downloads.unshift(rec);
dlItems.set(id, item);
emitDownloads();
item.on("updated", (_ev, state) => {
rec.state = state; // "progressing" | "interrupted"
rec.received = item.getReceivedBytes();
rec.total = item.getTotalBytes() || rec.total;
rec.savePath = item.getSavePath() || rec.savePath;
emitDownloads();
});
item.once("done", (_ev, state) => {
rec.state = state; // "completed" | "cancelled" | "interrupted"
rec.received = item.getReceivedBytes();
rec.savePath = item.getSavePath() || rec.savePath;
dlItems.delete(id);
emitDownloads();
});
});
}
function setActive(id) {
activeId = id;
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
@ -754,6 +832,12 @@ function createWindow() {
win.contentView.addChildView(enginePicker);
enginePicker.webContents.loadFile("engine-picker.html");
enginePicker.setVisible(false);
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(downloadsPop);
downloadsPop.webContents.loadFile("downloads.html");
downloadsPop.setVisible(false);
chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
@ -811,6 +895,29 @@ async function loadBns(t, id, host, rest, tld) {
catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
t.url = host + (rest === "/" ? "" : rest);
if (!entry) { setLoading(t, false); return fallbackToWeb("no BCNR record"); }
// ---- Collision policy (BCNR ↔ ICANN) --------------------------------------
// BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's,
// no collision possible). Otherwise it's a collision candidate — the same name
// *might* also exist on ICANN; the policy decides which to load.
// Full model: SilentMode/Argus/DESIGN-collision-modes.md.
if (!isBcnrNativeTld(tld)) {
const policy = settings.collisionPolicy || "bcnr-first";
let choice = overrideFor(host, tld); // per-name > per-TLD > null
if (!choice) {
if (policy === "icann-first") choice = "icann";
else if (policy === "soft") {
setLoading(t, false); // hide the loading indicator while asking
const pick = await collisionPromptOnce({ host, tld });
if (pick.choice === "cancel") { emitTabs(); return; } // user cancelled
choice = pick.choice;
rememberCollision(host, tld, choice, pick.remember);
setLoading(t, true);
} else choice = "bcnr"; // bcnr-first — the default
}
if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); }
}
await t.view.webContents.loadURL(`bns://${host}${rest}`);
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
@ -837,6 +944,30 @@ ipcMain.handle("toggle-site-info", (_e, rect) => {
});
ipcMain.handle("close-site-info", () => showPopover(false));
ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); });
// ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
ipcMain.handle("collision-switch", async (_e, arg) => {
const t = activeTab(); if (!t?.prov?.host) return null;
const host = t.prov.host, tld = tldOf(host);
const choice = arg?.choice === "bcnr" || arg?.choice === "icann"
? arg.choice
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
rememberCollision(host, tld, choice, arg?.remember || "no");
return navigateTab(t.id, host + (t.prov.path || "/"));
});
ipcMain.handle("collision-state", () => ({
policy: settings.collisionPolicy,
byName: collisions.byName,
byTld: collisions.byTld,
bcnrTlds,
}));
ipcMain.handle("collision-set-policy", (_e, p) => {
if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); }
return settings.collisionPolicy;
});
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
ipcMain.handle("set-search-engine", (_e, id) => {
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
@ -888,6 +1019,39 @@ ipcMain.handle("pick-engine", (_e, id) => {
showEnginePicker(false);
});
ipcMain.handle("picker-open-settings", () => { showEnginePicker(false); const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
// ---- Downloads --------------------------------------------------------------
ipcMain.handle("downloads-get", () => downloadsPublic());
ipcMain.handle("toggle-downloads", (_e, rect) => {
if (dlVisible) return showDownloads(false);
if (rect) dlPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showDownloads(true);
});
ipcMain.handle("close-downloads", () => showDownloads(false));
ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); });
ipcMain.handle("download-open", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
});
ipcMain.handle("download-show", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.savePath) { try { shell.showItemInFolder(d.savePath); } catch {} }
});
ipcMain.handle("download-cancel", (_e, id) => {
const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} }
});
// Only clear finished downloads; a progressing one is cancelled first.
ipcMain.handle("download-clear", (_e, id) => {
const i = downloads.findIndex((x) => x.id === id);
if (i < 0) return;
if (downloads[i].state === "progressing") { const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } }
downloads.splice(i, 1); dlItems.delete(id);
emitDownloads();
});
ipcMain.handle("downloads-clear-all", () => {
// Keep any still-progressing ones; drop everything else.
for (let i = downloads.length - 1; i >= 0; i--) if (downloads[i].state !== "progressing") downloads.splice(i, 1);
emitDownloads();
});
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain.handle("add-detected-engine", () => {
const d = activeTab()?.detected;
@ -942,9 +1106,11 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
loadSettings();
applyTheme();
loadBookmarks();
loadCollisions();
applyPermissions();
applyAcceptLanguage();
protocol.handle("bns", serveBns);
installDownloadTracker();
createWindow();
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });

View file

@ -36,6 +36,8 @@
"popover-preload.js",
"engine-picker.html",
"engine-picker-preload.js",
"downloads.html",
"downloads-preload.js",
"collision.html",
"collision-preload.js",
"package.json",

View file

@ -3,4 +3,7 @@ contextBridge.exposeInMainWorld("pop", {
onData: (cb) => ipcRenderer.on("site-info", (_e, d) => cb(d)),
close: () => ipcRenderer.invoke("close-site-info"),
resize: (h) => ipcRenderer.invoke("popover-resize", h),
// Live switcher: flip the active tab between BCNR and ICANN for this host,
// optionally remembering the choice per-name / per-TLD.
switchTo: (choice, remember) => ipcRenderer.invoke("collision-switch", { choice, remember }),
});

View file

@ -22,6 +22,17 @@
.details { border-top: 1px solid #ffffff12; padding: 11px 16px 13px; display: grid; grid-template-columns: auto 1fr; gap: 7px 14px; font-size: 12px; }
.details .k { color: #7f8aa0; } .details .v { color: #e7eaf1; }
.details .v code { font-family: ui-monospace, monospace; color: #bfeae4; word-break: break-all; }
/* Collision switcher — only shown when the host could be BCNR OR ICANN */
.switcher { border-top: 1px solid #ffffff12; padding: 10px 16px 12px; font-size: 12px; }
.switcher .title { color: #7f8aa0; margin-bottom: 6px; }
.switcher .row { display: flex; gap: 6px; }
.switcher button { flex: 1; background: transparent; color: #e7eaf1; border: 1px solid #ffffff26; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-size: 12px; }
.switcher button:hover { background: #ffffff10; }
.switcher button.active { background: #f5c51820; border-color: #f5c518; color: #f5c518; cursor: default; }
.switcher button.active.icann { background: #4c9eff20; border-color: #4c9eff; color: #4c9eff; }
.switcher .rem { display: flex; gap: 10px; margin-top: 8px; color: #7f8aa0; }
.switcher .rem label { display: flex; gap: 4px; align-items: center; cursor: pointer; }
.switcher .rem input { accent-color: #f5c518; }
/* light theme (placed last so it wins over the dark base rules) */
@media (prefers-color-scheme: light) {
.card { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; }
@ -31,6 +42,12 @@
.details .v code { color: #0a7d73; }
.hero.neutral .ico svg { stroke: #697280; } .hero.neutral .ico .bdy { fill: #697280; }
.hero .ico .kh { fill: #ffffff; }
.switcher { border-top-color: rgba(0,0,0,.10); }
.switcher .title, .switcher .rem { color: #7b8494; }
.switcher button { color: #1a1f28; border-color: rgba(0,0,0,.15); }
.switcher button:hover { background: rgba(0,0,0,.05); }
.switcher button.active { background: #f5c51830; }
.switcher button.active.icann { background: #4c9eff30; }
}
</style></head>
<body>
@ -41,6 +58,18 @@
<div><div class="status" id="status"></div><div class="host" id="host"></div><div class="sub" id="sub"></div></div>
</div>
<div class="details" id="details"></div>
<div class="switcher" id="switcher" style="display:none">
<div class="title">Open this name with</div>
<div class="row">
<button id="sw-bcnr" data-choice="bcnr">⛓ BCNR</button>
<button id="sw-icann" data-choice="icann" class="icann">🌐 ICANN</button>
</div>
<div class="rem">
<label><input type="radio" name="sw-rem" value="no" checked> Just this once</label>
<label><input type="radio" name="sw-rem" value="name"> Remember for <b id="sw-host"></b></label>
<label><input type="radio" name="sw-rem" value="tld"> All <b id="sw-tld"></b></label>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
@ -78,7 +107,25 @@
}
hero.className = cls; $("ico").innerHTML = ico; $("status").textContent = status; $("sub").innerHTML = sub;
$("details").innerHTML = details; $("details").style.display = details ? "grid" : "none";
// ---- collision switcher: only visible when this host is a collision candidate
// (loaded via BCNR or ICANN under a non-BCNR-native TLD) so flipping is meaningful.
const sw = $("switcher"), isCand = d && d.host && d.tld && (d.kind === "ok" || d.kind === "web") && !d.bcnrNativeTld;
if (isCand) {
const active = d.kind === "ok" ? "bcnr" : "icann";
$("sw-bcnr").classList.toggle("active", active === "bcnr");
$("sw-icann").classList.toggle("active", active === "icann");
$("sw-host").textContent = d.host;
$("sw-tld").textContent = "." + d.tld;
sw.style.display = "block";
} else { sw.style.display = "none"; }
requestAnimationFrame(() => { try { window.pop.resize(document.querySelector(".card").offsetHeight); } catch (e) {} });
});
// Live switcher — click the OTHER registry to flip the tab; the active one is a no-op.
for (const id of ["sw-bcnr", "sw-icann"]) $(id).onclick = (e) => {
const b = e.currentTarget; if (b.classList.contains("active")) return;
const rem = document.querySelector('input[name="sw-rem"]:checked')?.value || "no";
window.pop.switchTo(b.dataset.choice, rem);
window.pop.close();
};
</script>
</body></html>

View file

@ -29,4 +29,12 @@ contextBridge.exposeInMainWorld("theseus", {
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
// Collision-mode (BCNR ↔ ICANN) live switcher for the active tab
collisionSwitch: (arg) => ipcRenderer.invoke("collision-switch", arg),
collisionState: () => ipcRenderer.invoke("collision-state"),
// Downloads — the toolbar button subscribes to `downloads` to update its
// badge, and toggleDownloads opens/closes the floating panel.
getDownloads: () => ipcRenderer.invoke("downloads-get"),
toggleDownloads: (rect) => ipcRenderer.invoke("toggle-downloads", rect),
onDownloads: (cb) => ipcRenderer.on("downloads", (_e, d) => cb(d)),
});

View file

@ -7,4 +7,8 @@ contextBridge.exposeInMainWorld("cfg", {
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
setEngineOrder: (ids) => ipcRenderer.invoke("set-engine-order", ids),
// Collision-mode: BCNR/ICANN policy + per-name/per-TLD overrides
collisionState: () => ipcRenderer.invoke("collision-state"),
setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p),
resetCollisions: () => ipcRenderer.invoke("collision-reset"),
});

View file

@ -2,7 +2,7 @@
<html lang="en">
<head><meta charset="utf-8"><title>Theseus — Settings</title>
<style>
:root{ color-scheme: light dark; --bg:#0b0e14; --panel:#141a24; --line:rgba(255,255,255,.09);
:root{ --bg:#0b0e14; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; }
*{box-sizing:border-box}
body{margin:0;height:100vh;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
@ -25,16 +25,57 @@
.row .txt{flex:1}
.row .t{font-weight:600}
.row .d{color:var(--mut);font-size:13px;margin-top:2px}
.ctl{display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex:none}
/* 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%}
.ctl .coords{display:flex;gap:6px}
select,.ctl input{background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;outline:none}
select,.ctl input{background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;outline:none;min-width:140px}
select:focus,.ctl input:focus{border-color:#4b7bec}
.ctl input{width:150px} .ctl .coords input{width:92px}
.ctl input{width:170px} .ctl .coords input{width:92px;min-width:auto}
/* Chromium's native <select> popup uses the page's color-scheme; when it's
"light dark" (both accepted) Chromium picks by the OS, so a dark-theme app
on a light OS shows a light popup. main.js's applyTheme() maps to
nativeTheme.themeSource, which drives prefers-color-scheme — so pinning
color-scheme via that media query keeps the popup in sync automatically. */
:root { color-scheme: dark; }
@media (prefers-color-scheme: light) { :root { color-scheme: light; } }
/* Explicit option styling — Chromium respects it in the popup on Windows. */
select option { background: #1b2330; color: var(--ink); }
@media (prefers-color-scheme: light) { select option { background: #f1f3f7; color: #1a1f28; } }
code{font-family:ui-monospace,monospace;font-size:12px;background:#0e131b;border:1px solid var(--line);border-radius:5px;padding:1px 5px;color:#bfeae4}
.addeng{display:flex;gap:6px} .addeng input{flex:1;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;outline:none}
.addeng input:focus{border-color:#4b7bec}
.btn{border:1px solid #d6ff3d55;background:rgba(214,255,61,.12);color:#eaffb0;border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer}
.btn:hover{background:rgba(214,255,61,.22)}
/* segmented control — small toggle used elsewhere (kept for reuse) */
.segseg{display:inline-flex;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:2px;gap:2px}
.segseg .seg{background:transparent;color:var(--mut);border:none;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;font:inherit;line-height:1.2}
.segseg .seg:hover{color:var(--ink)}
.segseg .seg.on{background:#1c2432;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)}
/* theme cards — three visual previews (System / Light / Dark) */
.themeCards{display:flex;gap:14px;margin:6px 0 4px;flex-wrap:wrap}
.themeCards .tc{background:transparent;border:2px solid var(--line);border-radius:10px;padding:10px;
display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;color:var(--ink);
font:inherit;font-size:13px;min-width:150px;transition:border-color .12s}
.themeCards .tc:hover{border-color:#4b7bec55}
.themeCards .tc.on{border-color:#4b7bec;box-shadow:0 0 0 1px #4b7bec inset}
.themeCards .mock{width:130px;height:82px;border-radius:6px;overflow:hidden;display:block;position:relative;
border:1px solid rgba(255,255,255,.08)}
.themeCards .mock .mm-chrome{position:absolute;left:0;right:0;top:0;height:22px;display:block}
.themeCards .mock .mm-chrome::before{content:"";position:absolute;left:8px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6768a}
.themeCards .mock .mm-chrome::after {content:"";position:absolute;left:22px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6c15c;box-shadow:14px 0 0 #6ec27d}
.themeCards .mock .mm-body{position:absolute;left:0;right:0;top:22px;bottom:0;display:block}
.themeCards .mock-light .mm-chrome{background:#e9ecf2}
.themeCards .mock-light .mm-body {background:#ffffff;background-image:linear-gradient(#0000000c 1px,transparent 1px);background-size:100% 12px;background-position:0 10px}
.themeCards .mock-dark .mm-chrome{background:#141b28}
.themeCards .mock-dark .mm-body {background:#0b0e14;background-image:linear-gradient(#ffffff10 1px,transparent 1px);background-size:100% 12px;background-position:0 10px}
.themeCards .mock-system .mm-chrome{background:linear-gradient(90deg,#141b28 0 50%,#e9ecf2 50% 100%)}
.themeCards .mock-system .mm-body{background:linear-gradient(90deg,#0b0e14 0 50%,#ffffff 50% 100%)}
.themeCards .tc-label{font-weight:500;color:var(--ink)}
.polrow{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
.polrow:hover{background:#141c28}
.polrow input{accent-color:#d6ff3d}
.pmuted{color:var(--mut)}
.ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.ceng .cs{font-size:14px;flex:none}
/* engine checklist */
@ -48,6 +89,19 @@
.eng .grip:active{cursor:grabbing}
.eng.dragging{opacity:.45}
.eng.over{border-color:var(--acid);box-shadow:0 -2px 0 var(--acid) inset}
.ehdr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;padding-top:2px}
.ehdr:first-child{margin-top:0}
/* engine catalog panel — appears under the enabled list when "+ Add" is clicked */
.engcat{margin-top:6px;padding:12px 12px 10px;background:#0e131c;border:1px solid var(--line);border-radius:10px}
.engcat .cat{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;font-size:13px}
.engcat .cat:hover{background:#141b26}
.engcat .cat .eic{width:18px;height:18px;flex:none;display:grid;place-items:center}
.engcat .cat .eic .ei{width:16px;height:16px;border-radius:3px} .engcat .cat .eic .es{font-size:14px}
.engcat .cat .enm{flex:1}
.engcat .cat .kind{font-size:10.5px;letter-spacing:.05em;color:var(--dim);padding:1px 6px;border:1px solid var(--line);border-radius:999px}
.engcat .cat .add{background:transparent;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer}
.engcat .cat .add:hover{background:rgba(214,255,61,.14);border-color:#d6ff3d55;color:#eaffb0}
.engcat .cempty2{color:var(--dim);font-size:12.5px;padding:6px 8px}
.sw.sm{width:38px;height:22px} .sw.sm .knob{width:15px;height:15px} .sw.sm input:checked + .track .knob{transform:translateX(16px)}
.ceng .cn{font-weight:600} .ceng .cu{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.ceng .cx{cursor:pointer;color:#8b98a9;border:none;background:transparent;font-size:13px} .ceng .cx:hover{color:#f6768a}
@ -76,6 +130,8 @@
<nav class="side">
<div class="brand">⛓ Theseus</div>
<a data-sec="general" class="active">General</a>
<a data-sec="search">Search</a>
<a data-sec="naming">Naming</a>
<a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a>
</nav>
@ -84,28 +140,78 @@
<section id="general">
<h1>General</h1>
<p class="lede">Changes apply immediately and are saved for next time.</p>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Startup</h2>
<div class="row">
<div class="txt"><div class="t">Appearance</div><div class="d">Light, dark, or follow your system theme.</div></div>
<div class="ctl"><select id="theme"><option value="dark">Dark</option><option value="light">Light</option><option value="system">System</option></select></div>
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Appearance</h2>
<p class="subd">Choose a theme for the browser. <b>System</b> follows your operating system's light/dark setting.</p>
<div class="themeCards" id="theme" role="radiogroup" aria-label="Theme">
<button type="button" class="tc" data-val="system" role="radio" aria-checked="false">
<span class="mock mock-system"><span class="mm-chrome"></span><span class="mm-body"></span></span>
<span class="tc-label">System</span>
</button>
<button type="button" class="tc" data-val="light" role="radio" aria-checked="false">
<span class="mock mock-light"><span class="mm-chrome"></span><span class="mm-body"></span></span>
<span class="tc-label">Light</span>
</button>
<button type="button" class="tc" data-val="dark" role="radio" aria-checked="false">
<span class="mock mock-dark"><span class="mm-chrome"></span><span class="mm-body"></span></span>
<span class="tc-label">Dark</span>
</button>
</div>
</section>
<!-- SEARCH -->
<section id="search" hidden>
<h1>Search</h1>
<p class="lede">Pick what your address bar and the toolbar dropdown search with.</p>
<div class="row">
<div class="txt"><div class="t">Default search engine</div><div class="d">Used when you type a search into the address bar.</div></div>
<div class="txt"><div class="t">Default search engine</div><div class="d">Used when you type into the address bar.</div></div>
<div class="ctl"><select id="searchEngine"></select></div>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Search engines</div>
<div class="d">Tick which appear in the toolbar's search dropdown. Add your own with a URL containing <code>%s</code> — e.g. <code>https://searx.be/search?q=%s</code>. Theseus also offers to add a site's own search when you visit one.</div></div>
<div class="txt" style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<div>
<div class="t">Additional search engines</div>
<div class="d">These appear in the toolbar dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
</div>
<button id="engAddBtn" class="btn" type="button">+ Add search engine</button>
</div>
<div id="engineList"></div>
<div class="addeng">
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
<input id="engName" placeholder="Name (e.g. SearXNG)">
<input id="engUrl" placeholder="https://example.com/search?q=%s">
<button id="engAdd" class="btn">Add</button>
<!-- Catalog: hidden until "Add" is clicked. Shows built-in engines the user
hasn't enabled + a custom-URL form. -->
<div id="engineCatalog" class="engcat" hidden>
<div class="ehdr">Add from catalog</div>
<div id="catalogList"></div>
<div class="ehdr">Or add a custom URL</div>
<div class="addeng">
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
<input id="engName" placeholder="Name (e.g. SearXNG)">
<input id="engUrl" placeholder="https://example.com/search?q=%s">
<button id="engAdd" class="btn">Add</button>
</div>
</div>
</div>
<div class="row">
<div class="txt"><div class="t">Reopen tabs on launch</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</section>
<!-- NAMING -->
<section id="naming" hidden>
<h1>Naming</h1>
<p class="lede">How Theseus picks between the <b>Bitcoin Cash Name Registry (BCNR)</b> and <b>ICANN</b> when a name exists in both.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Collision policy</div>
<div class="d">A name only exists in both registries when its TLD isn't BCNR-native (e.g. <code>.de</code>). BCNR-native TLDs (<code>.bch</code>, <code>.p2p</code>, …) never conflict.</div></div>
<div id="policyList" style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="collisionPolicy" value="bcnr-first"><span><b>BCNR first</b> <span class="pmuted">— BCNR wins conflicts; falls back to ICANN.</span></span></label>
<label class="polrow"><input type="radio" name="collisionPolicy" value="icann-first"><span><b>ICANN first</b> <span class="pmuted">— ICANN wins conflicts; BCNR fills gaps.</span></span></label>
<label class="polrow"><input type="radio" name="collisionPolicy" value="soft"><span><b>Ask each time</b> <span class="pmuted">— an "Open with…" prompt on conflict, remembered per name.</span></span></label>
</div>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">Remembered choices</div>
<div class="d">"Always use …" picks you made from the switcher or the prompt. Reset them to be asked again.</div></div>
<div id="colSummary" class="pmuted" style="font-size:12.5px"></div>
<div><button id="resetCollisions" class="btn">Reset remembered choices</button></div>
</div>
</section>
<!-- PERFORMANCE -->
@ -211,7 +317,7 @@
<script>
const C = window.cfg;
// sidebar navigation
const sections = ["general", "performance", "privacy"];
const sections = ["general", "search", "naming", "performance", "privacy"];
document.querySelectorAll(".side a").forEach((a) => a.onclick = () => {
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x === a));
for (const s of sections) document.getElementById(s).hidden = (s !== a.dataset.sec);
@ -230,32 +336,70 @@
const engIcon = (e) => e.favicon
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${e.sym || "🔍"}'}))">`
: `<span class="es">${e.sym || "🔍"}</span>`;
const ENGINE_KINDS = [
{ key: "search", label: "Search engines" },
{ key: "llm", label: "AI answer engines" },
];
function renderEngines(d) {
// default engine: pick from the ENABLED engines (those shown in the dropdown)
const enabled = d.engines.filter((e) => e.enabled);
sel.innerHTML = enabled.map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${esc(e.name)}</option>`).join("");
// group the dropdown too, so search and LLM don't intermix
sel.innerHTML = ENGINE_KINDS.map(({ key, label }) => {
const opts = enabled.filter((e) => (e.kind || "search") === key)
.map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${esc(e.name)}</option>`).join("");
return opts ? `<optgroup label="${label}">${opts}</optgroup>` : "";
}).join("");
sel.value = d.current;
// unified checklist: drag a row by its handle to reorder; built-in get an
// enable toggle, custom get a remove ✕.
// Main list = the engines the user has enabled (i.e. actually shown in the
// toolbar dropdown). Everything else lives in the catalog panel below and
// is added via "+ Add". Drag reorders within a kind; toggling off moves an
// engine back to the catalog; custom engines get a ✕ to delete entirely.
const list = document.getElementById("engineList");
list.innerHTML = d.engines.map((e) => `<div class="eng" data-id="${e.id}" draggable="true">` +
const rowFor = (e) => `<div class="eng" data-id="${e.id}" data-kind="${e.kind || "search"}" draggable="true">` +
`<span class="grip" title="Drag to reorder"></span>` +
`<span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` +
(e.builtin
? `<label class="sw sm"><input type="checkbox" data-id="${e.id}" ${e.enabled ? "checked" : ""}><span class="track"><span class="knob"></span></span></label>`
: `<button class="cx" data-id="${e.id}" title="Remove"></button>`) + `</div>`).join("");
: `<button class="cx" data-id="${e.id}" title="Remove"></button>`) + `</div>`;
list.innerHTML = ENGINE_KINDS.map(({ key, label }) => {
const rows = enabled.filter((e) => (e.kind || "search") === key).map(rowFor).join("");
if (!rows) return "";
return `<div class="ehdr">${label}</div>${rows}`;
}).join("");
// Catalog = built-in engines the user has NOT enabled, ready to add.
const cat = document.getElementById("catalogList");
if (cat) {
const off = d.engines.filter((e) => e.builtin && !e.enabled);
const catRow = (e) => `<div class="cat" data-id="${e.id}">` +
`<span class="eic">${engIcon(e)}</span>` +
`<span class="enm">${esc(e.name)}</span>` +
`<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` +
`<button class="add" data-add="${e.id}">+ Add</button></div>`;
cat.innerHTML = off.length
? off.map(catRow).join("")
: `<div class="cempty2">All built-in engines are enabled. Add a custom URL below.</div>`;
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
}
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
list.querySelectorAll(".cx").forEach((b) => b.onclick = () => C.removeEngine(b.dataset.id).then(renderEngines));
// drag-and-drop reorder
// drag-and-drop reorder — same-kind only (dropping a Search engine into
// the LLM section would just re-group visually on next render, so we
// reject cross-kind drags outright).
let dragId = null;
let dragKind = null;
const sameKind = (row) => row.dataset.kind === dragKind;
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); });
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; dragKind = row.dataset.kind; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); });
row.addEventListener("dragend", () => { row.classList.remove("dragging"); list.querySelectorAll(".eng").forEach((r) => r.classList.remove("over")); });
row.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (row.dataset.id !== dragId) row.classList.add("over"); });
row.addEventListener("dragover", (e) => {
if (!sameKind(row)) { e.dataTransfer.dropEffect = "none"; return; }
e.preventDefault(); e.dataTransfer.dropEffect = "move";
if (row.dataset.id !== dragId) row.classList.add("over");
});
row.addEventListener("dragleave", () => row.classList.remove("over"));
row.addEventListener("drop", (e) => {
e.preventDefault(); row.classList.remove("over");
if (!dragId || dragId === row.dataset.id) return;
if (!dragId || dragId === row.dataset.id || !sameKind(row)) return;
const ids = [...list.querySelectorAll(".eng")].map((el) => el.dataset.id);
const from = ids.indexOf(dragId), to = ids.indexOf(row.dataset.id);
ids.splice(from, 1); ids.splice(to, 0, dragId);
@ -264,10 +408,19 @@
});
}
sel.onchange = () => C.set("searchEngine", sel.value);
// appearance (theme)
// appearance (theme) — three visual cards: system | light | dark. Any
// unrecognised saved value falls back to "system" (follow the OS).
const th = document.getElementById("theme");
th.value = s.theme || "dark";
th.onchange = () => C.set("theme", th.value);
let themeValue = ["system", "light", "dark"].includes(s.theme) ? s.theme : "system";
const paintTheme = () => th.querySelectorAll(".tc").forEach((b) => {
const on = b.dataset.val === themeValue;
b.classList.toggle("on", on);
b.setAttribute("aria-checked", on ? "true" : "false");
});
paintTheme();
th.querySelectorAll(".tc").forEach((b) => b.onclick = () => {
themeValue = b.dataset.val; paintTheme(); C.set("theme", themeValue);
});
// WebRTC IP policy
const wm = document.getElementById("webrtcMode");
wm.value = s.webrtcMode || "public_only";
@ -282,6 +435,16 @@
renderEngines(d);
});
};
// "+ Add search engine" toggles the catalog panel below the enabled list.
const catBtn = document.getElementById("engAddBtn");
const catBox = document.getElementById("engineCatalog");
if (catBtn && catBox) {
catBtn.onclick = () => {
const open = catBox.hidden;
catBox.hidden = !open;
catBtn.textContent = open ? " Hide catalog" : "+ Add search engine";
};
}
C.engines().then(renderEngines);
// anti-fingerprinting mode selectors, with value field(s) shown on "manual"
const bind = (mode, showValIf, apply) => {
@ -320,6 +483,26 @@
const la = document.getElementById("locationLat"), lo = document.getElementById("locationLon");
la.value = c[0]; lo.value = c[1]; C.set("locationLat", String(c[0])); C.set("locationLon", String(c[1]));
});
// ---- Naming section: BCNR/ICANN collision policy + remembered choices ----
function refreshCollisions() {
C.collisionState().then((cs) => {
// pick the current radio
document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => { r.checked = (r.value === cs.policy); });
const nn = Object.keys(cs.byName || {}).length, tn = Object.keys(cs.byTld || {}).length;
const bc = (cs.bcnrTlds || []).length;
document.getElementById("colSummary").textContent =
`Remembered: ${nn} name${nn === 1 ? "" : "s"}, ${tn} TLD${tn === 1 ? "" : "s"}. `
+ `BCNR-native TLDs on chain: ${bc}.`;
}).catch(() => {});
}
refreshCollisions();
document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => {
r.addEventListener("change", () => { if (r.checked) C.setCollisionPolicy(r.value).then(refreshCollisions); });
});
document.getElementById("resetCollisions").onclick = () => {
C.resetCollisions().then(refreshCollisions);
};
});
</script>
</body>