diff --git a/chrome.html b/chrome.html
index b9128e8..2a03174 100644
--- a/chrome.html
+++ b/chrome.html
@@ -1068,12 +1068,14 @@
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
if (!d || d.kind === "home") title = "Theseus";
+ else if (d.kind === "file") title = "Local file";
else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; }
else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; }
else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; }
else if (d.kind === "web") title = "Connection · ICANN — click for details";
else if (d.kind === "resolving") title = "Resolving…";
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; }
+ else if (d.kind === "error" && d.local) { cls = "secbadge insecure"; title = "Local file could not be opened"; }
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title;
}
@@ -1082,7 +1084,7 @@
// registry-menu-popup) so it can't be clipped by the chrome view.
function setReg(d) {
const r = $("reg");
- if (!d || d.kind === "home") { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; }
+ if (!d || d.kind === "home" || d.kind === "file" || d.local) { r.hidden = true; r.className = "regbtn"; r.title = "Ariadne's Thread"; return; }
// Keep the last state during "resolving" so a switch reads as a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; }
diff --git a/main.js b/main.js
index 6db3e05..75587cf 100644
--- a/main.js
+++ b/main.js
@@ -6,6 +6,7 @@
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron");
const path = require("path");
+const url = require("url");
const http = require("http");
const https = require("https");
const tls = require("tls");
@@ -195,6 +196,22 @@ function looksLikeUrl(q) {
const host = q.split(/[/?#]/)[0]; // strip path/query/frag
return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host
}
+// Local files typed or pasted into the address bar: a file:// URL, a Windows
+// drive path (D:\x\y.html, mixed slashes allowed), a UNC share, or on POSIX
+// an absolute / ~ path. Returns the file:// URL to load, or null when the
+// input isn't a local path. Checked before looksLikeUrl — a path has no dotted
+// host, so the URL heuristic would otherwise hand it to the search engine.
+function localFileUrl(q) {
+ if (!q) return null;
+ if (/^file:/i.test(q)) { try { return new URL(q).href; } catch { return null; } }
+ const isWin = process.platform === "win32";
+ const winPath = /^[a-z]:[\\/]/i.test(q) || /^\\\\[^\\]/.test(q);
+ const posixPath = !isWin && (q.startsWith("/") || q.startsWith("~/"));
+ if (!winPath && !posixPath) return null;
+ let p = q;
+ if (posixPath && p.startsWith("~/")) p = path.join(app.getPath("home"), p.slice(2));
+ try { return url.pathToFileURL(path.resolve(p)).href; } catch { return null; }
+}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS_DEFAULTS = {
@@ -2494,7 +2511,9 @@ function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""
try {
const raw = tab.view.webContents.getURL();
- if (raw && !raw.startsWith("file:")) tab.url = raw.replace(/^bns:\/\//, "https://");
+ // file: is normally our own surface (home/error pages) and never shown;
+ // a tab the user pointed at a local file is the exception.
+ if (raw && (!raw.startsWith("file:") || tab.prov?.kind === "file")) tab.url = raw.replace(/^bns:\/\//, "https://");
} catch {}
}
function loadHome(id) {
@@ -2547,7 +2566,7 @@ function loadErrorPage(t, id, { url, code, desc }) {
}).toString();
t.internalNav = true;
t.title = host ? "Error — " + host : "Load error";
- t.prov = { host, kind: "error", code, desc };
+ t.prov = { host, kind: "error", code, desc, local: failedUrl.startsWith("file:") };
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; });
@@ -2902,6 +2921,9 @@ async function navigateTab(id, input) {
if (!looksLikeUrl(q)) q = SEARCH(q);
const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase();
+ // Local paths open as files — never BCNR, never a search.
+ const fileUrl = localFileUrl(q);
+ if (fileUrl) return loadLocalFile(t, id, fileUrl);
const rest = raw.slice(host.length) || "/";
// Reflect the target URL immediately so the address bar doesn't keep
// showing the previous page's URL for the whole load duration. Without
@@ -2933,6 +2955,21 @@ async function navigateTab(id, input) {
// clearnet (https://) so the user isn't stranded when the chain
// is down or the name isn't registered.
async function loadBns(t, id, host, rest, tld) {
+// Open a local file (file:// URL) in a tab. A missing file surfaces through
+// did-fail-load → loadErrorPage like any other failed navigation.
+async function loadLocalFile(t, id, fileUrl) {
+ t.url = fileUrl;
+ t.prov = { host: "", kind: "file" };
+ t.bcnrOffer = null;
+ if (id === activeId) chrome.webContents.send("bcnr-offer", null);
+ setLoading(t, true);
+ t.nav = (t.nav || 0) + 1;
+ if (id === activeId) pushNav(t.prov);
+ emitTabs();
+ try { await t.view.webContents.loadURL(fileUrl); }
+ catch (e) { console.warn("local file load failed:", e?.message); }
+}
+
const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
const fallbackToWeb = async (reason) => {