Theseus: in-tab collision prompt + toggle-style switcher + auto-switch

Three UX fixes based on operator feedback (2026-08-02):

1) 'Open with…' is now a FULL-PAGE in-tab interstitial (was a modal window).
   - loadBns loads collision.html via loadFile with query params instead of
     opening a BrowserWindow.
   - collision.html is navigation-based: buttons redirect to a special
     bns://collision-choose/?host=…&choice=…&remember=…&resturl=… URL.
   - serveBns handles that URL: persists remember=name/tld choice, then returns
     a meta-refresh to the real target. For BCDN it appends ?_collision=bcnr
     one-shot marker; for ICANN it redirects to https://<host><path>.
   - loadBns strips and honors the ?_collision one-shot marker so BCDN
     redirects don't re-trigger the prompt.
   - Old modal path (collisionPromptOnce) removed.

2) Auto-switch from the address-bar chip: clicking BCDN/ICANN chip sets a
   transient per-tab collisionOverride that loadBns consumes ONCE for this
   navigation only, bypassing the soft-mode prompt entirely.

3) Address-bar toggle: chips stay visible during 'resolving' (no more
   flash-and-reappear); active chip is colored (acid green for BCDN, blue for
   ICANN — matching collision.html's palette); inactive is greyed with border,
   clickable to switch. Feels like a proper toggle.

Settings > Registries copy: operator's exact wording adopted (title stays
'Registries', body uses 'BCNR-unique' term for TLDs that only exist on BCNR).
This commit is contained in:
Local Dev 2026-08-02 14:43:31 +02:00
parent 75e468ae39
commit bf54c245b7
4 changed files with 212 additions and 112 deletions

View file

@ -70,14 +70,17 @@
.secbadge.warn svg .shk { stroke: #f6ad55; } .secbadge.warn svg .bdy { fill: #f6ad55; } .secbadge.warn svg .shk { stroke: #f6ad55; } .secbadge.warn svg .bdy { fill: #f6ad55; }
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; } input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
#url { flex: 1; } #url { flex: 1; }
/* registry indicator at the END of the address bar — one or two chips */ /* registry toggle at the END of the address bar — one or two chips */
#reg { display: inline-flex; gap: 4px; align-items: center; } #reg { display: inline-flex; gap: 4px; align-items: center; }
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 9px; border-radius: 999px; white-space: nowrap; .reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
background: rgba(214,255,61,.13); color: #d6ff3d; border: 1px solid #d6ff3d33; cursor: default; user-select: none; } cursor: default; user-select: none; transition: opacity .15s, background .15s, color .15s, border-color .15s; }
.reg.icann { background: rgba(76,158,255,.13); color: #4c9eff; border-color: rgba(76,158,255,.28); } /* Active BCDN — acid glow green */
/* collision candidate: two chips shown together; inactive one is dim + clickable */ .reg.bcdn.active { background: rgba(214,255,61,.15); color: #d6ff3d; border: 1px solid #d6ff3d55; }
.reg.switch { cursor: pointer; opacity: .55; } /* Active ICANN — blue */
.reg.switch:hover { opacity: 1; } .reg.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border: 1px solid #4c9eff55; }
/* Inactive chip in the toggle — greyed, clickable to switch */
.reg.switch { background: transparent; color: #6b7280; border: 1px solid #ffffff1c; cursor: pointer; }
.reg.switch:hover { color: #b9c2d0; border-color: #ffffff33; }
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; } .star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; } .star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
/* search box: engine icon (click = pick engine) + a wide typing area */ /* search box: engine icon (click = pick engine) + a wide typing area */
@ -258,24 +261,26 @@
} }
function setReg(d) { function setReg(d) {
const r = $("reg"); const r = $("reg");
if (!d || d.kind === "home" || d.kind === "resolving") { r.hidden = true; r.innerHTML = ""; return; } if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; }
// Keep chips visible during "resolving" so the switch feels like a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; }
r.hidden = false; r.hidden = false;
r.className = "reg-wrap"; // container; individual chips carry their own classes // Two chips (toggle) when this host is a collision candidate — could be
// Two chips when this host is a collision candidate — could be BCDN OR ICANN. // BCDN OR ICANN. Active one is coloured; inactive is greyed + clickable.
// The active one is highlighted; the other is clickable to switch.
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false; const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null; const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null;
const bcdn = `<span class="reg${active === "bcdn" ? "" : " switch"}" data-c="bcdn" title="${active === "bcdn" ? "Open with BCDN (current)" : "Switch to BCDN"}">BCDN</span>`;
const icann = `<span class="reg icann${active === "icann" ? "" : " switch"}" data-c="icann" title="${active === "icann" ? "Open with ICANN (current)" : "Switch to ICANN"}">ICANN</span>`;
if (isCand) { if (isCand) {
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`;
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`;
r.innerHTML = bcdn + icann; r.innerHTML = bcdn + icann;
for (const chip of r.querySelectorAll(".reg.switch")) { for (const chip of r.querySelectorAll(".reg.switch")) {
chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" }); chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" });
} }
} else if (d.kind === "web") { } else if (d.kind === "web") {
r.innerHTML = `<span class="reg icann">ICANN</span>`; r.innerHTML = `<span class="reg icann active">ICANN</span>`;
} else { } else {
r.innerHTML = `<span class="reg">BCDN</span>`; r.innerHTML = `<span class="reg bcdn active">BCDN</span>`;
} }
} }

