Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)

Setup    f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2

Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.

Five kinds, chosen by pickErrorKind(code, host):
  name-not-registered  BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
                       Says "no BCDN record on chain, no clearnet host
                       either." Offers Register on Sirius + Search +
                       Retry + Home.
  name-unreachable     ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
                       failed - offers Retry + Search + Register +
                       Home.
  unreachable          CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
                       Offers Retry + Tor guide + Home.
  tls                  ERR_CERT_* range (-200..-299). Offers Retry +
                       Home.
  generic              Everything else.

home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.

package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).

Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.

DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
This commit is contained in:
Local Dev 2026-08-31 13:38:05 +02:00
parent bb5e90ffc9
commit e43e009f73
5 changed files with 320 additions and 1 deletions

11
error-preload.js Normal file
View file

@ -0,0 +1,11 @@
const { contextBridge, ipcRenderer } = require("electron");
// Renderer -> main API for the branded error page. All calls are user-initiated
// button clicks; main validates the sender before acting so a hostile page
// can't drive navigation by loading window.errorpage.
contextBridge.exposeInMainWorld("errorpage", {
retry: (url) => ipcRenderer.invoke("error-retry", url),
goHome: () => ipcRenderer.invoke("error-home"),
searchFor: (text) => ipcRenderer.invoke("error-search", text),
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
});

189
error.html Normal file
View file

