From ed48646c71eeefd03ebb81a0c3e2525174b7d9be Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 10 Sep 2026 22:25:19 +0200 Subject: [PATCH] feat(theseus/chrome): "Open link in new window" on the link context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone page window on the same session (cookies, bns:// protocol, session-wide bcnr preload) with Theseus's fingerprint + WebRTC policy and no toolbar. Loads BCNR-first like a tab: a dotted host with a BCNR record goes over bns://, otherwise clearnet; collision names follow the configured policy without the "Open with…" interstitial. Cross-host navigations inside the window stay BCNR-first; popups go to the main window's tabs. Its own context menu offers open-in-tab / open-in-window / copy link and back/forward/reload. Add-on page bridges (wallet inject) are tab-scoped and don't run in these windows. openLinkWindow is exported for the test harness. Verified in the dev app: coinspectrum.x opened as bns://coinspectrum.x with the page title; navigate.st stayed https. --- main.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/main.js b/main.js index 1fb1cf7..66a46e2 100644 --- a/main.js +++ b/main.js @@ -2686,6 +2686,7 @@ function createTab(initial, opts = {}) { items.push( { label: "Open link in new tab", click: () => createTab(p.linkURL) }, { label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) }, + { label: "Open link in new window", click: () => openLinkWindow(p.linkURL) }, { label: "Copy link address", click: () => clipboard.writeText(p.linkURL) }, { type: "separator" }, ); @@ -4760,6 +4761,91 @@ ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => { } catch (e) { return hErr(e); } }); +// ---- "Open link in new window" ---- +// A standalone page window: same session (cookies, the bns:// protocol, the +// session-wide bcnr preload), Theseus's fingerprint + WebRTC policy, no +// toolbar. Loads BCNR-first like a tab does: a dotted host with a BCNR +// record goes over bns://, anything else over clearnet. Collision names +// follow the configured policy without the "Open with…" interstitial. +// Add-on page bridges (the wallet inject) are tab-scoped and don't run here. +const linkWindows = new Set(); +async function targetUrlFor(input) { + let u; + try { u = new URL(String(input).includes("://") ? String(input) : "https://" + String(input)); } catch { return null; } + if (u.protocol !== "http:" && u.protocol !== "https:") return u.href; + const host = u.hostname.toLowerCase(); + if (!isBnsHost(host)) return u.href; + let entry = null; + try { entry = await resolveHost(host); } catch {} + if (!entry) return u.href; + const policy = settings.collisionPolicy || "bcnr-first"; + if (!isBcnrNativeTld(tldOf(host)) && policy === "icann-first") return u.href; + return `bns://${host}${u.pathname}${u.search}${u.hash}`; +} +async function openLinkWindow(input) { + const target = await targetUrlFor(input); + if (!target) return null; + const w = new BrowserWindow({ + width: 1100, height: 760, title: "Theseus Navigator", + backgroundColor: nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff", + icon: app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico"), + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + w.setMenuBarVisibility(false); + const wc = w.webContents; + styleScrollbars(wc); + try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} + try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} + applyFingerprint(wc); + wc.on("page-title-updated", (_e, title) => { try { w.setTitle(title ? `${title} — Theseus` : "Theseus Navigator"); } catch {} }); + // Cross-host navigations inside the window stay BCNR-first too. Same-host + // ones go through Chromium untouched (form POSTs must survive). + wc.on("will-navigate", (e, u) => { + try { + const p = new URL(u); + if (p.protocol !== "http:" && p.protocol !== "https:") return; + if (!isBnsHost(p.hostname)) return; + let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {} + if (cur === p.hostname) return; + e.preventDefault(); + targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); }); + } catch {} + }); + // Popups from a page here go to the main window's tabs when it exists — + // one place for tabs — otherwise to another plain window. + wc.setWindowOpenHandler(({ url }) => { + if (url && url !== "about:blank") { + if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} } + else openLinkWindow(url); + } + return { action: "deny" }; + }); + wc.on("context-menu", (_e, p) => { + const items = []; + const haveMain = !!win && !win.isDestroyed(); + if (p.linkURL) { + items.push( + { label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } }, + { label: "Open link in new window", click: () => openLinkWindow(p.linkURL) }, + { label: "Copy link address", click: () => clipboard.writeText(p.linkURL) }, + { type: "separator" }, + ); + } + if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" }); + else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" }); + items.push( + { label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() }, + { label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() }, + { label: "Reload", click: () => wc.reload() }, + ); + Menu.buildFromTemplate(items).popup({ window: w }); + }); + linkWindows.add(w); + w.on("closed", () => linkWindows.delete(w)); + wc.loadURL(target).catch(() => {}); + return w; +} + function openHermesWindow() { if (hermesWin && !hermesWin.isDestroyed()) { hermesWin.focus(); return; @@ -5056,4 +5142,4 @@ if (!process.env.THESEUS_NO_AUTOSTART) { app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); } -module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf }; +module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow };