fix(theseus): open local files from the address bar instead of searching for them

A typed or pasted path such as D:\Dev\x\page.html has no dotted host, so
the URL-vs-search heuristic handed it to the search engine. Paths (drive,
UNC, file://, and absolute/~ on POSIX) now load as file:// URLs before the
heuristic runs. Local-file tabs keep their file:// URL in the address bar
(normally suppressed because our own home/error pages are file://), show a
"Local file" badge, and hide the registry button since no name resolution
is involved. A missing file lands on the error page with a matching badge.
This commit is contained in:
Local Dev 2026-09-15 01:36:10 +02:00
parent a43089782a
commit e596454446
2 changed files with 42 additions and 3 deletions

View file

@ -1068,12 +1068,14 @@
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:"); const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps; const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
if (!d || d.kind === "home") title = "Theseus"; 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 === "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" && 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" && 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 === "web") title = "Connection · ICANN — click for details";
else if (d.kind === "resolving") title = "Resolving…"; else if (d.kind === "resolving") title = "Resolving…";
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; } 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"; } else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title; b.className = cls; b.title = title;
} }
@ -1082,7 +1084,7 @@
// registry-menu-popup) so it can't be clipped by the chrome view. // registry-menu-popup) so it can't be clipped by the chrome view.
function setReg(d) { function setReg(d) {
const r = $("reg"); 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, // Keep the last state during "resolving" so a switch reads as a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02). // not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; } if (d.kind === "resolving") { r.hidden = false; return; }

41
main.js
View file

@ -6,6 +6,7 @@
// boundary. // boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron"); const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron");
const path = require("path"); const path = require("path");
const url = require("url");
const http = require("http"); const http = require("http");
const https = require("https"); const https = require("https");
const tls = require("tls"); const tls = require("tls");
@ -195,6 +196,22 @@ function looksLikeUrl(q) {
const host = q.split(/[/?#]/)[0]; // strip path/query/frag const host = q.split(/[/?#]/)[0]; // strip path/query/frag
return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host 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) ---- // ---- persistent user settings (userData/settings.json) ----
const SETTINGS_DEFAULTS = { const SETTINGS_DEFAULTS = {
@ -2494,7 +2511,9 @@ function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = "" if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""
try { try {
const raw = tab.view.webContents.getURL(); 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 {} } catch {}
} }
function loadHome(id) { function loadHome(id) {
@ -2547,7 +2566,7 @@ function loadErrorPage(t, id, { url, code, desc }) {
}).toString(); }).toString();
t.internalNav = true; t.internalNav = true;
t.title = host ? "Error — " + host : "Load error"; 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 }) t.view.webContents.loadFile(path.join(__dirname, "error.html"), { search: q })
.catch((e) => console.warn("error page load failed:", e?.message)) .catch((e) => console.warn("error page load failed:", e?.message))
.finally(() => { t.internalNav = false; }); .finally(() => { t.internalNav = false; });
@ -2902,6 +2921,9 @@ async function navigateTab(id, input) {
if (!looksLikeUrl(q)) q = SEARCH(q); if (!looksLikeUrl(q)) q = SEARCH(q);
const raw = q.replace(/^[a-z]+:\/\//i, ""); const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase(); 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) || "/"; const rest = raw.slice(host.length) || "/";
// Reflect the target URL immediately so the address bar doesn't keep // Reflect the target URL immediately so the address bar doesn't keep
// showing the previous page's URL for the whole load duration. Without // showing the previous page's URL for the whole load duration. Without
@ -2933,6 +2955,21 @@ async function navigateTab(id, input) {
// clearnet (https://<host><rest>) so the user isn't stranded when the chain // clearnet (https://<host><rest>) so the user isn't stranded when the chain
// is down or the name isn't registered. // is down or the name isn't registered.
async function loadBns(t, id, host, rest, tld) { 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); const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry }); if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
const fallbackToWeb = async (reason) => { const fallbackToWeb = async (reason) => {