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:
parent
cd3c4462d1
commit
7aae2b2f3b
3 changed files with 104 additions and 1 deletions
|
|
@ -8,4 +8,5 @@ contextBridge.exposeInMainWorld("errorpage", {
|
||||||
searchFor: (text) => ipcRenderer.invoke("error-search", text),
|
searchFor: (text) => ipcRenderer.invoke("error-search", text),
|
||||||
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
|
||||||
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
|
||||||
|
bnsSimilar: (host) => ipcRenderer.invoke("error-bns-similar", host),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
64
error.html
64
error.html
|
|
@ -37,6 +37,14 @@
|
||||||
.tried .t { display: flex; gap: 8px; align-items: center; padding: 3px 0; }
|
.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 { width: 12px; text-align: center; color: var(--err); }
|
||||||
.tried .t .m.ok { color: var(--acid) }
|
.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; }
|
.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;
|
.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);
|
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 class="t"><span class="m">✕</span> <span>Clearnet DNS — no host at this name either</span></div>
|
||||||
</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="actions" id="actions"></div>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
|
|
@ -77,12 +90,19 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const q = new URLSearchParams(location.search);
|
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 host = q.get("host") || "";
|
||||||
const url = q.get("url") || "";
|
const url = q.get("url") || "";
|
||||||
const code = q.get("code") || "";
|
const code = q.get("code") || "";
|
||||||
const desc = q.get("desc") || "";
|
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 $ = (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 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; };
|
const hostSpan = (h) => { const s = el("span", "host"); s.textContent = h || "(no host)"; return s; };
|
||||||
|
|
@ -93,6 +113,25 @@
|
||||||
|
|
||||||
// Kind → title, subtitle, body, actions.
|
// Kind → title, subtitle, body, actions.
|
||||||
const configs = {
|
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": {
|
"name-not-registered": {
|
||||||
icon: "?", iconCls: "warn",
|
icon: "?", iconCls: "warn",
|
||||||
title: "That name isn't registered",
|
title: "That name isn't registered",
|
||||||
|
|
@ -184,6 +223,29 @@
|
||||||
b.addEventListener("click", def.fn);
|
b.addEventListener("click", def.fn);
|
||||||
actionsBox.appendChild(b);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
40
main.js
40
main.js
|
|
@ -2303,6 +2303,46 @@ ipcMain.handle("error-register", (e, host) => {
|
||||||
navigateTab(activeId, url);
|
navigateTab(activeId, url);
|
||||||
return true;
|
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) => {
|
ipcMain.handle("error-open-external", (e, url) => {
|
||||||
if (!isErrorPageSender(e.sender)) return false;
|
if (!isErrorPageSender(e.sender)) return false;
|
||||||
// Allowlist Silent Mode domains only — no arbitrary external opens from
|
// Allowlist Silent Mode domains only — no arbitrary external opens from
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue