feat(theseus/error): offline banner + BCNR did-you-mean on typos

Two problems bundled into the branded error page:

1) "fetch failed" on no network — the tab kept falling through to
   name-not-registered (and other "we asked and got nothing" verdicts)
   because a totally offline browser can't reach the beacon to know.
   Add a first-class `offline` kind that runs before every other
   verdict: navigator.onLine === false wins; the page says "You appear
   to be offline" and keeps the address so the user can Retry after
   reconnecting.

2) Typos on BCNR names (games.x when they meant game.x) — the page
   only offered Search / Register. Now the page asks main for the top
   near-matches from the warm sharedIndex (Levenshtein ≤ 2, same TLD)
   over an origin-gated `error-bns-similar` IPC. Up to three matches
   render as chip-links; clicking one retries at that host, preserving
   the original path. Works offline too — the index is local.

Origin-gate on the IPC uses the existing isErrorPageSender check, and
error-preload.js exposes only the invoke — no arbitrary index access.
This commit is contained in:
Local Dev 2026-09-04 20:21:30 +02:00
parent cd3c4462d1
commit 7aae2b2f3b
3 changed files with 104 additions and 1 deletions

View file

@ -8,4 +8,5 @@ contextBridge.exposeInMainWorld("errorpage", {
searchFor: (text) => ipcRenderer.invoke("error-search", text),
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
bnsSimilar: (host) => ipcRenderer.invoke("error-bns-similar", host),
});

View file

@ -37,6 +37,14 @@
.tried .t { display: flex; gap: 8px; align-items: center; padding: 3px 0; }
.tried .t .m { width: 12px; text-align: center; color: var(--err); }
.tried .t .m.ok { color: var(--acid) }
.suggest { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2);
border: 1px solid rgba(214,255,61,.25); border-radius: 10px; font-size: 13px; color: var(--ink); }
.suggest .lbl { color: var(--mut); font-size: 12px; margin-bottom: 6px; }
.suggest .row { display: flex; flex-wrap: wrap; gap: 6px 8px; }
.suggest a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 6px;
background: var(--panel); border: 1px solid var(--line); color: var(--acid);
font-family: ui-monospace, monospace; font-size: 12.5px; text-decoration: none; cursor: pointer; }
.suggest a:hover { border-color: rgba(214,255,61,.55); filter: brightness(1.05); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 1.3rem; }
.btn { font-family: inherit; font-size: 13.5px; padding: 9px 16px; border-radius: 9px;
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
@ -67,6 +75,11 @@
<div class="t"><span class="m"></span> <span>Clearnet DNS — no host at this name either</span></div>
</div>
<div class="suggest" id="suggest" hidden>
<div class="lbl">Did you mean</div>
<div class="row" id="suggestRow"></div>
</div>
<div class="actions" id="actions"></div>
<div class="details">
@ -77,12 +90,19 @@
<script>
const q = new URLSearchParams(location.search);
const kind = q.get("kind") || "generic"; // name-not-registered | name-unreachable | unreachable | tls | generic
let kind = q.get("kind") || "generic"; // offline | name-not-registered | name-unreachable | unreachable | tls | generic
const host = q.get("host") || "";
const url = q.get("url") || "";
const code = q.get("code") || "";
const desc = q.get("desc") || "";
// Offline is a special case: if the browser process itself has no
// network, every load will fail here and we'd otherwise mis-report
// BCNR-eligible names as "not registered" (we couldn't reach the
// beacon to know). Detect it up front so the user sees a clear
// "you're offline" instead of a false verdict about the name.
if (typeof navigator !== "undefined" && navigator.onLine === false) kind = "offline";
const $ = (id) => document.getElementById(id);
const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
const hostSpan = (h) => { const s = el("span", "host"); s.textContent = h || "(no host)"; return s; };
@ -93,6 +113,25 @@
// Kind → title, subtitle, body, actions.
const configs = {
"offline": {
icon: "⌀", iconCls: "info",
title: "You appear to be offline",
sub: "No network connection detected",
body: (b) => {
b.append("This browser can't reach the network. Wi-Fi may be off, the cable may be unplugged, or a VPN / proxy may be dropping traffic. ");
b.append("Reconnect and press ");
const kbd = document.createElement("b"); kbd.textContent = "Retry"; b.append(kbd);
b.append(".");
if (host) {
b.append(document.createElement("br"));
b.append(document.createElement("br"));
b.append("Trying to open ");
b.append(hostSpan(host));
b.append(" — the address is kept so you can retry once you're back online.");
}
},
actions: ["retry", "home"],
},
"name-not-registered": {
icon: "?", iconCls: "warn",
title: "That name isn't registered",
@ -184,6 +223,29 @@
b.addEventListener("click", def.fn);
actionsBox.appendChild(b);
}
// "Did you mean" — for BCNR-name failures, ask main for near-matches in
// the warm shared index (Levenshtein ≤ 2, same TLD). No network — the
// index is local, so this works offline too. Clicking a suggestion
// retries the load at that host, preserving the original path if any.
if ((kind === "name-not-registered" || kind === "name-unreachable") && host && window.errorpage?.bnsSimilar) {
window.errorpage.bnsSimilar(host).then((matches) => {
if (!Array.isArray(matches) || !matches.length) return;
const row = $("suggestRow");
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
for (const name of matches) {
const a = el("a", null, name);
a.href = "#";
a.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.retry("https://" + name + origPath);
});
row.appendChild(a);
}
$("suggest").hidden = false;
}).catch(() => {});
}
</script>
</body>
</html>

40
main.js
View file

@ -2303,6 +2303,46 @@ ipcMain.handle("error-register", (e, host) => {
navigateTab(activeId, url);
return true;
});
// "Did you mean" for the error page. Returns up to `limit` BCNR-registered
// names within a small edit distance of `host`, same TLD only. Uses the
// warm sharedIndex — no network call, no wait — so an offline user still
// gets suggestions if the index warmed at least once. Ranks by ascending
// distance, then alphabetical for a stable list.
function levenshtein(a, b) {
const m = a.length, n = b.length;
if (Math.abs(m - n) > 3) return 4; // early-out, we only care about ≤2
const prev = new Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j;
const cur = new Array(n + 1);
for (let i = 1; i <= m; i++) {
cur[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= n; j++) prev[j] = cur[j];
}
return prev[n];
}
ipcMain.handle("error-bns-similar", (e, host) => {
if (!isErrorPageSender(e.sender)) return [];
const h = String(host || "").trim().toLowerCase();
if (!h || !sharedIndex || typeof sharedIndex.keys !== "function") return [];
const tld = tldOf(h);
if (!tld) return [];
const cands = [];
const maxDist = 2;
for (const key of sharedIndex.keys()) {
if (typeof key !== "string") continue;
// Same TLD only — a typo like "games.x" → "game.x" or "gaeme.x" → "game.x".
if (!key.endsWith("." + tld)) continue;
if (key === h) continue;
const d = levenshtein(h, key);
if (d <= maxDist) cands.push({ name: key, dist: d });
if (cands.length > 200) break; // hard cap so a huge index doesn't stall the render
}
cands.sort((a, b) => (a.dist - b.dist) || a.name.localeCompare(b.name));
return cands.slice(0, 3).map((c) => c.name);
});
ipcMain.handle("error-open-external", (e, url) => {
if (!isErrorPageSender(e.sender)) return false;
// Allowlist Silent Mode domains only — no arbitrary external opens from