@ -0,0 +1,189 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Load error</title>
<style>
:root { color-scheme: light dark;
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--warn:#ffc75f; --err:#f6768a; --info:#4b7bec; }
@media (prefers-color-scheme: light) {
:root { --bg:#f6f8fb; --panel:#ffffff; --panel2:#f0f4f9; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
}
* { box-sizing: border-box }
html, body { margin: 0; height: 100% }
body {
background: radial-gradient(1100px 560px at 50% -12%, #16202e, var(--bg));
color: var(--ink); font: 15px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: grid; place-items: center; padding: 2rem 1.2rem;
}
.card { max-width: 640px; width: 100%; background: var(--panel); border: 1px solid var(--line);
border-radius: 14px; padding: 2rem 2.2rem 1.7rem; }
.head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 1rem; }
.icon { width: 42px; height: 42px; flex: none; border-radius: 10px; display: grid; place-items: center;
font-size: 22px; background: rgba(246,118,138,.10); }
.icon.warn { background: rgba(255,199,95,.10) }
.icon.info { background: rgba(75,123,236,.10) }
h1 { font-size: 1.15rem; font-weight: 600; margin: .1rem 0 .1rem; letter-spacing: .1px }
.sub { color: var(--mut); font-size: 13.5px; margin: 0; }
.body { color: var(--ink); font-size: 14px; line-height: 1.62; margin: 1rem 0 .6rem; }
.body b { color: var(--acid); }
.body .host { font-family: ui-monospace, monospace; background: var(--panel2); padding: 1px 6px;
border-radius: 5px; color: var(--acid); word-break: break-all; }
.tried { margin: 1rem 0 0; padding: 12px 14px; background: var(--panel2);
border: 1px dashed var(--line); border-radius: 10px; font-size: 12.5px; color: var(--mut); }
.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) }
.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);
cursor: pointer; transition: filter .1s, background .1s, border-color .1s; }
.btn:hover { background: var(--panel); border-color: rgba(214,255,61,.35); filter: brightness(1.05); }
.btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 600; }
.btn.primary:hover { background: var(--acid); filter: brightness(1.08); }
.details { margin-top: 1.2rem; padding-top: .9rem; border-top: 1px solid var(--line);
font: 11.5px/1.55 ui-monospace, monospace; color: var(--dim); }
.details .row { display: grid; grid-template-columns: 78px 1fr; gap: 8px; }
.details .row b { color: var(--mut); font-weight: 500; }
</style>
</head>
<body>
<div class="card">
<div class="head">
<div class="icon" id="icon"></div>
<div>
<h1 id="title">Couldn't load this page</h1>
<p class="sub" id="sub">The tab kept its address so you can edit and try again.</p>
</div>
</div>
<div class="body" id="body"></div>
<div class="tried" id="tried" hidden>
<div class="t"><span class="m"></span> <span>Bitcoin Cash Domain Names (BCDN) — no record on chain</span></div>
<div class="t"><span class="m"></span> <span>Clearnet DNS — no host at this name either</span></div>
</div>
<div class="actions" id="actions"></div>
<div class="details">
<div class="row"><b>URL</b><span id="dUrl"></span></div>
<div class="row"><b>Error</b><span id="dErr"></span></div>
</div>
</div>
<script>
const q = new URLSearchParams(location.search);
const kind = q.get("kind") || "generic"; // 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") || "";
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; };
document.title = host ? "Error — " + host : "Load error";
$("dUrl").textContent = url || "(none)";
$("dErr").textContent = (code ? code + " " : "") + (desc || "");
// Kind → title, subtitle, body, actions.
const configs = {
"name-not-registered": {
icon: "?", iconCls: "warn",
title: "That name isn't registered",
sub: "No BCDN record on chain, and no clearnet host at " + host,
body: (b) => {
b.append("Nothing answered when we asked for ");
b.append(hostSpan(host));
b.append(". Bitcoin Cash Domain Names had no record for it, and ordinary DNS has no host at that name either.");
b.append(document.createElement("br"));
b.append(document.createElement("br"));
b.append("If it's supposed to be yours, register it on the chain. If you were guessing, try a search instead.");
},
tried: true,
actions: ["register", "search", "retry", "home"],
},
"name-unreachable": {
icon: "⌘", iconCls: "warn",
title: "This name didn't resolve",
sub: "Your DNS resolver didn't know " + host,
body: (b) => {
b.append("We couldn't turn ");
b.append(hostSpan(host));
b.append(" into an address. Your network's resolver may be blocking it, offline, or the name simply doesn't exist. BCDN was tried first — if the host is a BCDN name, it's not registered on the chain either.");
},
actions: ["retry", "search", "register", "home"],
},
"unreachable": {
icon: "✕", iconCls: "",
title: "This site is unreachable",
sub: host ? ("Couldn't connect to " + host) : "The server didn't respond",
body: (b) => {
b.append("The name resolved, but the server at ");
b.append(hostSpan(host));
b.append(" didn't answer. It may be down, blocking your network, or reachable only through Tor.");
},
actions: ["retry", "tor-hint", "home"],
},
"tls": {
icon: "!", iconCls: "warn",
title: "The site's certificate isn't trustworthy",
sub: host ? ("HTTPS handshake with " + host + " failed") : "Certificate error",
body: (b) => {
b.append("The server at ");
b.append(hostSpan(host));
b.append(" presented a certificate the browser couldn't verify. This can mean the certificate has expired, doesn't match the hostname, was self-signed, or that someone is intercepting the connection.");
},
actions: ["retry", "home"],
},
"generic": {
icon: "✕", iconCls: "",
title: "Couldn't load this page",
sub: host ? ("Something went wrong loading " + host) : "Load failed",
body: (b) => {
b.append("Something interrupted the load of ");
b.append(hostSpan(host));
b.append(". Details are at the bottom of this page.");
},
actions: ["retry", "home"],
},
};
const c = configs[kind] || configs.generic;
$("icon").textContent = c.icon;
$("icon").className = "icon" + (c.iconCls ? " " + c.iconCls : "");
$("title").textContent = c.title;
$("sub").textContent = c.sub;
c.body($("body"));
if (c.tried) $("tried").hidden = false;
// Actions — order matters (first is primary). "register" only when we can
// construct a Sirius URL (need a host).
const btnDefs = {
retry: { label: "Retry", primary: true, fn: () => window.errorpage.retry(url) },
search: { label: "Search for '" + host + "'", primary: false, fn: () => window.errorpage.searchFor(host) },
register: { label: "Register on Sirius", primary: false, fn: () => window.errorpage.registerOnSirius(host) },
home: { label: "Home", primary: false, fn: () => window.errorpage.goHome() },
"tor-hint": {
label: "Open Tor guide", primary: false,
fn: () => window.errorpage.openExternal("https://silentmode.st/tools/#tor"),
},
};
const actionsBox = $("actions");
let primaryUsed = false;
for (const a of c.actions) {
const def = btnDefs[a]; if (!def) continue;
if (a === "register" && !host) continue;
if (a === "search" && !host) continue;
const b = el("button", "btn" + (def.primary && !primaryUsed ? " primary" : ""), def.label);
if (def.primary) primaryUsed = true;
b.addEventListener("click", def.fn);
actionsBox.appendChild(b);
}
</script>
</body>
</html>

