From e43e009f7378ecca903b51396b3e30918b0b96be Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 31 Aug 2026 13:38:05 +0200 Subject: [PATCH] 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. --- error-preload.js | 11 +++ error.html | 189 +++++++++++++++++++++++++++++++++++++++++++++++ home-preload.js | 10 +++ main.js | 107 +++++++++++++++++++++++++++ package.json | 4 +- 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 error-preload.js create mode 100644 error.html diff --git a/error-preload.js b/error-preload.js new file mode 100644 index 0000000..071e94c --- /dev/null +++ b/error-preload.js @@ -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), +}); diff --git a/error.html b/error.html new file mode 100644 index 0000000..ad57f60 --- /dev/null +++ b/error.html @@ -0,0 +1,189 @@ + + + + +Load error + + + +
+
+
+
+

Couldn't load this page

+

The tab kept its address so you can edit and try again.

+
+
+ +
+ + + +
+ +
+
URL
+
Error
+
+
+ + + + diff --git a/home-preload.js b/home-preload.js index 9d2f663..6f6beef 100644 --- a/home-preload.js +++ b/home-preload.js @@ -11,3 +11,13 @@ contextBridge.exposeInMainWorld("home", { resetCards: () => ipcRenderer.invoke("home-cards-reset"), 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), +}); diff --git a/main.js b/main.js index c58dc6b..e39c852 100644 --- a/main.js +++ b/main.js @@ -331,6 +331,13 @@ function isHomePageSender(sender) { return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u); } 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) -------------------------- // Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries. @@ -1403,6 +1410,56 @@ function loadHome(id) { if (id === activeId) pushNav(t.prov); 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 = {}) { const id = ++tabSeq; // 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-start-loading", () => setLoading(tab, true)); 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 // when the pointer enters/leaves an anchor. Empty string = no hover. 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(); }); 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=; 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 // system browser so downloads go to the user's usual Downloads folder, // not into Theseus's in-app download tracker (which would then need diff --git a/package.json b/package.json index c36c04c..584a49a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "author": "Silent Mode", "main": "main.js", @@ -47,6 +47,8 @@ "pw-fill.html", "pw-fill-preload.js", "home-preload.js", + "error.html", + "error-preload.js", "link-status.html", "link-status-preload.js", "collision.html",