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) => {