View file

@ -7,104 +7,158 @@
<style> <style>
:root { :root {
color-scheme: dark light; color-scheme: dark light;
--bg:#0f1116; --fg:#e5e7eb; --muted:#9ca3af; --border:#2a2f3a; --bg: #0f1116;
--btn:#1f2430; --btn-hover:#2a3040; --accent:#f5c518; --icann:#4c9eff; --fg: #e5e7eb;
--muted: #9ca3af;
--border: #2a2f3a;
--panel: #131722;
--panel-hover: #181e2b;
--bcdn: #d6ff3d; /* acid glow green (matches address-bar toggle) */
--icann: #4c9eff; /* blue (matches address-bar toggle) */
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { --bg:#f7f7f8; --fg:#111827; --muted:#6b7280; --border:#e5e7eb; --btn:#fff; --btn-hover:#f0f2f5; } :root { --bg:#f7f7f8; --fg:#111827; --muted:#5b6472; --border:#e5e7eb; --panel:#ffffff; --panel-hover:#f0f2f5; --bcdn:#7fa900; --icann:#1f6fd4; }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin:0; height:100%; overflow:hidden; } html, body { margin: 0; min-height: 100%; }
body { background:var(--bg); color:var(--fg); font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; display:flex; flex-direction:column; padding:26px 34px; } body {
h1 { margin:0 0 6px; font-size:18px; font-weight:600; } background: var(--bg); color: var(--fg);
.sub { color:var(--muted); font-size:13.5px; margin-bottom:22px; } font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
.host { color:var(--fg); font-weight:600; } display: flex; flex-direction: column; align-items: center; justify-content: center;
.choices { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:22px; } padding: 40px 24px;
.choice { border:2px solid var(--border); border-radius:10px; padding:18px; background:var(--btn); text-align:left; color:var(--fg); cursor:pointer; transition: border-color .12s, background .12s; } }
.choice:hover { background:var(--btn-hover); } main { width: 100%; max-width: 720px; }
.choice.sel.bcdn { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--btn)); } header { margin-bottom: 28px; }
.choice.sel.icann { border-color: var(--icann); background: color-mix(in srgb, var(--icann) 12%, var(--btn)); } h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: -.01em; }
.choice .head { display:flex; align-items:center; gap:10px; margin-bottom:8px; } .sub { color: var(--muted); font-size: 14px; }
.choice .radio { flex:none; width:16px; height:16px; border-radius:50%; border:2px solid var(--border); position:relative; } .host { color: var(--fg); font-weight: 600; }
.choice.sel.bcdn .radio { border-color: var(--accent); background: radial-gradient(circle, var(--accent) 40%, transparent 42%); }
.choice.sel.icann .radio { border-color: var(--icann); background: radial-gradient(circle, var(--icann) 40%, transparent 42%); } .choices { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 28px; }
.choice .icon { font-size:20px; line-height:1; } @media (max-width: 620px) { .choices { grid-template-columns: 1fr; } }
.choice .title { font-weight:600; font-size:15px; }
.choice.bcdn .title { color: var(--accent); } .choice {
background: var(--panel); border: 2px solid var(--border); border-radius: 12px;
padding: 22px; color: var(--fg); text-align: left; cursor: pointer;
transition: border-color .12s, background .12s, transform .06s;
display: flex; flex-direction: column; gap: 8px;
}
.choice:hover { background: var(--panel-hover); }
.choice:active { transform: scale(.99); }
.choice.sel.bcdn { border-color: var(--bcdn); box-shadow: 0 0 0 1px var(--bcdn), 0 0 22px -8px var(--bcdn); }
.choice.sel.icann { border-color: var(--icann); box-shadow: 0 0 0 1px var(--icann), 0 0 22px -10px var(--icann); }
.choice .head { display: flex; align-items: center; gap: 12px; }
.choice .radio {
flex: none; width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--border); position: relative;
}
.choice.sel.bcdn .radio { border-color: var(--bcdn); background: radial-gradient(circle, var(--bcdn) 40%, transparent 42%); }
.choice.sel.icann .radio { border-color: var(--icann); background: radial-gradient(circle, var(--icann) 40%, transparent 42%); }
.choice .icon { font-size: 22px; line-height: 1; }
.choice .title { font-weight: 600; font-size: 16px; }
.choice.bcdn .title { color: var(--bcdn); }
.choice.icann .title { color: var(--icann); } .choice.icann .title { color: var(--icann); }
.choice .desc { font-size:13px; color:var(--muted); } .choice .desc { font-size: 13.5px; color: var(--muted); }
fieldset { border:0; padding:0; margin:0 0 22px; display:flex; flex-direction:column; gap:8px; }
fieldset legend { color:var(--muted); font-size:12.5px; padding:0; margin-bottom:6px; text-transform:uppercase; letter-spacing:.03em; } fieldset { border: 0; padding: 0; margin: 0 0 28px; display: flex; flex-direction: column; gap: 10px; }
fieldset .row { display:flex; align-items:center; gap:9px; font-size:13.5px; padding:2px 0; } fieldset legend {
fieldset .row input { accent-color:var(--accent); width:15px; height:15px; } color: var(--muted); font-size: 12px; padding: 0; margin-bottom: 8px;
fieldset .row label { color:var(--fg); cursor:pointer; } text-transform: uppercase; letter-spacing: .06em;
.actions { display:flex; justify-content:flex-end; gap:10px; margin-top:auto; } }
button.act { border:1px solid var(--border); background:var(--btn); color:var(--fg); padding:10px 20px; border-radius:8px; font-size:14px; cursor:pointer; } fieldset .row { display: flex; align-items: center; gap: 10px; font-size: 14px; padding: 3px 0; }
button.act:hover { background: var(--btn-hover); } fieldset .row input { accent-color: var(--bcdn); width: 16px; height: 16px; }
button.act.primary { background:var(--accent); color:#111; border-color:var(--accent); font-weight:600; } fieldset .row label { color: var(--fg); cursor: pointer; }
button.act.primary:hover { filter:brightness(1.05); }
kbd { border:1px solid var(--border); border-radius:3px; padding:0 5px; font:11px monospace; color:var(--muted); margin-left:6px; } .actions { display: flex; justify-content: flex-end; gap: 12px; }
button.act {
border: 1px solid var(--border); background: var(--panel); color: var(--fg);
padding: 11px 22px; border-radius: 8px; font-size: 14px; cursor: pointer;
transition: background .1s, filter .1s;
}
button.act:hover { background: var(--panel-hover); }
button.act.primary { background: var(--bcdn); color: #111; border-color: var(--bcdn); font-weight: 600; }
button.act.primary:hover { filter: brightness(1.06); }
kbd { border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; font: 11px monospace; color: var(--muted); margin-left: 6px; }
</style> </style>
</head> </head>
<body> <body>
<h1><span class="host" id="host"></span> exists in two registries</h1> <main>
<div class="sub">Choose which one to open. You can change this later in Settings.</div> <header>
<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>
</header>
<div class="choices" id="choices"> <div class="choices">
<div class="choice bcdn sel" data-choice="bcdn" tabindex="0"> <div class="choice bcdn sel" data-choice="bcnr" tabindex="0">
<div class="head"> <div class="head">
<span class="radio"></span><span class="icon"></span> <span class="radio"></span><span class="icon"></span>
<span class="title">BCNR / BCDN</span> <span class="title">BCNR / BCDN</span>
</div>
<div class="desc">On-chain registration. Owned by the holder of a digital name certificate. The registry served by the blockchain root.</div>
</div> </div>
<div class="desc">On-chain registration. Owned by the holder of a digital name certificate. The registry served by the blockchain root.</div> <div class="choice icann" data-choice="icann" tabindex="0">
</div> <div class="head">
<div class="choice icann" data-choice="icann" tabindex="0"> <span class="radio"></span><span class="icon">🌐</span>
<div class="head"> <span class="title">ICANN / IANA</span>
<span class="radio"></span><span class="icon">🌐</span> </div>
<span class="title">ICANN / IANA</span> <div class="desc">DNS service coordinated by the ICANN. The registry served by the incumbent root.</div>
</div> </div>
<div class="desc">DNS service coordinated by the ICANN. The registry served by the incumbent root.</div>
</div> </div>
</div>
<fieldset> <fieldset>
<legend>Remember this choice</legend> <legend>Remember this choice</legend>
<div class="row"><input type="radio" name="remember" id="rem-no" value="no" checked><label for="rem-no">Ask again next time</label></div> <div class="row"><input type="radio" name="remember" id="rem-no" value="no" checked><label for="rem-no">Ask again next time</label></div>
<div class="row"><input type="radio" name="remember" id="rem-name" value="name"><label for="rem-name">Always use for <span class="host" id="host2"></span></label></div> <div class="row"><input type="radio" name="remember" id="rem-name" value="name"><label for="rem-name">Always use 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 for all <span id="tld"></span> names</label></div> <div class="row"><input type="radio" name="remember" id="rem-tld" value="tld"><label for="rem-tld">Always use for all <span id="tld"></span> names</label></div>
</fieldset> </fieldset>
<div class="actions"> <div class="actions">
<button class="act" id="cancel">Cancel<kbd>Esc</kbd></button> <button class="act" id="cancel">Cancel<kbd>Esc</kbd></button>
<button class="act primary" id="confirm">Open<kbd>Enter</kbd></button> <button class="act primary" id="confirm">Open<kbd>Enter</kbd></button>
</div> </div>
</main>
<script> <script>
const qs = new URLSearchParams(location.search); const qs = new URLSearchParams(location.search);
const host = qs.get("host") || ""; const tld = qs.get("tld") || ""; const host = qs.get("host") || "";
document.getElementById("host").textContent = host; const tld = qs.get("tld") || "";
const resturl = qs.get("resturl") || "/";
document.getElementById("host").textContent = host;
document.getElementById("host2").textContent = host; document.getElementById("host2").textContent = host;
document.getElementById("tld").textContent = "." + tld; document.getElementById("tld").textContent = "." + tld;
document.title = "Open " + host + " with…"; document.title = "Open " + host + " with…";
let choice = "bcdn"; // default preselected let choice = "bcnr";
const bcdnEl = document.querySelector('.choice[data-choice="bcdn"]'); const bcdnEl = document.querySelector('.choice[data-choice="bcnr"]');
const icannEl = document.querySelector('.choice[data-choice="icann"]'); const icannEl = document.querySelector('.choice[data-choice="icann"]');
function select(c) { function select(c) {
choice = c; choice = c;
bcdnEl.classList.toggle("sel", c === "bcdn"); bcdnEl.classList.toggle("sel", c === "bcnr");
icannEl.classList.toggle("sel", c === "icann"); icannEl.classList.toggle("sel", c === "icann");
// primary button colour follows the selected choice
const p = document.getElementById("confirm");
p.style.background = c === "bcnr"
? "var(--bcdn)"
: "var(--icann)";
p.style.borderColor = p.style.background;
p.style.color = c === "bcnr" ? "#111" : "#fff";
} }
select("bcnr"); // initial paint
for (const el of [bcdnEl, icannEl]) { for (const el of [bcdnEl, icannEl]) {
el.addEventListener("click", () => select(el.dataset.choice)); el.addEventListener("click", () => select(el.dataset.choice));
el.addEventListener("keydown", (e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); select(el.dataset.choice); } }); el.addEventListener("keydown", (e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); select(el.dataset.choice); } });
} }
function remember() { return document.querySelector('input[name="remember"]:checked')?.value || "no"; } function remember() { return document.querySelector('input[name="remember"]:checked')?.value || "no"; }
function confirm() { function confirm() {
// Map "bcdn" (user-facing) -> "bcnr" (internal key used by main.js/collisions.json) const q = new URLSearchParams({ host, tld, choice, remember: remember(), resturl });
window.collisionApi.choose(host, { choice: choice === "bcdn" ? "bcnr" : "icann", remember: remember() }); // Navigate to the special bns:// endpoint that main.js's serveBns intercepts:
// it applies the remember choice and redirects to the real target.
location.href = "bns://collision-choose/?" + q.toString();
}
function cancel() {
if (history.length > 1) history.back();
else location.href = "about:blank";
} }
function cancel() { window.collisionApi.choose(host, { choice: "cancel", remember: "no" }); }
document.getElementById("confirm").addEventListener("click", confirm); document.getElementById("confirm").addEventListener("click", confirm);
document.getElementById("cancel").addEventListener("click", cancel); document.getElementById("cancel").addEventListener("click", cancel);
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {

101
main.js
View file

@ -37,7 +37,9 @@ const SEARCH_ENGINES = {
yandex: { kind: "search", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + 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) }, 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) }, 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) }, // SearXNG is federated (dozens of public instances at searx.space); any single
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
// Users who want SearXNG add their preferred instance via the custom URL form.
wikipedia: { kind: "search", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + 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. // 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 // ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
@ -207,25 +209,9 @@ function refreshBcnrTlds(index) {
} catch {} } catch {}
} }
// One-shot modal "Open with…" prompt window. Resolves with the user's pick. // (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the
let collisionWin = null; // prompt is now an in-tab full-page interstitial loaded from collision.html,
function collisionPromptOnce({ host, tld }) { // wired via the bns://collision-choose/ handler in serveBns().)
return new Promise((resolve) => {
if (collisionWin && !collisionWin.isDestroyed()) { try { collisionWin.close(); } catch {} }
const parent = BrowserWindow.getFocusedWindow() || win;
collisionWin = new BrowserWindow({
parent, modal: true, width: 640, height: 520, resizable: false, minimizable: false,
maximizable: false, autoHideMenuBar: true, title: "Open with…", show: false,
webPreferences: { preload: path.join(__dirname, "collision-preload.js"), sandbox: true, contextIsolation: true },
});
collisionWin.loadFile(path.join(__dirname, "collision.html"), { query: { host, tld } });
collisionWin.once("ready-to-show", () => collisionWin.show());
let done = false;
const finish = (payload) => { if (done) return; done = true; try { collisionWin?.close(); } catch {} resolve(payload); };
ipcMain.once(`collision-choose:${host}`, (_e, payload) => finish(payload));
collisionWin.on("closed", () => { finish({ choice: "cancel", remember: "no" }); collisionWin = null; });
});
}
function rememberCollision(host, tld, choice, remember) { function rememberCollision(host, tld, choice, remember) {
if (choice !== "bcnr" && choice !== "icann") return; if (choice !== "bcnr" && choice !== "icann") return;
if (remember === "name") collisions.byName[String(host).toLowerCase()] = choice; if (remember === "name") collisions.byName[String(host).toLowerCase()] = choice;
@ -545,6 +531,36 @@ async function serveBns(request) {
const url = new URL(request.url); const url = new URL(request.url);
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
const reqPath = decodeURIComponent(url.pathname) || "/"; const reqPath = decodeURIComponent(url.pathname) || "/";
// Special host: apply a soft-mode "Open with…" choice submitted by
// collision.html, then redirect the tab to the actual target. Persistent
// memory (byName/byTld) is written HERE; the one-shot BCDN choice rides
// along via a ?_collision=bcnr param that loadBns consumes.
if (host === "collision-choose") {
const p = url.searchParams;
const target = String(p.get("host") || "").toLowerCase();
const cTld = String(p.get("tld") || "").toLowerCase();
const cChoice = p.get("choice");
const cRem = p.get("remember") || "no";
const rest = p.get("resturl") || "/";
if (cChoice === "bcnr" || cChoice === "icann") {
rememberCollision(target, cTld, cChoice, cRem);
}
let dest;
if (cChoice === "icann") {
dest = "https://" + target + rest;
} else {
// BCNR/BCDN: bounce back through the bns:// resolver with a one-shot
// marker so loadBns won't re-prompt for this specific load.
const sep = rest.includes("?") ? "&" : "?";
dest = "bns://" + target + rest + sep + "_collision=bcnr";
}
const html = `<!doctype html><meta charset="utf-8"><title>Opening…</title>` +
`<meta http-equiv="refresh" content="0;url=${dest.replace(/"/g, "&quot;")}">` +
`<body style="background:#0f1116;color:#9ca3af;font:14px system-ui;padding:24px">Opening ${target}…</body>`;
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}
let rec = entries.get(host); let rec = entries.get(host);
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); } if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
@ -890,6 +906,18 @@ async function loadBns(t, id, host, rest, tld) {
if (id === activeId) pushNav(t.prov); if (id === activeId) pushNav(t.prov);
emitTabs(); emitTabs();
}; };
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
// (harmless) if absent.
let urlChoice = null;
try {
const u = new URL(rest, `bns://${host}/`);
if (u.searchParams.has("_collision")) {
urlChoice = u.searchParams.get("_collision");
u.searchParams.delete("_collision");
rest = u.pathname + (u.search ? u.search : "");
}
} catch {}
let entry; let entry;
try { entry = await resolveHost(host); } try { entry = await resolveHost(host); }
catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); } catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
@ -902,17 +930,24 @@ async function loadBns(t, id, host, rest, tld) {
// *might* also exist on ICANN; the policy decides which to load. // *might* also exist on ICANN; the policy decides which to load.
// Full model: SilentMode/Argus/DESIGN-collision-modes.md. // Full model: SilentMode/Argus/DESIGN-collision-modes.md.
if (!isBcnrNativeTld(tld)) { if (!isBcnrNativeTld(tld)) {
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
const transient = t.collisionOverride; t.collisionOverride = null;
const policy = settings.collisionPolicy || "bcnr-first"; const policy = settings.collisionPolicy || "bcnr-first";
let choice = overrideFor(host, tld); // per-name > per-TLD > null // Precedence: urlChoice (one-shot from collision-choose) > transient (chip
// switcher) > persistent per-name/per-TLD > global policy.
let choice = urlChoice || transient || overrideFor(host, tld);
if (!choice) { if (!choice) {
if (policy === "icann-first") choice = "icann"; if (policy === "icann-first") choice = "icann";
else if (policy === "soft") { else if (policy === "soft") {
setLoading(t, false); // hide the loading indicator while asking // In-tab full-page "Open with…" prompt (loaded from disk; buttons post
const pick = await collisionPromptOnce({ host, tld }); // back through bns://collision-choose/ which serveBns handles above).
if (pick.choice === "cancel") { emitTabs(); return; } // user cancelled setLoading(t, false);
choice = pick.choice; const q = new URLSearchParams({ host, tld, resturl: rest }).toString();
rememberCollision(host, tld, choice, pick.remember); await t.view.webContents.loadFile(path.join(__dirname, "collision.html"), { search: q });
setLoading(t, true); t.prov = { host, kind: "resolving", tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
return;
} else choice = "bcnr"; // bcnr-first — the default } else choice = "bcnr"; // bcnr-first — the default
} }
if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); } if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); }
@ -951,9 +986,15 @@ ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380,
ipcMain.handle("collision-switch", async (_e, arg) => { ipcMain.handle("collision-switch", async (_e, arg) => {
const t = activeTab(); if (!t?.prov?.host) return null; const t = activeTab(); if (!t?.prov?.host) return null;
const host = t.prov.host, tld = tldOf(host); const host = t.prov.host, tld = tldOf(host);
const choice = arg?.choice === "bcnr" || arg?.choice === "icann" // Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
? arg.choice const raw = arg?.choice;
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr"
: (raw === "icann") ? "icann"
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
// Transient per-tab override consumed once by loadBns — no soft-mode re-prompt
// for this specific chip-triggered switch.
t.collisionOverride = choice;
// Optional persistent memory ("Always use…" from the popover switcher).
rememberCollision(host, tld, choice, arg?.remember || "no"); rememberCollision(host, tld, choice, arg?.remember || "no");
return navigateTab(t.id, host + (t.prov.path || "/")); return navigateTab(t.id, host + (t.prov.path || "/"));
}); });

View file

@ -197,10 +197,10 @@
<!-- NAMING --> <!-- NAMING -->
<section id="naming" hidden> <section id="naming" hidden>
<h1>Registries</h1> <h1>Registries</h1>
<p class="lede">How Theseus picks between <b>BCDN</b> (Bitcoin Cash Domain Names, on-chain) and <b>ICANN</b> (traditional DNS) when a name exists in both registries.</p> <p class="lede">How Theseus picks between the <b>BCNR / BCDN</b> and <b>ICANN / IANA</b>, when a name exists in both.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> <div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Collision policy</div> <div class="txt"><div class="t">Collision policy</div>
<div class="d">A collision only happens when a BCDN name is registered under a TLD that ICANN also serves (e.g. <code>.de</code>). BCNR-native TLDs (<code>.bch</code>, <code>.p2p</code>, …) never conflict — they only exist on BCDN.</div></div> <div class="d">A name only exists in both registries when its TLD isn't BCNR-unique (e.g. <code>.de</code>). BCNR-unique TLDs (that only exist on BCNR) never conflict.</div></div>
<div id="policyList" style="display:flex;flex-direction:column;gap:6px"> <div id="policyList" style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="collisionPolicy" value="bcnr-first"><span><b>BCDN first</b> <span class="pmuted">— BCDN wins conflicts; falls back to ICANN for anything BCDN doesn't have.</span></span></label> <label class="polrow"><input type="radio" name="collisionPolicy" value="bcnr-first"><span><b>BCDN first</b> <span class="pmuted">— BCDN wins conflicts; falls back to ICANN for anything BCDN doesn't have.</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; BCDN fills gaps for non-ICANN TLDs.</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; BCDN fills gaps for non-ICANN TLDs.</span></span></label>