From 7aae2b2f3baec9bdfac2441ed308d7993795e135 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 4 Sep 2026 20:21:30 +0200 Subject: [PATCH] feat(theseus/error): offline banner + BCNR did-you-mean on typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- error-preload.js | 1 + error.html | 64 +++++++++++++++++++++++++++++++++++++++++++++++- main.js | 40 ++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/error-preload.js b/error-preload.js index 071e94c..76f8f7a 100644 --- a/error-preload.js +++ b/error-preload.js @@ -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), }); diff --git a/error.html b/error.html index ad57f60..d130b52 100644 --- a/error.html +++ b/error.html @@ -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 @@
Clearnet DNS — no host at this name either
+ +
@@ -77,12 +90,19 @@ diff --git a/main.js b/main.js index 31f2ad5..2b9702b 100644 --- a/main.js +++ b/main.js @@ -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