View file

@ -11,3 +11,13 @@ contextBridge.exposeInMainWorld("home", {
resetCards: () => ipcRenderer.invoke("home-cards-reset"), resetCards: () => ipcRenderer.invoke("home-cards-reset"),
navigate: (url) => ipcRenderer.invoke("navigate", url), navigate: (url) => ipcRenderer.invoke("navigate", url),
}); });
// Same preload also serves the branded error page (error.html). Handlers in
// main.js sender-check for error.html so a random page seeing the API shape
// can't drive navigation.
contextBridge.exposeInMainWorld("errorpage", {
retry: (url) => ipcRenderer.invoke("error-retry", url),
goHome: () => ipcRenderer.invoke("error-home"),
searchFor: (text) => ipcRenderer.invoke("error-search", text),
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
});

107
main.js
View file

@ -331,6 +331,13 @@ function isHomePageSender(sender) {
return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u); return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u);
} catch { return false; } } catch { return false; }
} }
// Same origin-gating pattern for the branded error page.
function isErrorPageSender(sender) {
try {
const u = sender.getURL() || "";
return u.startsWith("file://") && /error\.html(?:$|\?|#)/i.test(u);
} catch { return false; }
}
// ---- address-bar history (userData/history.json) -------------------------- // ---- address-bar history (userData/history.json) --------------------------
// Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries. // Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries.
@ -1403,6 +1410,56 @@ function loadHome(id) {
if (id === activeId) pushNav(t.prov); if (id === activeId) pushNav(t.prov);
emitTabs(); emitTabs();
} }
// Errors we deliberately ignore (Chromium's own reasons that shouldn't show
// a user-facing error page):
// -3 ERR_ABORTED — navigation superseded by another / user pressed Stop
// -20 ERR_BLOCKED_BY_CLIENT — extension/ad-blocker style cancel
const ERROR_CODE_IGNORE = new Set([-3, -20]);
// Chromium error-code buckets. Keep the ranges narrow — anything unmapped
// falls through to the generic error page.
// -105 ERR_NAME_NOT_RESOLVED
// -102 ERR_CONNECTION_REFUSED
// -101 ERR_CONNECTION_RESET
// -118 ERR_CONNECTION_TIMED_OUT
// -100 ERR_CONNECTION_CLOSED
// -7 ERR_TIMED_OUT
// -21 ERR_NETWORK_CHANGED
const ERROR_UNREACHABLE = new Set([-102, -101, -118, -100, -7, -21]);
function pickErrorKind(code, host) {
if (code === -105) {
// Name didn't resolve. If the host is BCNR-eligible (has a real TLD),
// that also means BCNR had no record — otherwise resolveHost/loadBns
// would have served something. Treat as "not registered" to promote
// the register-on-Sirius action.
return isBnsHost(host) ? "name-not-registered" : "name-unreachable";
}
if (ERROR_UNREACHABLE.has(code)) return "unreachable";
if (code <= -200 && code >= -299) return "tls"; // ERR_CERT_* range
return "generic";
}
// Load the branded error surface for a failed navigation. Keeps t.url =
// the attempted URL so the address bar still shows what the user asked
// for and they can edit + retry; refreshTabUrl already skips file:// so
// the error page's own path never leaks back into the bar.
function loadErrorPage(t, id, { url, code, desc }) {
if (!t) return;
const failedUrl = String(url || t.url || "");
let host = "";
try { host = new URL(failedUrl).hostname; } catch {}
const kind = pickErrorKind(code, host);
const q = new URLSearchParams({
kind, host, url: failedUrl,
code: String(code || ""), desc: String(desc || ""),
}).toString();
t.internalNav = true;
t.title = host ? "Error — " + host : "Load error";
t.prov = { host, kind: "error", code, desc };
t.view.webContents.loadFile(path.join(__dirname, "error.html"), { search: q })
.catch((e) => console.warn("error page load failed:", e?.message))
.finally(() => { t.internalNav = false; });
if (id === activeId) pushNav(t.prov);
emitTabs();
}
function createTab(initial, opts = {}) { function createTab(initial, opts = {}) {
const id = ++tabSeq; const id = ++tabSeq;
// Non-settings tabs get home-preload so the built-in home page can round- // Non-settings tabs get home-preload so the built-in home page can round-
@ -1436,6 +1493,17 @@ function createTab(initial, opts = {}) {
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-start-loading", () => setLoading(tab, true)); wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false)); wc.on("did-stop-loading", () => setLoading(tab, false));
// Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc.
// Show the branded error page instead of Chromium's default "This site
// can't be reached". Skip subframe errors, our own programmatic loads,
// and the couple of Chromium codes that fire on normal user actions
// (Stop / superseded nav / extension cancel).
wc.on("did-fail-load", (_e, code, desc, validatedURL, isMainFrame) => {
if (!isMainFrame) return;
if (tab.internalNav) return;
if (ERROR_CODE_IGNORE.has(code)) return;
loadErrorPage(tab, tab.id, { url: validatedURL || tab.url, code, desc });
});
// Firefox / Chrome-style bottom-left link preview: fires with the href // Firefox / Chrome-style bottom-left link preview: fires with the href
// when the pointer enters/leaves an anchor. Empty string = no hover. // when the pointer enters/leaves an anchor. Empty string = no hover.
wc.on("update-target-url", (_e, url) => { if (tab.id === activeId) showLinkStatus(url); }); wc.on("update-target-url", (_e, url) => { if (tab.id === activeId) showLinkStatus(url); });
@ -1753,6 +1821,45 @@ ipcMain.handle("move-tab", (_e, id, targetId, place) => {
emitTabs(); emitTabs();
}); });
ipcMain.handle("go-home", () => loadHome(activeId)); ipcMain.handle("go-home", () => loadHome(activeId));
// Error-page actions. All origin-gated to error.html so a third-party page
// that happens to see the API shape (home-preload exposes it on every tab)
// can't drive them.
ipcMain.handle("error-retry", (e, url) => {
if (!isErrorPageSender(e.sender)) return false;
if (typeof url !== "string" || !url) return false;
navigateTab(activeId, url);
return true;
});
ipcMain.handle("error-home", (e) => {
if (!isErrorPageSender(e.sender)) return false;
loadHome(activeId);
return true;
});
ipcMain.handle("error-search", (e, text) => {
if (!isErrorPageSender(e.sender)) return false;
const q = String(text || "").trim();
if (!q) return false;
navigateTab(activeId, SEARCH(q));
return true;
});
ipcMain.handle("error-register", (e, host) => {
if (!isErrorPageSender(e.sender)) return false;
const h = String(host || "").trim().toLowerCase();
if (!h) return false;
// Sirius's registrar UI takes ?prefill=<name>; if it ignores an unknown
// param the user just lands on the form and types it themselves.
const url = "https://sirius.x/register.html?prefill=" + encodeURIComponent(h);
navigateTab(activeId, url);
return true;
});
ipcMain.handle("error-open-external", (e, url) => {
if (!isErrorPageSender(e.sender)) return false;
// Allowlist Silent Mode domains only — no arbitrary external opens from
// a page that visits when things are already going wrong.
const ok = typeof url === "string" && /^https:\/\/(silentmode\.st|silentmode\.bch|sirius\.x|theseus\.x|navigate\.st)(\/|$)/i.test(url);
if (!ok) return false;
try { shell.openExternal(url); return true; } catch { return false; }
});
// Update chip: user clicked the download button → open the URL in the // Update chip: user clicked the download button → open the URL in the
// system browser so downloads go to the user's usual Downloads folder, // system browser so downloads go to the user's usual Downloads folder,
// not into Theseus's in-app download tracker (which would then need // not into Theseus's in-app download tracker (which would then need

View file

@ -1,6 +1,6 @@
{ {
"name": "theseus-navigator", "name": "theseus-navigator",
"version": "0.1.2", "version": "0.1.3",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode", "author": "Silent Mode",
"main": "main.js", "main": "main.js",
@ -47,6 +47,8 @@
"pw-fill.html", "pw-fill.html",
"pw-fill-preload.js", "pw-fill-preload.js",
"home-preload.js", "home-preload.js",
"error.html",
"error-preload.js",
"link-status.html", "link-status.html",
"link-status-preload.js", "link-status-preload.js",
"collision.html", "collision.html",