theseus/main.js
Local Dev 1c34fc2426 feat(theseus/keys): F12 / Ctrl+Shift+I opens tab DevTools (detached)
The packaged build stripped the native app menu, which took Chromium's
default DevTools accelerators with it. Wire the two everyone expects —
F12 and Ctrl+Shift+I — in the same before-input-event handler that
already owns reload / sidebar shortcuts. Always target the active tab
regardless of which view received the keystroke (chrome, overlay, tab)
so debugging is consistent with every other browser. Detach mode keeps
the tools out of the tab strip.
2026-09-06 16:50:50 +02:00

3511 lines
177 KiB
JavaScript

// Theseus Navigator — Electron main process.
// Native .bch via a custom `bns://` protocol: resolves names with the shared
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron");
const path = require("path");
const http = require("http");
const https = require("https");
const tls = require("tls");
const { spawn } = require("child_process");
const fs = require("fs");
const WebSocket = require("ws");
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
// THESEUS_USER_DATA points a dev run at a throwaway profile so it never
// touches (or races) the real install's settings, vault and add-ons.
if (process.env.THESEUS_USER_DATA) {
try { app.setPath("userData", path.resolve(process.env.THESEUS_USER_DATA)); } catch (e) { console.warn("userData override failed:", e?.message); }
}
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app.isPackaged
? path.join(RES_DIR, "resolver-web.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
// Password vault — same .mjs-in-resources pattern as the resolver.
const VAULT_MOD = app.isPackaged
? path.join(RES_DIR, "password-vault.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "password-vault.js");
let vaultLib;
async function loadVaultLib() {
if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`);
return vaultLib;
}
// Hermes messaging module — same .mjs-in-resources / .js-in-dev pattern.
const HERMES_MOD = app.isPackaged
? path.join(RES_DIR, "lib", "hermes.mjs")
: path.join(__dirname, "lib", "hermes.js");
let hermesLib;
async function loadHermesLib() {
if (!hermesLib) hermesLib = await import(`file://${HERMES_MOD.replace(/\\/g, "/")}`);
return hermesLib;
}
// Built-in engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own).
// fav = the domain to load a real favicon from; sym = emoji fallback.
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
// tier = "catalog" (curated first-class options shown in the primary Add
// panel) or "extra" (a wider bank hidden behind a filter box for
// discovery). Missing tier defaults to "catalog".
// URL routing is identical for all — kind and tier are display-only grouping.
const SEARCH_ENGINES = {
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q, h = {}) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=${h.hl || "en"}&gl=${h.gl || "us"}&pws=0` },
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
yandex: { kind: "search", tier: "catalog", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
ecosia: { kind: "search", tier: "catalog", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
mojeek: { kind: "search", tier: "catalog", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
// SearXNG is federated (dozens of public instances at searx.space); any single
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
// Users who want SearXNG add their preferred instance via the custom URL form.
wikipedia: { kind: "search", tier: "catalog", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
perplexity: { kind: "llm", tier: "catalog", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
phind: { kind: "llm", tier: "catalog", name: "Phind", sym: "🧑‍💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
// These are known-working engines that don't require sign-in on ?q= but aren't
// first-class enough to sit in the primary catalog. Keep the list vetted — if
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
marginalia: { kind: "search", tier: "extra", name: "Marginalia", sym: "🕸", fav: "search.marginalia.nu", url: (q) => "https://search.marginalia.nu/search?query=" + encodeURIComponent(q) },
stract: { kind: "search", tier: "extra", name: "Stract", sym: "🧵", fav: "stract.com", url: (q) => "https://stract.com/search?q=" + encodeURIComponent(q) },
yep: { kind: "search", tier: "extra", name: "Yep", sym: "✳️", fav: "yep.com", url: (q) => "https://yep.com/web?q=" + encodeURIComponent(q) },
presearch: { kind: "search", tier: "extra", name: "Presearch", sym: "🔷", fav: "presearch.com", url: (q) => "https://presearch.com/search?q=" + encodeURIComponent(q) },
metager: { kind: "search", tier: "extra", name: "MetaGer", sym: "🇩🇪", fav: "metager.org", url: (q) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent(q) },
qwant: { kind: "search", tier: "extra", name: "Qwant", sym: "🇫🇷", fav: "www.qwant.com", url: (q) => "https://www.qwant.com/?q=" + encodeURIComponent(q) },
swisscows: { kind: "search", tier: "extra", name: "Swisscows", sym: "🐄", fav: "swisscows.com", url: (q) => "https://swisscows.com/en/web?query=" + encodeURIComponent(q) },
naver: { kind: "search", tier: "extra", name: "Naver", sym: "🇰🇷", fav: "www.naver.com", url: (q) => "https://search.naver.com/search.naver?query=" + encodeURIComponent(q) },
baidu: { kind: "search", tier: "extra", name: "Baidu", sym: "🇨🇳", fav: "www.baidu.com", url: (q) => "https://www.baidu.com/s?wd=" + encodeURIComponent(q) },
};
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
const DEFAULT_ENABLED = ["startpage", "duckduckgo", "google", "brave", "bing"];
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null);
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
// Two-tier state: an engine is INSTALLED if it's in the user's Additional
// list (visible in Settings), and ENABLED if it's currently toggled on
// (visible in the toolbar dropdown). Toggle flips enabled only; right-click
// "Remove from list" is what actually removes an installed engine.
function isInstalled(id) {
if ((settings.customEngines || []).some((e) => e.id === id)) return true; // customs are always installed
return (settings.installedEngines || DEFAULT_ENABLED).includes(id);
}
function allEngines() {
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) }));
for (const c of settings.customEngines || [])
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) });
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
const order = settings.engineOrder || [];
return list.slice().sort((a, b) => {
const ia = order.indexOf(a.id), ib = order.indexOf(b.id);
if (ia === -1 && ib === -1) return 0;
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
}
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
// Region → 2-letter country code, for engines that accept a `gl`-style hint
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
// a consent redirect or the region-detect start page instead of results).
const REGION_TO_COUNTRY = {
europe: "de", asia: "jp", north_america: "us", south_america: "br",
africa: "ke", middle_east: "ae", australia: "au",
};
function searchHints() {
const loc = effLocale(); // e.g. "en-US" (or null → show real)
const region = settings.locationMode === "spoof" ? settings.locationRegion : null;
return {
hl: (loc || app.getLocale() || "en").split("-")[0],
gl: REGION_TO_COUNTRY[region] || (loc && loc.split("-")[1]?.toLowerCase()) || "us",
};
}
function engineUrl(id, q) {
const h = searchHints();
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q, h);
const c = (settings.customEngines || []).find((e) => e.id === id);
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q, h);
}
const SEARCH = (q) => engineUrl(settings.searchEngine, q);
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st";
// ---- BNS name detection (multi-TLD) --------------------------------------
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
// <label>.<tld> from the BCNR beacon. Flow:
// 1. User navigates to `<label>.<tld>` (address bar or link click)
// 2. Theseus asks BCNR first
// 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u)
// 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web
// (https://<host><path>), so users aren't locked out of the clearnet
// when the chain is down or the name isn't registered.
// Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames,
// non-http schemes) bypass BCNR and load directly.
// The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN.
const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = (host) => {
const h = String(host).toLowerCase().replace(/\.$/, "");
const dot = h.lastIndexOf(".");
return dot < 0 ? null : h.slice(dot + 1);
};
// Any dotted host that isn't an IP or localhost is a BCNR candidate.
const isBnsHost = (host) => {
if (!host) return false;
const h = String(host).toLowerCase().replace(/\.$/, "");
if (h === "localhost" || h.startsWith("localhost:")) return false;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(h)) return false; // IPv4[:port]
if (h.startsWith("[")) return false; // IPv6 literal
const dot = h.lastIndexOf(".");
return dot > 0 && dot < h.length - 1; // has a real TLD
};
// Kept as aliases so external callers (tests, module.exports) don't break.
const nativeTld = (host) => isBnsHost(host) ? tldOf(host) : null;
const dualTld = () => null; // dual-priority mode is gone — no ICANN-first TLDs
const registryOf = (_tld) => REGISTRY;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl(q) {
if (!q) return false;
if (/\s/.test(q)) return false; // has whitespace -> search
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(q)) return true; // scheme://…
if (/^localhost(:\d+)?([/?#]|$)/i.test(q)) return true; // localhost[:port]
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/.test(q)) return true; // IPv4[:port]
const host = q.split(/[/?#]/)[0]; // strip path/query/frag
return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host
}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS_DEFAULTS = {
webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone: true, // deny microphone by default (also hides mic labels)
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
// Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit: true, // drop cookies + logins + saved-form data
clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit: true, // drop navigation history + saved tabs
clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords
searchEngine: "startpage",// default search engine (built-in id or a custom id)
installedEngines: DEFAULT_ENABLED.slice(), // built-in engines added to the user's list (visible in Settings)
enabledEngines: DEFAULT_ENABLED.slice(), // subset that's currently toggled on (shown in the toolbar dropdown)
engineOrder: [], // user-defined display order of engine ids (empty = natural)
customEngines: [], // user-added: [{ id, name, url-with-%s }]
theme: "dark", // dark | light | system — drives prefers-color-scheme in all views
// BCNR/ICANN collision policy (see SilentMode/Argus/DESIGN-collision-modes.md):
// "bcnr-first" — BCNR wins collisions (default).
// "icann-first" — ICANN wins collisions; BCNR fills gaps.
// "soft" — "Open with…" prompt on collision, remembered per name/TLD.
collisionPolicy: "bcnr-first",
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons: [],
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340,
};
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
function applyTheme() {
try { nativeTheme.themeSource = ["dark", "light", "system"].includes(settings.theme) ? settings.theme : "dark"; } catch {}
}
// Auto decoys used by "spoof" mode (plausible but not the user's real values).
const SPOOF = { tz: "America/New_York", lang: "en-US", lat: 40.7128, lon: -74.0060 };
let settings = { ...SETTINGS_DEFAULTS };
const settingsFile = () => path.join(app.getPath("userData"), "settings.json");
function loadSettings() {
try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; }
catch (e) { console.error("settings load failed:", e.message); }
// Restore the persisted sidebar width so the first-open of a session
// uses whatever the user left it at last time.
const w = Number(settings.sidebarWidth) || SIDEBAR_W_DEFAULT;
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, w));
// Normalize the search-engine state so the toolbar picker and Settings tab
// can never disagree. Two invariants:
// 1. Every enabledEngines id must also be in installedEngines. If a user
// manually edited settings.json (or an upgrade left the two out of
// sync), we add the missing installed rows now.
// 2. settings.searchEngine must be an enabled engine. If the default from
// SETTINGS_DEFAULTS points at an id the user has disabled, fall back
// to the first currently-enabled engine.
try {
const enabled = Array.isArray(settings.enabledEngines) ? settings.enabledEngines : DEFAULT_ENABLED.slice();
const installed = Array.isArray(settings.installedEngines) ? settings.installedEngines : DEFAULT_ENABLED.slice();
settings.installedEngines = [...new Set([...installed, ...enabled])];
if (!enabled.includes(settings.searchEngine)) {
settings.searchEngine = enabled[0] || DEFAULT_ENABLED[0];
}
} catch (e) { console.warn("engine normalize:", e?.message); }
}
function saveSettings() {
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
}
// ---- bookmarks / saved pages (userData/bookmarks.json) ----
let bookmarks = [];
const bookmarksFile = () => path.join(app.getPath("userData"), "bookmarks.json");
function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } }
function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } }
function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} }
// ---- in-app update check (cheap) ------------------------------------------
// Fetch the releases manifest at startup + every 6h. If it names a Theseus
// version newer than ours, surface a chip in the toolbar with a link to the
// download URL. No auto-install, no signing check — the on-chain pointer at
// releases.silentmode.bch publishes the SAME manifest URL, so users who want
// to verify integrity can compare the manifest hash to what BCNR returns.
const UPDATE_MANIFEST_URL = "https://dl.silentmode.st/releases-manifest.json";
const UPDATE_DOWNLOAD_BASE = "https://dl.silentmode.st/";
let updateAvailable = null; // { version, setupUrl, portableUrl, setupHash, portableHash, date }
let updateDismissedThisSession = false;
// Simple string version compare — "0.0.4" > "0.0.3" and "0.10.0" > "0.9.9".
function versionIsNewer(candidate, current) {
const a = String(candidate || "").split(".").map((n) => parseInt(n, 10) || 0);
const b = String(current || "").split(".").map((n) => parseInt(n, 10) || 0);
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const x = a[i] || 0, y = b[i] || 0;
if (x > y) return true;
if (x < y) return false;
}
return false; // equal → not newer
}
async function checkForUpdate() {
try {
const controller = new AbortController();
const to = setTimeout(() => controller.abort(), 5000);
const r = await fetch(UPDATE_MANIFEST_URL, { signal: controller.signal, cache: "no-store" });
clearTimeout(to);
if (!r.ok) return;
const manifest = await r.json();
const rel = (manifest.releases || []).find((x) => x.id === "theseus-navigator");
if (!rel || !rel.version) return;
if (!versionIsNewer(rel.version, app.getVersion())) {
// Same version or older — nothing to offer. Clear any stale state so the
// chip disappears after the user has updated + relaunched.
if (updateAvailable) { updateAvailable = null; updateDownloadState = "idle"; updateDownloadPath = null; emitUpdateAvailable(); }
return;
}
const files = rel.files || {};
const setupFile = Object.keys(files).find((k) => /Setup/i.test(k));
const portableFile = Object.keys(files).find((k) => /portable/i.test(k));
const nextAvailable = {
version: rel.version,
date: rel.date || "",
setupUrl: setupFile ? UPDATE_DOWNLOAD_BASE + setupFile : null,
portableUrl: portableFile ? UPDATE_DOWNLOAD_BASE + portableFile : null,
setupHash: setupFile ? files[setupFile] : null,
portableHash: portableFile ? files[portableFile] : null,
};
const versionChanged = !updateAvailable || updateAvailable.version !== nextAvailable.version;
updateAvailable = nextAvailable;
if (versionChanged) {
// New candidate — reset any prior download state and kick off a fresh
// silent background fetch so the chip lands as "ready to install".
updateDownloadState = "idle";
updateDownloadPath = null;
autoDownloadUpdate();
}
emitUpdateAvailable();
} catch { /* offline / manifest unreachable — silent */ }
}
// Silent background pre-download of the update installer. The user never
// has to click Download — clicking the chip goes straight to Install.
// State machine: idle -> downloading -> ready | failed.
let updateDownloadState = "idle";
let updateDownloadPath = null; // path on disk once "ready"
let updateDownloadReceived = 0; // bytes so far
let updateDownloadTotal = 0; // total bytes
function autoDownloadUpdate() {
if (!updateAvailable || !updateAvailable.setupUrl) return;
if (updateDownloadState !== "idle") return;
updateDownloadState = "downloading";
updateDownloadReceived = 0;
updateDownloadTotal = 0;
try {
session.defaultSession.downloadURL(updateAvailable.setupUrl);
console.log(`[update] silent fetch started: ${updateAvailable.setupUrl}`);
} catch (e) {
console.warn("update prefetch failed:", e?.message);
updateDownloadState = "failed";
}
emitUpdateAvailable();
}
function emitUpdateAvailable() {
const base = (updateDismissedThisSession || !updateAvailable) ? null : updateAvailable;
const payload = base ? {
...base,
downloadState: updateDownloadState, // idle | downloading | ready | failed
downloadReceived: updateDownloadReceived,
downloadTotal: updateDownloadTotal,
} : null;
try { chrome?.webContents.send("update-available", payload); } catch {}
}
// ---- home page editable cards (userData/home-cards.json) ------------------
// Rendered by home.html as the "quick links" grid on the new-tab page. User
// can add/edit/remove via the page's edit mode. First-run seed = the classic
// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.).
const DEFAULT_HOME_CARDS = [
{ title: "hello.bch", url: "https://hello.bch/", sub: "A small page on the blockchain itself.", badge: "on-chain" },
{ title: "siatest.bch", url: "https://siatest.bch/", sub: "A page with no server, backed by Sia.", badge: "Sia" },
{ title: "SilentMode.X", url: "https://silentmode.x/", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" },
{ title: "Theseus.X", url: "https://theseus.x/", sub: "The Web Navigator — this browser's own address.", badge: "Navigator" },
{ title: "Sirius.X", url: "https://sirius.x/", sub: "Register and manage BCDN names.", badge: "Registrar" },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", sub: "The forge — Silent Mode's code host.", badge: "Code host" },
{ title: "Prometheus.X", url: "https://prometheus.x/", sub: "Decentralized App Marketplace.", badge: "App store" },
{ title: "Helios.X", url: "https://helios.x/", sub: "Search engine for the decentralized web (in design).", badge: "Search" },
{ title: "Hermes.X", url: "https://hermes.x/", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" },
];
// User's local edits win over everything else — that's the whole point of
// the edit mode. Remote pull only feeds the "defaults" tier so brand copy
// changes reach installs without a browser release.
const homeCardsFile = () => path.join(app.getPath("userData"), "home-cards.json");
const homeCardsRemoteCache = () => path.join(app.getPath("userData"), "home-cards-remote.json");
// The canonical remote card list is served from silentmode.st (and mirrored
// on silentmode.bch via Sia). Editing that file updates every install on
// its next launch — no reinstall required.
const HOME_CARDS_URL = "https://dl.silentmode.st/home-cards.json";
const HOME_CARDS_REFRESH_MS = 6 * 60 * 60 * 1000; // every 6h
function loadHomeCards() {
// Priority: user's local edits > cached remote copy > code defaults.
try {
if (fs.existsSync(homeCardsFile())) {
const v = JSON.parse(fs.readFileSync(homeCardsFile(), "utf8"));
if (Array.isArray(v) && v.length) return v;
}
} catch (e) { console.error("home cards (user) load failed:", e.message); }
try {
if (fs.existsSync(homeCardsRemoteCache())) {
const v = JSON.parse(fs.readFileSync(homeCardsRemoteCache(), "utf8"));
if (Array.isArray(v) && v.length) return v;
}
} catch (e) { console.error("home cards (remote-cache) load failed:", e.message); }
return DEFAULT_HOME_CARDS.slice();
}
function saveHomeCards(cards) {
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(cards, null, 2)); }
catch (e) { console.error("home cards save failed:", e.message); }
}
// Fetch the canonical home-cards.json into the remote cache. Silent on any
// error (no network, 404, bad JSON, etc.) — the cache stays as-is and the
// user sees either their last cached set or the built-in defaults.
async function refreshRemoteHomeCards() {
try {
const controller = new AbortController();
const to = setTimeout(() => controller.abort(), 6000);
const r = await fetch(HOME_CARDS_URL, { signal: controller.signal, cache: "no-store" });
clearTimeout(to);
if (!r.ok) return;
const list = await r.json();
if (!Array.isArray(list) || list.length === 0) return;
// Basic sanity: every entry must be an object with a string title + url.
const clean = list.filter((c) => c && typeof c.title === "string" && typeof c.url === "string");
if (!clean.length) return;
fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(clean, null, 2));
// Only push into open home tabs if the user hasn't overridden — their
// edits stay put.
if (!fs.existsSync(homeCardsFile())) {
for (const t of tabs) {
try { t.view.webContents.send("home-cards", clean); } catch {}
}
}
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`);
} catch (e) { /* silent */ }
}
// Sender validation — only accept IPC from our own home.html file:// URL.
// Rejects third-party pages that see the API shape via the preload.
function isHomePageSender(sender) {
try {
const u = sender.getURL() || "";
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.
// Cleared on quit when settings.clearHistoryOnQuit is on (default).
const HISTORY_CAP = 500;
let history = []; // [{ url, title, ts }]
let historySaveTimer = null;
const historyFile = () => path.join(app.getPath("userData"), "history.json");
function loadHistory() { try { if (fs.existsSync(historyFile())) history = JSON.parse(fs.readFileSync(historyFile(), "utf8")); } catch (e) { console.error("history load failed:", e.message); history = []; } }
function saveHistoryDebounced() {
clearTimeout(historySaveTimer);
historySaveTimer = setTimeout(() => {
try { fs.writeFileSync(historyFile(), JSON.stringify(history)); } catch (e) { console.error("history save failed:", e.message); }
}, 800);
}
function historyAdd(url, title) {
if (!url) return;
const clean = String(url).trim();
// Skip internal / non-http(s) URLs — never useful in address suggestions.
if (!/^https?:\/\//i.test(clean) && !/^bns:\/\//i.test(clean)) return;
// LRU: remove any existing entry for this URL, unshift a fresh one to the top.
const i = history.findIndex((h) => h.url === clean);
if (i >= 0) history.splice(i, 1);
history.unshift({ url: clean, title: String(title || "").slice(0, 200), ts: Date.now() });
if (history.length > HISTORY_CAP) history.length = HISTORY_CAP;
saveHistoryDebounced();
}
// Rank matches: prefix-of-host wins, then prefix-of-URL, then contains,
// then recency. Cap results — the dropdown wants at most ~8 entries.
function historySearch(query, cap = 8) {
const q = String(query || "").trim().toLowerCase();
if (!q) return history.slice(0, cap);
const scored = [];
for (const h of history) {
const u = h.url.toLowerCase();
const host = u.replace(/^https?:\/\//, "").split(/[/?#]/)[0];
let score;
if (host.startsWith(q)) score = 100;
else if (u.startsWith(q)) score = 80;
else if (host.includes(q)) score = 60;
else if (u.includes(q)) score = 40;
else if ((h.title || "").toLowerCase().includes(q)) score = 20;
else continue;
scored.push({ h, score });
}
scored.sort((a, b) => (b.score - a.score) || (b.h.ts - a.h.ts));
return scored.slice(0, cap).map((s) => s.h);
}
// ---- BCNR/ICANN collisions (userData/collisions.json) --------------------
// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode.
// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model.
let collisions = { byName: {}, byTld: {} };
const collisionsFile = () => path.join(app.getPath("userData"), "collisions.json");
function loadCollisions() {
try { if (fs.existsSync(collisionsFile())) collisions = { byName: {}, byTld: {}, ...JSON.parse(fs.readFileSync(collisionsFile(), "utf8")) }; }
catch (e) { console.error("collisions load failed:", e.message); }
}
function saveCollisions() {
try { fs.writeFileSync(collisionsFile(), JSON.stringify(collisions, null, 2)); } catch (e) { console.error("collisions save failed:", e.message); }
}
// per-name > per-TLD > hard policy. Returns "bcnr" | "icann" | null (null = ask in soft).
function overrideFor(host, tld) {
const n = String(host).toLowerCase();
const t = String(tld || "").toLowerCase();
if (collisions.byName[n]) return collisions.byName[n];
if (collisions.byTld[t]) return collisions.byTld[t];
return null;
}
// Cached BCNR-native TLD list from tlds.bch. Registered names under a native TLD
// are NOT collision candidates (whole TLD belongs to BCNR); non-native = might collide.
let bcnrTlds = ["bch"];
function isBcnrNativeTld(tld) { return bcnrTlds.includes(String(tld || "").toLowerCase()); }
function refreshBcnrTlds(index) {
try {
const raw = index?.get?.("tlds.bch")?.records?.tlds;
if (typeof raw === "string") {
const list = raw.split(/\s+/).filter(Boolean).map((s) => s.toLowerCase());
if (list.length) bcnrTlds = list;
}
} catch {}
}
// (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the
// prompt is now an in-tab full-page interstitial loaded from collision.html,
// wired via the bns://collision-choose/ handler in serveBns().)
function rememberCollision(host, tld, choice, remember) {
if (choice !== "bcnr" && choice !== "icann") return;
if (remember === "name") collisions.byName[String(host).toLowerCase()] = choice;
else if (remember === "tld") collisions.byTld[String(tld || "").toLowerCase()] = choice;
if (remember !== "no") saveCollisions();
}
function emitEngines() {
try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {}
if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {}
}
// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter"
// Chrome extension provides, done natively (that extension's chrome.privacy API
// isn't available in Electron, and this is more reliable). Tor forces the strongest.
const WEBRTC_POLICIES = {
default: "default", // allow all (may expose local IP)
public_only: "default_public_interface_only", // only the default public interface
public_private: "default_public_and_private_interfaces",
disable_udp: "disable_non_proxied_udp", // strongest (only proxied UDP)
};
function webrtcPolicy() {
if (torState === "on") return "disable_non_proxied_udp";
return WEBRTC_POLICIES[settings.webrtcMode] || "default_public_interface_only";
}
// ---- anti-fingerprinting: timezone + language + location ----
// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value).
function effTimezone() {
switch (settings.timezoneMode) {
case "hide": return "UTC";
case "spoof": return SPOOF.tz;
case "manual": return settings.timezoneValue || "UTC";
default: return null;
}
}
function effLocale() {
switch (settings.languageMode) {
case "hide": return "en-US";
case "spoof": return settings.languageSpoof || SPOOF.lang; // chosen from the top-languages list
case "manual": return settings.languageValue || "en-US";
default: return null;
}
}
// Representative coordinates per world region — used when the spoofed location is
// set to a region rather than exact coordinates (a major city stands in for each).
const REGIONS = {
europe: { lat: 52.5200, lon: 13.4050 }, // Berlin
asia: { lat: 35.6762, lon: 139.6503 }, // Tokyo
north_america: { lat: 40.7128, lon: -74.0060 }, // New York
south_america: { lat: -23.5505, lon: -46.6333 }, // São Paulo
africa: { lat: -1.2921, lon: 36.8219 }, // Nairobi
middle_east: { lat: 25.2048, lon: 55.2708 }, // Dubai
australia: { lat: -33.8688, lon: 151.2093 }, // Sydney
};
// Geolocation: null = show (real, allowed); "deny" = hide (blocked);
// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page.
function effLocation() {
const m = settings.locationMode;
if (m === "hide") return "deny";
if (m === "spoof") { const r = REGIONS[settings.locationRegion] || REGIONS.europe; return { lat: r.lat, lon: r.lon }; }
if (m === "manual") return { lat: Number(settings.locationLat) || 0, lon: Number(settings.locationLon) || 0 };
return null; // show
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint(wc) {
try {
if (!wc.debugger.isAttached()) wc.debugger.attach("1.3");
const tz = effTimezone();
await wc.debugger.sendCommand("Emulation.setTimezoneOverride", { timezoneId: tz || "" });
const loc = effLocale();
await wc.debugger.sendCommand("Emulation.setLocaleOverride", loc ? { locale: loc } : {});
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc.debugger.sendCommand("Page.enable");
if (wc._langScript) {
try { await wc.debugger.sendCommand("Page.removeScriptToEvaluateOnNewDocument", { identifier: wc._langScript }); } catch {}
wc._langScript = null;
}
// Build one injected script covering navigator.language(s) and geolocation.
let src = "";
if (loc) {
const langs = JSON.stringify([loc, loc.split("-")[0]]);
src += `Object.defineProperty(navigator,'language',{get:()=>${JSON.stringify(loc)},configurable:true});` +
`Object.defineProperty(navigator,'languages',{get:()=>${langs},configurable:true});`;
}
const geo = effLocation();
if (geo && geo !== "deny") { // spoof/manual: override the reported coordinates
const pos = `{coords:{latitude:${geo.lat},longitude:${geo.lon},accuracy:100,altitude:null,altitudeAccuracy:null,heading:null,speed:null},timestamp:Date.now()}`;
src += `try{const p=()=>(${pos});if(navigator.geolocation){navigator.geolocation.getCurrentPosition=(ok)=>{try{ok(p())}catch(e){}};navigator.geolocation.watchPosition=(ok)=>{try{ok(p())}catch(e){}return 0};}}catch(e){}`;
}
// Media-device privacy: Chromium leaks audiooutput (speaker) labels + deviceIds
// via enumerateDevices even when camera/mic are blocked. Like Firefox, blank
// every device's label/deviceId/groupId and collapse to one entry per kind.
if (settings.hideMediaDevices) {
src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`;
}
if (src) {
const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src });
wc._langScript = res.identifier;
try { await wc.executeJavaScript(src); } catch {} // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
// cache, localStorage, IndexedDB, service workers, cache API by default.
// This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) {
const ses = session.defaultSession;
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
const storages = [];
if (cookies) storages.push("cookies");
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
if (storages.length) {
try { await ses.clearStorageData({ storages }); }
catch (e) { console.warn("clearStorageData:", e.message); }
}
// navigation history lives in each webContents; drop it too when history-clear was asked.
// (called separately by the before-quit hook, since history-clear also deletes session.json)
}
async function clearHistoryNow() {
for (const t of tabs) {
try { t.view.webContents.navigationHistory.clear(); } catch {}
}
try { fs.unlinkSync(sessionFile()); } catch {}
// Address-bar suggestions history — wipe both in-memory + on-disk.
history = [];
clearTimeout(historySaveTimer); historySaveTimer = null;
try { fs.unlinkSync(historyFile()); } catch {}
}
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() {
const loc = effLocale() || app.getLocale() || "en-US";
try {
const ua = session.defaultSession.getUserAgent();
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
} catch {}
}
// ---- session restore + background throttling ----
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
function saveSession() {
try { fs.writeFileSync(sessionFile(), JSON.stringify(tabs.filter((t) => !t.settings && t.url).map((t) => t.url))); }
catch (e) { console.error("session save failed:", e.message); }
}
function loadSession() {
try { if (fs.existsSync(sessionFile())) return JSON.parse(fs.readFileSync(sessionFile(), "utf8")); } catch {}
return [];
}
function applyThrottle() {
for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed(kinds) {
if (kinds.includes("video") && settings.blockCamera) return false;
if (kinds.includes("audio") && settings.blockMicrophone) return false;
return true;
}
function applyPermissions() {
const ses = session.defaultSession;
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
if (SENSITIVE_DEVICE.has(permission)) return callback(false);
callback(true); // benign UX permissions (fullscreen, pointerLock, …)
});
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
if (permission === "media") {
if (details?.mediaType === "video") return !settings.blockCamera;
if (details?.mediaType === "audio") return !settings.blockMicrophone;
return !(settings.blockCamera && settings.blockMicrophone);
}
if (permission === "geolocation") return effLocation() !== "deny";
if (SENSITIVE_DEVICE.has(permission)) return false;
return true;
});
}
// Cookie shim for cross-site embeds. Sites like the faucet hub's captcha-gated testnet
// faucets set session cookies with no SameSite attribute; Chromium defaults those to
// Lax and withholds them inside cross-site iframes, so cookie-bound captcha endpoints
// fail (tbch.googol.cash /captcha 500s without its session cookie). Rewriting their
// Set-Cookie to SameSite=None; Secure makes the cookie frame-eligible. Allowlist only —
// SameSite is CSRF protection, never relax it globally. NOTE: Electron keeps a single
// onHeadersReceived listener per session; if another is ever added, merge them.
const EMBED_COOKIE_SITES = ["https://tbch.googol.cash/*", "https://signetfaucet.com/*"];
function applyEmbedCookieShim() {
session.defaultSession.webRequest.onHeadersReceived({ urls: EMBED_COOKIE_SITES }, (details, callback) => {
const headers = details.responseHeaders || {};
for (const key of Object.keys(headers)) {
if (key.toLowerCase() !== "set-cookie") continue;
headers[key] = headers[key].map((c) => (/;\s*samesite=/i.test(c) ? c : c + "; SameSite=None; Secure"));
}
callback({ responseHeaders: headers });
});
}
protocol.registerSchemesAsPrivileged([
{ scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
]);
let resolver;
async function getResolver() {
if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`);
return resolver;
}
// ---- Tor (optional onion routing, toggled from the UI) ----
// IP privacy, not full anonymity: this browser can still be fingerprinted.
const TOR_PORT = 9152;
const TOR_BIN = path.join(RES_DIR, "tor", "tor", "tor.exe");
const TOR_GEOIP = path.join(RES_DIR, "tor", "data", "geoip");
const TOR_GEOIP6 = path.join(RES_DIR, "tor", "data", "geoip6");
let torProc = null, torState = "off";
let torWsAgent = null;
let SocksProxyAgent;
async function loadSocks() { if (!SocksProxyAgent) ({ SocksProxyAgent } = await import("socks-proxy-agent")); }
function sendTor() { try { chrome?.webContents.send("tor", { state: torState }); } catch {} }
async function startTor() {
if (torProc) return;
torState = "connecting"; sendTor();
await loadSocks();
const dataDir = path.join(app.getPath("userData"), "tor-data");
torProc = spawn(TOR_BIN, ["--SocksPort", String(TOR_PORT), "--ControlPort", "0",
"--DataDirectory", dataDir, "--GeoIPFile", TOR_GEOIP, "--GeoIPv6File", TOR_GEOIP6], { windowsHide: true });
torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); });
torProc.stderr.on("data", () => {});
torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); });
}
function torReady() {
torState = "on";
torWsAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
session.defaultSession.setProxy({ proxyRules: `socks5://127.0.0.1:${TOR_PORT}` });
applyWebRTCPolicy();
sendTor();
}
function torOff() {
torState = "off"; torWsAgent = null;
session.defaultSession.setProxy({ proxyRules: "" });
applyWebRTCPolicy();
sendTor();
}
function stopTor() { torOff(); if (torProc) { try { torProc.kill(); } catch {} torProc = null; } }
// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy
// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the
// way the Tor Browser does). This is the usual reason a site still sees your IP.
function applyWebRTCPolicy() {
const policy = webrtcPolicy();
for (const t of tabs) { try { t.view.webContents.setWebRTCIPHandlingPolicy(policy); } catch {} }
}
class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torWsAgent, ...opts }); } }
const currentWS = () => (torState === "on" ? TorWebSocket : WebSocket);
function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const lib = u.protocol === "https:" ? https : http;
const req = lib.request(u, { method, headers, agent }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
});
req.on("error", reject); req.end();
});
}
async function contentFetch(url, init = {}) {
if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); }
const r = await fetch(url, init);
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) };
}
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
// isn't in ICANN DNS → "fetch failed".
// 2. It validates TLS against the public CA store, but BNS certs are signed
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
// record's SHA-256 fingerprint, which we pin against here. See
// Argus/src/lib/ca.js for the underlying trust model, and the parallel
// implementation in Argus/src/gateway/public-gateway.mjs — keep both in
// step. This code path also runs through Tor when Tor is on.
function certFp(cert) {
const fp = cert && cert.fingerprint256;
return fp ? fp.toLowerCase().replace(/:/g, "") : "";
}
async function pinnedHttpsGet(ip, port, servername, reqPath, expectedFp) {
const useTor = torState === "on";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
host: ip, port, servername, method: "GET", path: reqPath,
headers: { host: servername, "user-agent": "theseus/1" },
};
opts["rejectUnauthorized"] = false; // fingerprint pin below is the gate.
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = https.request(opts, (res) => {
const gotFp = certFp(res.socket.getPeerCertificate(false));
if (gotFp !== expectedFp) {
res.socket.destroy();
return reject(new Error(`tls fingerprint mismatch for ${servername}: got ${gotFp}, expected ${expectedFp}`));
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
});
req.setTimeout(15000, () => { req.destroy(new Error("tls request timeout")); });
req.on("error", reject);
req.end();
});
}
async function httpGetByIp(ip, reqPath, hostHeader) {
const useTor = torState === "on";
if (useTor) await loadSocks();
return new Promise((resolve, reject) => {
const opts = {
host: ip, port: 80, method: "GET", path: reqPath,
headers: { host: hostHeader, "user-agent": "theseus/1" },
};
if (useTor) opts.agent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
const req = http.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
});
req.on("error", reject); req.end();
});
}
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin.
async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) {
if (tlsFingerprint) return await pinnedHttpsGet(ip, 443, hostHeader, reqPath, String(tlsFingerprint).toLowerCase());
return await httpGetByIp(ip, reqPath, hostHeader);
}
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
async function fetchOpenSearch(href) {
try {
const r = await contentFetch(href, {});
const xml = r.buffer.toString("utf8");
const nameM = xml.match(/<ShortName>([^<]+)<\/ShortName>/i);
const urlM = xml.match(/<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i)
|| xml.match(/<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i);
if (!urlM) return null;
const template = urlM[1].replace(/\{searchTerms\??\}/gi, "%s").replace(/\{[^}]*\}/g, ""); // drop other {params}
if (!template.includes("%s") || !/^https?:\/\//i.test(template)) return null;
return { name: (nameM ? nameM[1] : new URL(href).hostname).trim().slice(0, 40), url: template };
} catch { return null; }
}
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
// build. The last discovered list is cached to disk and tried first next launch.
let electrumPool = null;
let lastElectrumRefresh = 0;
const electrumFile = () => path.join(app.getPath("userData"), "electrum-servers.json");
const serverKey = (s) => (typeof s === "string" ? s : s && s.url);
function mergeServers(preferred, rest) {
const seen = new Set(), out = [];
for (const s of [...(preferred || []), ...(rest || [])]) {
const k = serverKey(s);
if (k && !seen.has(k)) { seen.add(k); out.push(s); }
}
return out;
}
async function initElectrumPool() {
const { CHIPNET_ELECTRUM } = await getResolver();
let saved = [];
try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {}
electrumPool = mergeServers(saved, CHIPNET_ELECTRUM); // discovered first, seed always kept
}
async function refreshElectrumPool() {
try {
const { fetchElectrumServers } = await getResolver();
const found = await fetchElectrumServers({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
if (found && found.length) {
electrumPool = mergeServers(found, electrumPool);
try { fs.writeFileSync(electrumFile(), JSON.stringify(found, null, 2)); } catch {}
}
} catch { /* list unpublished or unreachable — keep the current pool */ }
}
function maybeRefreshElectrum() {
if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return;
lastElectrumRefresh = Date.now();
refreshElectrumPool(); // fire-and-forget
}
const entries = new Map();
// Cached chain index. Building it (connect + fetch every beacon tx) is the slow
// part, and it was happening on EVERY navigation. Build once, reuse for lookups,
// and refresh in the background — so .bch pages open near-instantly after the first.
let sharedIndex = null, indexBuiltAt = 0, indexBuilding = null;
const INDEX_TTL = 45_000;
// ---- warm-start from a pre-fetched beacon snapshot ------------------------
//
// The first ensureIndex() call walks the whole beacon over electrum — that's
// the "empty tab spinner" a user sees on cold start. The snapshot pipeline
// (Argus/src/publish-name-mirror.mjs) makes that walk skippable: an operator
// publishes the raw history+txs to Sia; every Theseus install carries a
// starter snapshot bundled at build time, then GETs a fresher one on boot.
// The warm sharedIndex is served immediately; the live buildIndex runs in the
// background to catch any events past the snapshot's asOfHeight.
//
// Sources tried in order:
// 1. app.getPath("userData")/bns-name-snapshot.json — the fresher copy
// written on our last successful Sia refresh (persisted across launches)
// 2. RES_DIR/bns-name-snapshot.json — the copy bundled with the build (stale
// by definition, but strictly better than "no index at all")
// Both are optional; if neither exists, ensureIndex() does what it always did
// and the user sees the same cold-start experience as before this change.
const SNAPSHOT_BUNDLED = path.join(RES_DIR, "bns-name-snapshot.json");
const SIA_SNAPSHOT_URL = "https://s3.silentmode.st:8600/bns/name-list.json";
function snapshotUserPath() { return path.join(app.getPath("userData"), "bns-name-snapshot.json"); }
function readSnapshotFrom(p) {
try {
if (!fs.existsSync(p)) return null;
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
// A minimal shape check — buildIndexFromSnapshot will throw with a
// clear message on anything else, but we want to log which source
// was chosen for diagnostics.
if (!parsed || !Array.isArray(parsed.history)) return null;
return parsed;
} catch { return null; }
}
// In-memory copy of the raw snapshot state (`{beacon, history, txs, ...}`)
// that drives the sharedIndex. Kept alongside sharedIndex so the delta poll
// can merge new beacon events into it without re-reading from disk on every
// refresh. Written to disk after each successful merge — the user cache is
// always the most up-to-date snapshot this process knows about, so a restart
// resumes from where we left off instead of from the stale bundled copy.
let currentSnapshotState = null;
async function warmFromSnapshot() {
if (sharedIndex) return sharedIndex; // already warm — nothing to do
const { buildIndexFromSnapshot } = await getResolver();
if (!buildIndexFromSnapshot) return null; // running against an older resolver-web.js
const snap = readSnapshotFrom(snapshotUserPath()) || readSnapshotFrom(SNAPSHOT_BUNDLED);
if (!snap) return null;
try {
const idx = buildIndexFromSnapshot({ snapshot: snap });
sharedIndex = idx;
currentSnapshotState = snap;
// Deliberately set indexBuiltAt to 0 so the first real navigation still
// triggers a live refresh — the snapshot is a floor, not a ceiling.
indexBuiltAt = 0;
refreshBcnrTlds(idx);
console.log(`[bns] warm-started from snapshot: ${idx.size} names @ height=${snap.asOfHeight ?? "?"} root=${snap.root ?? "?"}`);
return idx;
} catch (e) {
console.warn("[bns] snapshot warm-start failed:", e.message);
return null;
}
}
// ---- continuous background delta refresh --------------------------------
//
// Every POLL_INTERVAL_MS the browser opens ONE electrum connection, fetches
// the beacon's current history (a single fast call), diffs it against the
// snapshot we already hold in memory, and only fetches the verbose tx bodies
// for the txids we don't have yet. Then we rebuild the index locally and
// persist the enlarged snapshot to disk.
//
// This turns "index refresh" from ~60 s of round-trips (fetch every verbose
// tx for the whole beacon) into ~1 s of round-trips per new event. And
// because it runs while the browser is idle, by the time the user actually
// types a name into the URL bar there is nothing to wait for.
//
// Sources conspiring for freshness:
// * warmFromSnapshot on boot — sharedIndex is warm before nav
// * this poll loop, every 30 s — keeps sharedIndex live and current
// * refreshSnapshotFromSia on boot — pulls the operator's published
// snapshot from Sia for the NEXT
// boot; if this browser was closed
// for a week, next launch skips
// days of catch-up
// * ensureIndex still exists — full-walk fallback for the case
// where the poll cannot connect
// (offline first launch, etc.)
const POLL_INTERVAL_MS = 30_000;
let pollInFlight = null;
let pollTimer = null;
let pollAttempts = 0, pollLastError = null;
async function pollAndMerge() {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
pollAttempts++;
try {
const R = await getResolver();
if (!R.connectElectrum || !R.BEACON_SCRIPTHASH || !R.buildIndexFromSnapshot) {
// Older resolver-web without the delta primitives — nothing to do.
return null;
}
if (!electrumPool) await initElectrumPool();
// Base state: memory > user cache > bundled > empty. The "empty" branch
// is what turns the very first cold start (no bundled snapshot present
// because we shipped a build that predates snapshotting) into a full
// rebuild — mergeFreshHistory will fetch every tx.
let snap = currentSnapshotState
|| readSnapshotFrom(snapshotUserPath())
|| readSnapshotFrom(SNAPSHOT_BUNDLED)
|| { beacon: R.BEACON_SCRIPTHASH, history: [], txs: {} };
const el = await R.connectElectrum({
electrum: electrumPool, WebSocket: currentWS(), directIP: true,
});
try {
const freshHistory = await el.call("blockchain.scripthash.get_history", [R.BEACON_SCRIPTHASH]);
const known = new Set(snap.history.map((h) => h.tx_hash));
// Merge fresh into snapshot history (dedup by tx_hash, keep fresh height —
// an event that was mempool at snapshot time now has a real height).
const merged = new Map(snap.history.map((h) => [h.tx_hash, h]));
const txs = { ...(snap.txs || {}) };
let added = 0;
for (const h of freshHistory) {
if (!known.has(h.tx_hash)) {
try {
txs[h.tx_hash] = await el.call("blockchain.transaction.get", [h.tx_hash, true]);
added++;
} catch { /* unreadable — the reduction rules ignore missing txs */ }
}
merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height });
}
const history = [...merged.values()];
currentSnapshotState = { ...snap, beacon: R.BEACON_SCRIPTHASH, history, txs };
const idx = R.buildIndexFromSnapshot({ snapshot: currentSnapshotState });
sharedIndex = idx;
indexBuiltAt = Date.now();
pollLastError = null;
refreshBcnrTlds(idx);
// Persist for the next launch. Failure here is not fatal — worst case
// we redo this merge on the next start.
try {
fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true });
fs.writeFileSync(snapshotUserPath(), JSON.stringify(currentSnapshotState));
} catch { /* readonly userdata / disk full — skip */ }
if (added > 0) {
console.log(`[bns] delta-refresh: +${added} new tx${added === 1 ? "" : "s"} (total ${history.length}, index has ${idx.size} names)`);
}
} finally { try { el.close(); } catch {} }
} catch (e) {
pollLastError = e && e.message || String(e);
// Silent — the browser stays usable via sharedIndex (last-known-good) or
// the ensureIndex fallback on the next navigation.
} finally { pollInFlight = null; }
})();
return pollInFlight;
}
function startBnsPolling() {
if (pollTimer) return;
// Fire immediately so the boot warm-start gets a delta pass right away, in
// parallel with the Sia refresh and the ensureIndex fallback below. Then
// every POLL_INTERVAL_MS while the browser is running.
pollAndMerge().catch(() => {});
pollTimer = setInterval(() => pollAndMerge().catch(() => {}), POLL_INTERVAL_MS);
}
function stopBnsPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
// Fetch the latest published snapshot from Sia and persist it as the user
// copy — the next launch (or the next warmFromSnapshot call) picks it up.
// Fire-and-forget: failures are silent; the live buildIndex path is the
// authoritative catch-up.
async function refreshSnapshotFromSia() {
try {
const res = await fetch(SIA_SNAPSHOT_URL, { redirect: "follow" });
if (!res.ok) return;
const body = await res.text();
const parsed = JSON.parse(body);
if (!parsed || !Array.isArray(parsed.history)) return;
try { fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true }); } catch {}
fs.writeFileSync(snapshotUserPath(), body);
console.log(`[bns] snapshot refreshed from Sia: ${parsed.history.length} beacon txs root=${parsed.root ?? "?"}`);
} catch { /* offline / Sia unreachable / bad JSON — the live path still works */ }
}
async function ensureIndex(force = false) {
const { buildIndex } = await getResolver();
if (!electrumPool) await initElectrumPool();
if (!force && sharedIndex && Date.now() - indexBuiltAt < INDEX_TTL) return sharedIndex;
if (indexBuilding) return indexBuilding; // dedupe concurrent builds
indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool })
.then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; })
.finally(() => { indexBuilding = null; });
// If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return (sharedIndex && !force) ? sharedIndex : indexBuilding;
}
async function resolveHost(host) {
const { normalizeName } = await getResolver();
let key; try { key = normalizeName(host); } catch { return null; }
// Prefer the warm sharedIndex — the poll loop keeps it live. If we don't
// have one yet (very cold start, snapshot missing AND poll hasn't landed
// yet), fall through to a full ensureIndex build.
let idx = sharedIndex || (await ensureIndex());
let entry = idx.get(key) ?? null;
// Miss on a possibly-stale index → try a fast delta refresh (1 history +
// only-new-tx bodies), not a full walk. Only if we've had time for at least
// one poll to land (indexBuiltAt updated by both ensureIndex and the delta
// poll). If the delta path is unavailable (older resolver-web), fall back
// to a full rebuild — same behavior as before this change.
if (!entry && Date.now() - indexBuiltAt > 8_000) {
const R = await getResolver();
if (R.connectElectrum && R.buildIndexFromSnapshot) {
await pollAndMerge();
entry = sharedIndex?.get(key) ?? null;
} else {
idx = await ensureIndex(true);
entry = idx.get(key) ?? null;
}
}
if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() });
maybeRefreshElectrum();
return entry;
}
const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml",
ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" };
const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
async function serveBns(request) {
const url = new URL(request.url);
const host = url.hostname.toLowerCase();
const reqPath = decodeURIComponent(url.pathname) || "/";
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)
let rec = entries.get(host);
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
const r = rec.entry.records;
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
// whole namespace via Host routing; `s3` semantics are exact-key per name.
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
// apex (host === entry.name), current priority stands. See public-gateway.mjs
// for the full argument; keep this in step with that file.
const isSubdomain = host !== rec.entry.name;
const serveIp = async () => {
// See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls`
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS).
const up = await ipRequest(r.ip, reqPath + url.search, host, r.tls);
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
};
try {
if (isSubdomain && r.ip) return await serveIp();
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
if (r.s3) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {});
const ct = up.contentType && up.contentType !== "application/octet-stream"
? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath);
let body = up.buffer;
if (ct.includes("text/html")) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
}
return new Response(body, { status: up.status, headers: { "content-type": ct } });
}
if (r.ip) return await serveIp();
if (r.u) return Response.redirect(r.u, 302);
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
}
// ---- window + tabs ----
let win, chrome;
let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown
// Site-info popover: a floating overlay VIEW on top of the page content, so it
// never pushes the page down. Positioned under the address-bar badge on demand.
let popover, popVisible = false, popPos = { x: 8, y: 90 };
const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
const EP_W = 250; let epH = 320;
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
const DL_W = 340; let dlH = 240;
// Address-bar suggestions dropdown. Floating overlay under the address bar.
let addressPicker, apVisible = false, apPos = { x: 60, y: 70 };
let apW = 520; let apH = 60;
// Password-fill picker — floating dropdown under a small key chip in the
// toolbar that appears only when the vault is unlocked AND the current
// site has matching credentials.
let pwFillPop, pwfVisible = false, pwfPos = { x: 8, y: 90 };
const PWF_W = 280; let pwfH = 80;
// Link-hover status bar — small pill at the bottom-left of the window
// showing the href when the mouse hovers a link (Chrome / Firefox style).
// Hidden when hover leaves. Fed by webContents.update-target-url on every
// tab; the pill auto-sizes to its text.
let linkStatus, linkStatusVisible = false;
let linkStatusW = 100, linkStatusH = 22;
// Add-on sidebar — one right-anchored WebContentsView that hosts an add-on's
// registered panel HTML. First registered panel wins for the MVP; a tab
// strip / picker for multiple panels lands in a later rev. Sidebar loads
// nothing until the user actively opens it, so the perf cost of an unused
// add-on is nil.
let sidebar, sidebarVisible = false, sidebarActivePanelId = null;
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user.
const SIDEBAR_W_MIN = 200, SIDEBAR_W_MAX = 800, SIDEBAR_W_DEFAULT = 340;
let sidebarW = SIDEBAR_W_DEFAULT;
// The add-on host is the single point of truth for what's installed and
// active. Populated by initAddons() at app-ready time.
let addonHost = null;
// One-shot proxy-login handler installed by setSessionProxy when the
// extension provided credentials. Removed and re-installed on every
// setSessionProxy call so the current credentials always match the
// current proxy.
let proxyLoginHandler = null;
const { AddonHost } = require("./addons-host.js");
function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); }
function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); }
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
// addons directory the first time we see them missing. Users can then edit,
// disable, or delete them — the framework treats bundled and user add-ons
// identically, no special path handling.
function seedBundledAddons() {
const dst = addonsUserDir();
try { fs.mkdirSync(dst, { recursive: true }); } catch {}
const src = bundledAddonsDir();
if (!fs.existsSync(src)) return;
let entries = [];
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const target = path.join(dst, e.name);
if (fs.existsSync(target)) continue; // never overwrite user copies
try { fs.cpSync(path.join(src, e.name), target, { recursive: true }); }
catch (err) { console.warn(`[addons] seed ${e.name} failed:`, err?.message); }
}
}
function initAddons() {
seedBundledAddons();
addonHost = new AddonHost({
addonsDir: addonsUserDir(),
dataDir: addonsDataDir(),
isDisabled: (id) => Array.isArray(settings.disabledAddons) && settings.disabledAddons.includes(id),
logger: (...a) => console.log("[addons]", ...a),
// Session-proxy capability. Add-ons that declare "session-proxy" in
// their manifest can call api.setSessionProxy(rules) to swap
// Chromium's outbound network path. Same primitive Tor uses.
//
// Authentication: Chromium's setProxy does NOT parse credentials from
// `socks5://user:pass@host:port` — it rejects it as ERR_NO_SUPPORTED_
// PROXIES. Add-ons pass auth separately either as an object:
// api.setSessionProxy({ proxyRules, auth: { username, password } })
// or inline URL — this hook strips the user:pass@ and installs a
// one-shot login handler on the default session that answers with
// the extracted credentials next time Chromium asks the proxy for auth.
setSessionProxy: async (rules, addonId) => {
const ses = session.defaultSession;
// Always clear any prior proxy-login handler before swapping.
if (proxyLoginHandler) { ses.off("login", proxyLoginHandler); proxyLoginHandler = null; }
if (rules == null || rules === "") {
console.log(`[addons] [${addonId}] clearing session proxy`);
try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); }
return;
}
let opts;
let auth = null;
if (typeof rules === "string") {
// Parse inline creds: "scheme://user:pass@host:port".
const m = rules.match(/^([a-z0-9+.-]+:\/\/)([^:@\/]+):([^@\/]+)@(.+)$/i);
if (m) { opts = { proxyRules: m[1] + m[4] }; auth = { username: m[2], password: decodeURIComponent(m[3]) }; }
else opts = { proxyRules: rules };
} else {
opts = { proxyRules: rules.proxyRules };
if (rules.auth && rules.auth.username != null) auth = { username: String(rules.auth.username), password: String(rules.auth.password || "") };
}
const publicRules = opts.proxyRules; // never log the password
console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : "");
if (auth) {
// Chromium fires session#login with `authenticationResponseDetails.isProxy === true`
// when the proxy asks for creds. Answer once per session.
proxyLoginHandler = (event, _details, authInfo, callback) => {
if (!authInfo || !authInfo.isProxy) return;
event.preventDefault();
callback(auth.username, auth.password);
};
ses.on("login", proxyLoginHandler);
}
try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); }
},
// vault-derive capability. Resolves once the vault is unlocked (the
// user types the master password at boot or later in Settings) with a
// 32-byte HKDF child of the vault's root. The vault never persists the
// BIP-39 seed — only per-purpose roots — so add-on material hangs off
// the passwords root under an "addons/" info label: recoverable from
// the same mnemonic on any device, and a derived password can't be
// walked back to it (HKDF is one-way).
vaultDerive: async (purposePath, addonId) => {
if (!fs.existsSync(vaultFile())) throw new Error("password vault is not set up");
while (!vaultState) await new Promise((r) => setTimeout(r, 500));
const v = await loadVaultLib();
const wc = require("node:crypto").webcrypto;
const key = await wc.subtle.importKey("raw", v.hexToBytes(vaultState.purposeRoot), "HKDF", false, ["deriveBits"]);
const info = new TextEncoder().encode(`silentmode/addons/${purposePath}`);
console.log(`[addons] [${addonId}] vault.derive ${purposePath}`);
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
},
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
emitToPanel: (addonId, msg, payload) => {
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return;
try { sidebar.webContents.send("addon-event", msg, payload); } catch {}
},
hostRequire: (name) => require(name),
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
openTab: (url) => { if (win) createTab(url); },
});
addonHost.discoverAndActivate();
const snap = addonHost.snapshot();
console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled, ${snap.sidebarPanels.length} sidebar panels`);
}
// Given a webContents sender URL, work out which add-on folder it lives in.
// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch
// its own store.
function addonIdForSender(sender) {
try {
const u = new URL(sender.getURL());
if (u.protocol !== "file:") return null;
const filePath = decodeURIComponent(u.pathname).replace(/^\/+/, "");
const norm = filePath.replace(/\\/g, "/");
const dirNorm = addonsUserDir().replace(/\\/g, "/").replace(/\/+$/, "");
if (!norm.toLowerCase().startsWith(dirNorm.toLowerCase() + "/")) return null;
const rest = norm.slice(dirNorm.length + 1);
const first = rest.split("/")[0];
return first || null;
} catch { return null; }
}
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id);
const activeTab = () => tabById(activeId);
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
// Enrich prov with a "collision candidate?" flag so the popover switcher can
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
function decorate(prov) {
if (!prov || !prov.tld) return prov;
return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) };
}
function pushNav(prov) {
const p = decorate(prov);
chrome?.webContents.send("nav", p);
if (popVisible) popover?.webContents.send("site-info", p);
emitPwAvailability();
}
function layout() {
if (!win) return;
const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H);
// Sidebar (when visible) claims a fixed slice on the right; the tab views
// shrink to fit alongside it. When hidden, tabs get the full width.
const sideW = sidebarVisible ? sidebarW : 0;
const tabW = Math.max(0, width - sideW);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH });
// Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted.
if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
positionPopover();
positionEnginePicker();
positionDownloads();
positionAddressPicker();
positionPwFill();
if (linkStatusVisible) positionLinkStatus();
}
function positionPopover() {
if (!popover) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(popPos.x, width - POP_W - 6));
popover.setBounds({ x, y: popPos.y, width: POP_W, height: popH });
}
function showPopover(show) {
if (!popover) return;
if (show) {
positionPopover();
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
win.contentView.removeChildView(popover);
win.contentView.addChildView(popover);
popover.setVisible(true); popVisible = true;
popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" });
} else { popover.setVisible(false); popVisible = false; }
}
function positionEnginePicker() {
if (!enginePicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(epPos.x, width - EP_W - 6));
enginePicker.setBounds({ x, y: epPos.y, width: EP_W, height: epH });
}
function showEnginePicker(show) {
if (!enginePicker) return;
if (show) {
positionEnginePicker();
win.contentView.removeChildView(enginePicker);
win.contentView.addChildView(enginePicker);
enginePicker.setVisible(true); epVisible = true;
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
} else { enginePicker.setVisible(false); epVisible = false; }
}
function positionDownloads() {
if (!downloadsPop) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(dlPos.x, width - DL_W - 6));
downloadsPop.setBounds({ x, y: dlPos.y, width: DL_W, height: dlH });
}
function showDownloads(show) {
if (!downloadsPop) return;
if (show) {
positionDownloads();
win.contentView.removeChildView(downloadsPop);
win.contentView.addChildView(downloadsPop);
downloadsPop.setVisible(true); dlVisible = true;
downloadsPop.webContents.send("downloads", downloadsPublic());
} else { downloadsPop.setVisible(false); dlVisible = false; }
}
function positionAddressPicker() {
if (!addressPicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(apPos.x, width - apW - 6));
addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH });
}
function positionPwFill() {
if (!pwFillPop) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6));
pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH });
}
function positionLinkStatus() {
if (!linkStatus || !win) return;
const { width, height } = win.getContentBounds();
const w = Math.min(Math.max(120, linkStatusW), Math.max(200, width - 20));
const h = Math.max(20, linkStatusH);
linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h });
}
function showLinkStatus(url) {
if (!linkStatus) return;
const s = String(url || "");
if (!s) {
if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; }
return;
}
positionLinkStatus();
// Raise the pill above any tab view that was added after it.
try { win.contentView.removeChildView(linkStatus); win.contentView.addChildView(linkStatus); } catch {}
linkStatus.setVisible(true); linkStatusVisible = true;
try { linkStatus.webContents.send("link-status-url", s); } catch {}
}
// Open (or close) the sidebar. Loading the panel HTML is lazy — the first
// open triggers loadFile; subsequent opens just flip visibility.
function toggleSidebar() { setSidebar(!sidebarVisible); }
function setSidebar(show, panelId) {
if (!sidebar) return;
const panels = addonHost ? addonHost.getSidebarPanels() : [];
if (show && panels.length === 0) {
// No add-on offers a sidebar panel — silently ignore. Settings surfaces
// the "install one" path.
return;
}
if (show) {
const wantId = panelId || sidebarActivePanelId || panels[0].panelId;
const panel = panels.find((p) => p.panelId === wantId) || panels[0];
if (sidebarActivePanelId !== panel.panelId) {
sidebarActivePanelId = panel.panelId;
try { sidebar.webContents.loadFile(panel.pageFile); } catch (e) { console.warn("sidebar loadFile failed:", e?.message); }
}
sidebarVisible = true;
sidebar.setVisible(true);
try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {}
layout();
try { sidebar.webContents.send("sidebar-visibility", true); } catch {}
try { chrome?.webContents.send("sidebar-state", { visible: true, active: sidebarActivePanelId, panels }); } catch {}
} else {
sidebarVisible = false;
sidebar.setVisible(false);
layout();
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
try { chrome?.webContents.send("sidebar-state", { visible: false, active: sidebarActivePanelId, panels }); } catch {}
}
}
function showPwFill(show, matches) {
if (!pwFillPop) return;
if (show) {
positionPwFill();
win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop);
pwFillPop.setVisible(true); pwfVisible = true;
pwFillPop.webContents.send("pw-matches", { matches: matches || [] });
} else { pwFillPop.setVisible(false); pwfVisible = false; }
}
// Compute credential matches for a host. Exact hostname match in phase-1;
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
function pwMatchesForHost(host) {
if (!vaultState || !host) return [];
const h = String(host).toLowerCase();
return (vaultState.entries || [])
.filter((e) => e.domain === h)
.map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" }));
}
// Emit the current tab's match count to chrome so the toolbar chip can
// show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability() {
const t = activeTab();
const host = t?.prov?.host || "";
const count = pwMatchesForHost(host).length;
try { chrome?.webContents.send("pw-availability", { host, count }); } catch {}
}
// Inject a small script into the active tab that fills the first visible
// password field + tries to fill the adjacent/associated username field.
// Kept intentionally small — the whole autofill affordance is opt-in
// (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab(entry) {
const t = activeTab(); if (!t) return false;
const wc = t.view.webContents;
const script = `(() => {
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible);
if (!pwds.length) return { ok: false, why: 'no-password-field' };
const pw = pwds[0];
const form = pw.closest('form');
const scope = form ? form.querySelectorAll('input') : document.querySelectorAll('input');
const users = [...scope].filter((el) => el !== pw && visible(el) && !el.disabled &&
/^(?:text|email|tel|url|search|)$/i.test(el.type || 'text') &&
/^(?:username|user|email|login|account|id)$/i.test((el.name || el.id || el.autocomplete || '').replace(/[-_]/g, '').toLowerCase()));
const user = users[0] || null;
const fill = (el, v) => {
el.focus();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
if (user && ${JSON.stringify(String(entry.username || ""))}) fill(user, ${JSON.stringify(String(entry.username || ""))});
fill(pw, ${JSON.stringify(String(entry.password))});
pw.blur();
return { ok: true, filledUsername: !!user };
})()`;
try {
const res = await wc.executeJavaScript(script, true);
return res;
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
}
function showAddressPicker(show, suggestions) {
if (!addressPicker) return;
if (show) {
if (!suggestions || !suggestions.length) return showAddressPicker(false);
positionAddressPicker();
win.contentView.removeChildView(addressPicker);
win.contentView.addChildView(addressPicker);
addressPicker.setVisible(true); apVisible = true;
addressPicker.webContents.send("address-suggest", { suggestions });
} else { addressPicker.setVisible(false); apVisible = false; }
}
// Public view of a download — no DownloadItem refs leak to the renderer.
const downloadsPublic = () => downloads.map((d) => ({ ...d }));
function emitDownloads() {
const pub = downloadsPublic();
try { chrome?.webContents.send("downloads", pub); } catch {}
if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {}
}
// Attach the will-download listener to the SHARED default session. Every tab's
// WebContents inherits it, so we catch downloads regardless of which tab
// initiated them (including anchor clicks with `download`, form posts serving
// attachments, and manual save-as gestures).
function installDownloadTracker() {
session.defaultSession.on("will-download", (_e, item /*, wc */) => {
const url = item.getURL();
// Update installer? Route it to a fixed temp path, keep it out of the
// visible downloads list, drive updateDownloadState instead so the chip
// can show "ready to install" and one-click install-and-restart.
const isUpdate = updateAvailable && (url === updateAvailable.setupUrl || url === updateAvailable.portableUrl);
if (isUpdate) {
const dst = path.join(app.getPath("temp"), item.getFilename());
try { item.setSavePath(dst); } catch {}
updateDownloadTotal = item.getTotalBytes() || 0;
updateDownloadReceived = 0;
item.on("updated", () => {
updateDownloadReceived = item.getReceivedBytes();
updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal;
emitUpdateAvailable();
});
item.once("done", (_ev, state) => {
if (state === "completed") {
updateDownloadPath = item.getSavePath() || dst;
updateDownloadState = "ready";
console.log(`[update] silent fetch complete: ${updateDownloadPath}`);
} else {
updateDownloadState = "failed";
console.warn(`[update] silent fetch ${state}`);
}
emitUpdateAvailable();
});
return;
}
const id = nextDlId++;
const rec = {
id,
filename: item.getFilename(),
url: item.getURL(),
mime: item.getMimeType(),
total: item.getTotalBytes() || 0,
received: 0,
state: "progressing", // progressing | paused | completed | cancelled | interrupted
savePath: "",
startedAt: Date.now(),
};
downloads.unshift(rec);
dlItems.set(id, item);
emitDownloads();
item.on("updated", (_ev, state) => {
rec.state = state; // "progressing" | "interrupted"
rec.received = item.getReceivedBytes();
rec.total = item.getTotalBytes() || rec.total;
rec.savePath = item.getSavePath() || rec.savePath;
emitDownloads();
});
item.once("done", (_ev, state) => {
rec.state = state; // "completed" | "cancelled" | "interrupted"
rec.received = item.getReceivedBytes();
rec.savePath = item.getSavePath() || rec.savePath;
dlItems.delete(id);
emitDownloads();
});
});
}
function setActive(id) {
activeId = id;
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
for (const t of tabs) t.view.setVisible(t.id === id);
const t = activeTab();
if (t?.prov) pushNav(t.prov);
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
emitTabs();
}
function emitTabs() {
const t = activeTab();
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })),
collapsedGroups: [...tabGroupCollapsed],
url: t?.url || "",
loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false,
canForward: wc ? wc.navigationHistory.canGoForward() : false,
});
}
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
// https:// is what the user sees regardless of how the bytes were fetched.
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://");
} catch {}
}
function loadHome(id) {
const t = tabById(id); if (!t) return;
t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" };
t.view.webContents.loadFile("home.html");
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-
// trip its editable-cards state via IPC. IPC handlers reject any call
// whose sender URL isn't our own home.html, so a third-party page sees
// the API's shape but can't act through it.
const view = new WebContentsView(opts.settings
? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } }
: { webPreferences: { preload: path.join(__dirname, "home-preload.js") } });
const wc = view.webContents;
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null };
tabs.push(tab);
win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => {
tab.title = title; emitTabs();
// Keep the top-of-history title in sync when a page's title loads late.
if (history[0] && tab.url && history[0].url === tab.url) { history[0].title = title; saveHistoryDebounced(); }
});
// Site favicon → tab icon. Take the first URL Electron emits (usually the
// 32x32 or 16x16 <link rel="icon">). We don't proactively clear on nav —
// mainstream browsers keep the old icon until the new one arrives, which
// avoids a flash on every subpage click.
wc.on("page-favicon-updated", (_e, urls) => {
const next = (urls && urls[0]) || null;
if (tab.favicon !== next) { tab.favicon = next; emitTabs(); }
});
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
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); });
wc.on("will-navigate", (e, u) => {
try {
// Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/")
// and that host is often a BCNR-registered dotted name — without this guard,
// isBnsHost() would send us right back into navigateTab, canceling the
// fallback (blank-page bug 2026-08-02).
if (tab.internalNav) return;
const parsed = new URL(u);
// Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns
// doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync.
if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") {
e.preventDefault();
const p = parsed.searchParams;
const target = String(p.get("host") || "").toLowerCase();
const cTld = String(p.get("tld") || "").toLowerCase();
const cChoice = p.get("choice");
const cRem = p.get("remember") || "no";
const rest = p.get("resturl") || "/";
if (!target) return;
if (cChoice === "bcnr" || cChoice === "icann") {
rememberCollision(target, cTld, cChoice, cRem);
tab.collisionOverride = cChoice; // one-shot, consumed by loadBns
}
return navigateTab(id, target + rest);
}
if (parsed.protocol === "bns:") return;
if (isBnsHost(parsed.hostname)) {
// Only intercept cross-origin navigations. Same-origin (a form submit
// or a subpage link on the site we're currently on) must go through
// Chromium natively — our navigateTab path calls loadURL(url), which
// is always a GET and drops any POST body. That silently broke
// Startpage (whose in-page search form POSTs to /do/search), and any
// other site that POSTs (logins, comment submits, checkouts, ...).
// The site is already loaded from clearnet, so its subsequent
// navigation belongs to clearnet too — no BCNR re-lookup needed.
let currentHost = "";
try { currentHost = new URL(wc.getURL()).hostname; } catch {}
if (currentHost === parsed.hostname) return;
// Preserve query + fragment. Dropping them broke every search engine
// that submits via a classic form GET (Google's /search?q=foo lost
// the ?q=, so the results page opened blank).
e.preventDefault();
navigateTab(id, u.replace(/^[a-z]+:\/\//i, ""));
}
} catch {}
});
// "You have unsaved changes" confirmation: fires when the page's beforeunload
// handler is trying to keep the user on the page (e.g. an unsent form draft,
// an editor with a dirty document). Show a native confirm; on "Leave", call
// preventDefault to override the block. Applies to both link clicks AND our
// programmatic loads (chip switcher, address-bar navigation).
wc.on("will-prevent-unload", (e) => {
const parent = BrowserWindow.getFocusedWindow() || win;
const choice = dialog.showMessageBoxSync(parent, {
type: "question",
buttons: ["Stay on page", "Leave anyway"],
defaultId: 0,
cancelId: 0,
title: "Unsaved changes",
message: "This page is asking you to stay.",
detail: "You may have unsaved changes that will be lost if you leave.",
});
if (choice === 1) e.preventDefault(); // Leave anyway -> override the beforeunload
});
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => {
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
return { action: "deny" };
});
// Right-click context menu.
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
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: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
// Image context menu: only when the pointer is actually on an image, and
// we have a src to act on. Save-image-as triggers will-download with no
// preset savePath, so Electron shows the native Save As dialog.
if (p.mediaType === "image" && p.srcURL) {
items.push(
{ label: "Open image in new tab", click: () => createTab(p.srcURL) },
{ label: "Save image as…", click: () => wc.downloadURL(p.srcURL) },
{ label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } },
{ label: "Copy image address", click: () => clipboard.writeText(p.srcURL) },
{ 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" });
// "Search for …" when text is selected. Label uses a short excerpt so
// a long selection doesn't stretch the menu. Opens in a new foreground
// tab so the current page isn't lost — matches Chrome / Firefox UX.
if (p.selectionText) {
const raw = p.selectionText.replace(/\s+/g, " ").trim();
if (raw) {
const excerpt = raw.length > 40 ? raw.slice(0, 40) + "…" : raw;
items.push(
{ label: `Search for "${excerpt.replace(/&/g, "&&")}"`, click: () => createTab(SEARCH(raw)) },
{ 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();
});
layout();
if (opts.background) { view.setVisible(false); emitTabs(); }
else setActive(id);
if (opts.settings) {
tab.prov = { host: "", kind: "home" };
wc.loadFile("settings.html");
if (id === activeId) pushNav(tab.prov);
emitTabs();
} else if (initial) navigateTab(id, initial);
else loadHome(id);
return id;
}
function closeTab(id) {
const i = tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const [t] = tabs.splice(i, 1);
win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.();
if (tabs.length === 0) { createTab(); return; }
if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id);
else emitTabs();
}
function createWindow() {
win = new BrowserWindow({
width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: "#0f1420",
// Taskbar / titlebar icon. Packaged builds ship build/icon.ico as
// extraResource; dev reads the source file directly.
icon: app.isPackaged
? path.join(process.resourcesPath, "icon.ico")
: path.join(__dirname, "build", "icon.ico"),
});
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
win.contentView.addChildView(chrome);
chrome.webContents.loadFile("chrome.html");
// Floating site-info overlay (hidden until the address-bar badge is clicked).
popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } });
try { popover.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(popover);
popover.webContents.loadFile("popover.html");
popover.setVisible(false);
// Floating engine-picker overlay (custom dropdown with real favicons).
enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } });
try { enginePicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(enginePicker);
enginePicker.webContents.loadFile("engine-picker.html");
enginePicker.setVisible(false);
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(downloadsPop);
downloadsPop.webContents.loadFile("downloads.html");
downloadsPop.setVisible(false);
// Floating address-bar suggestions dropdown.
addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } });
try { addressPicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(addressPicker);
addressPicker.webContents.loadFile("address-picker.html");
addressPicker.setVisible(false);
// Floating password-fill picker.
pwFillPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "pw-fill-preload.js") } });
try { pwFillPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(pwFillPop);
pwFillPop.webContents.loadFile("pw-fill.html");
pwFillPop.setVisible(false);
// Link-hover status pill (bottom-left of window).
linkStatus = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "link-status-preload.js") } });
try { linkStatus.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(linkStatus);
linkStatus.webContents.loadFile("link-status.html");
linkStatus.setVisible(false);
// Add-on sidebar host. Doesn't loadFile until an add-on panel is opened.
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
win.contentView.addChildView(sidebar);
sidebar.setVisible(false);
// Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
try { approvalPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(approvalPop);
approvalPop.webContents.loadFile("approval.html");
approvalPop.setVisible(false);
chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
// Re-emit any pending update notice — the fetch may have completed
// before chrome finished loading, in which case the initial send was
// a no-op.
emitUpdateAvailable();
});
win.on("resize", layout);
layout();
}
async function navigateTab(id, input) {
const t = tabById(id); if (!t) return;
let q = String(input).trim();
if (!q) return;
// Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search.
if (!looksLikeUrl(q)) q = SEARCH(q);
const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase();
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
// this, emitTabs below (triggered by setLoading) carries the old t.url
// and the chrome renderer paints it, since we blurred the input on Enter.
t.url = "https://" + host + (rest === "/" ? "" : rest);
setLoading(t, true); // show the loading indicator immediately (covers BNS resolution)
t.nav = (t.nav || 0) + 1;
// Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
t.bcnrOffer = null;
if (id === activeId) chrome.webContents.send("bcnr-offer", null);
// BCNR-first for every dotted host. loadBns falls through to https://<host>
// on NXDOMAIN or resolver failure, so clearnet still works.
if (isBnsHost(host)) return loadBns(t, id, host, rest, tldOf(host));
// Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
t.url = q.includes("://") ? q : "https://" + q;
await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web" };
if (id === activeId) pushNav(t.prov);
emitTabs();
}
// Load a name from BCNR into a tab. Called for every dotted host — BCNR is
// tried first; on NXDOMAIN or resolver failure we always fall through to the
// clearnet (https://<host><rest>) 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) {
const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
const fallbackToWeb = async (reason) => {
t.url = "https://" + host + (rest === "/" ? "" : rest);
t.internalNav = true;
try { await t.view.webContents.loadURL(t.url); }
catch (e) { console.warn("fallback loadURL failed:", e?.message); }
finally { t.internalNav = false; }
t.prov = { host, kind: "web", note: `fallback: ${reason}`, tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
};
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
// (harmless) if absent.
let urlChoice = null;
try {
const u = new URL(rest, `bns://${host}/`);
if (u.searchParams.has("_collision")) {
urlChoice = u.searchParams.get("_collision");
u.searchParams.delete("_collision");
rest = u.pathname + (u.search ? u.search : "");
}
} catch {}
let entry;
try { entry = await resolveHost(host); }
catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
// Address-bar display: show https:// even for BCDN sites. Rationale — BCNR
// replaces DNS (name resolution), NOT HTTP (transport). For s3/ip/p records
// the actual delivery IS HTTPS under the hood; for h records the content is
// on-chain (no HTTP at all, but https:// is the least surprising display).
// The BCDN/ICANN badge is the source-of-truth for which registry served us;
// the URL scheme is a convention, kept consistent so users' muscle memory
// holds. (bns:// is an internal Electron protocol implementation detail.)
t.url = "https://" + host + (rest === "/" ? "" : rest);
if (!entry) { setLoading(t, false); return fallbackToWeb("no BCNR record"); }
// ---- Collision policy (BCNR ↔ ICANN) --------------------------------------
// BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's,
// no collision possible). Otherwise it's a collision candidate — the same name
// *might* also exist on ICANN; the policy decides which to load.
// Full model: SilentMode/Argus/DESIGN-collision-modes.md.
if (!isBcnrNativeTld(tld)) {
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
const transient = t.collisionOverride; t.collisionOverride = null;
const policy = settings.collisionPolicy || "bcnr-first";
// Precedence: urlChoice (one-shot from collision-choose) > transient (chip
// switcher) > persistent per-name/per-TLD > global policy.
let choice = urlChoice || transient || overrideFor(host, tld);
if (!choice) {
if (policy === "icann-first") choice = "icann";
else if (policy === "soft") {
// In-tab full-page "Open with…" prompt (loaded from disk; buttons post
// back through bns://collision-choose/ which serveBns handles above).
setLoading(t, false);
const q = new URLSearchParams({ host, tld, resturl: rest }).toString();
await t.view.webContents.loadFile(path.join(__dirname, "collision.html"), { search: q });
t.prov = { host, kind: "resolving", tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
return;
} else choice = "bcnr"; // bcnr-first — the default
}
if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); }
}
await t.view.webContents.loadURL(`bns://${host}${rest}`);
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
// routes via the parent's server, not via Sia. See serveBns for the rule.
const _isSub = host !== entry.name;
const src = (_isSub && entry.records.ip) ? "direct server"
: entry.records.h ? "on-chain (chain)"
: entry.records.s3 ? "Sia network"
: entry.records.ip ? "direct server"
: entry.records.u ? "redirect"
: "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
}
ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input));
ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
// Tab context menu backing IPCs. All scoped to a specific tab id so the
// active tab doesn't have to be the one the user right-clicked.
ipcMain.handle("tab-reload", (_e, id) => { const t = tabById(id); if (t) try { t.view.webContents.reload(); } catch {} });
ipcMain.handle("tab-duplicate", (_e, id) => {
const t = tabById(id); if (!t) return;
const target = t.url || "";
if (target) createTab(target); else createTab();
});
ipcMain.handle("tab-mute", (_e, id, on) => {
const t = tabById(id); if (!t) return false;
const want = typeof on === "boolean" ? on : !t.muted;
try { t.view.webContents.setAudioMuted(want); t.muted = want; emitTabs(); return want; }
catch { return t.muted; }
});
ipcMain.handle("tab-group", (_e, id, color) => {
const t = tabById(id); if (!t) return null;
// color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]);
const next = allowed.has(color) ? color : null;
t.group = next;
// Cluster: move this tab so all same-group tabs sit contiguously. Place
// it right after the LAST existing tab of that group; if there are no
// other members yet, leave it in place. When a tab is removed from a
// group (color === null) we don't reorder — the visual break is enough.
if (next) {
const idx = tabs.indexOf(t);
let insertAfter = -1;
for (let i = 0; i < tabs.length; i++) {
if (i !== idx && tabs[i].group === next) insertAfter = i;
}
if (insertAfter !== -1) {
tabs.splice(idx, 1);
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
tabs.splice(dst, 0, t);
}
}
emitTabs();
return t.group;
});
// Groups get a collapsed/expanded state, per-color, in-memory only (resets
// on relaunch). Flipping this doesn't touch tabs — the renderer just hides
// tabs whose group is collapsed and shows the group chip in their place.
const tabGroupCollapsed = new Set(); // colors currently collapsed
ipcMain.handle("tab-group-toggle", (_e, color) => {
if (!color) return false;
if (tabGroupCollapsed.has(color)) tabGroupCollapsed.delete(color); else tabGroupCollapsed.add(color);
// Piggy-back on emitTabs so the chrome renderer receives the change.
emitTabs();
return tabGroupCollapsed.has(color);
});
ipcMain.handle("tab-bookmark", (_e, id) => {
const t = tabById(id); if (!t) return false;
const url = t.url; const title = t.title || url;
if (!url) return false;
if (bookmarks.some((b) => b.url === url)) return true; // already saved
bookmarks.unshift({ url, title, addedAt: Date.now() });
saveBookmarks(); emitBookmarks();
return true;
});
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
const src = tabs.findIndex((t) => t.id === id);
const dst = tabs.findIndex((t) => t.id === targetId);
if (src < 0 || dst < 0 || src === dst) return;
const [t] = tabs.splice(src, 1);
const insertAt = tabs.findIndex((x) => x.id === targetId);
tabs.splice(place === "after" ? insertAt + 1 : insertAt, 0, t);
emitTabs();
});
ipcMain.handle("go-home", () => loadHome(activeId));
// --- Add-on framework -------------------------------------------------------
// Sidebar toggle + panel switching, driven from the chrome toolbar. `panelId`
// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no
// coercion, main matches it verbatim.
ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible; });
// Drag events stream in from sidebar-preload while the user is holding the
// grip. Delta is px per mousemove; we clamp, layout, and debounce the save.
let _sidebarSaveTimer = null;
ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
const d = Number(deltaPx) || 0;
const next = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarW + d));
if (next === sidebarW) return sidebarW;
sidebarW = next;
layout();
settings.sidebarWidth = sidebarW;
clearTimeout(_sidebarSaveTimer);
_sidebarSaveTimer = setTimeout(saveSettings, 400);
return sidebarW;
});
ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; });
ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; });
ipcMain.handle("sidebar-state", () => ({
visible: sidebarVisible,
active: sidebarActivePanelId,
panels: addonHost ? addonHost.getSidebarPanels() : [],
}));
// Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
// add-ons activate immediately and newly-disabled ones drop out — no
// restart required.
ipcMain.handle("addons-set-enabled", (_e, id, enabled) => {
if (!id || typeof id !== "string") return false;
const disabled = new Set(Array.isArray(settings.disabledAddons) ? settings.disabledAddons : []);
if (enabled) disabled.delete(id); else disabled.add(id);
settings.disabledAddons = [...disabled];
saveSettings();
// Rebuild the host so state matches settings.
if (addonHost) addonHost.discoverAndActivate();
// Sidebar may need to close if its current panel came from an add-on we
// just disabled.
const panels = addonHost ? addonHost.getSidebarPanels() : [];
if (sidebarVisible && sidebarActivePanelId && !panels.find((p) => p.panelId === sidebarActivePanelId)) {
sidebarActivePanelId = null;
setSidebar(false);
}
return true;
});
// Reveal an add-on's folder in the OS file manager — the primary way users
// edit / uninstall add-ons.
ipcMain.handle("addons-reveal", (_e, folder) => {
if (typeof folder !== "string" || !folder) return false;
const norm = path.normalize(folder);
const base = addonsUserDir();
if (!norm.toLowerCase().startsWith(base.toLowerCase())) return false; // don't leak arbitrary paths
try { shell.showItemInFolder(norm); return true; } catch { return false; }
});
ipcMain.handle("addons-open-dir", () => {
try { shell.openPath(addonsUserDir()); return true; } catch { return false; }
});
ipcMain.handle("addons-reload", () => {
if (!addonHost) return false;
addonHost.discoverAndActivate();
return true;
});
// --- Add-on messaging + capabilities -----------------------------------------
// Panel → add-on: the sidebar panel's file:// URL tells us which add-on it
// belongs to (same gate as storage). The add-on's onMessage handler runs in
// main and its return value is the response.
ipcMain.handle("addon-msg", async (e, msg, payload) => {
const id = addonIdForSender(e.sender);
if (!id || !addonHost) throw new Error("not an add-on panel");
return addonHost.dispatch(id, String(msg), payload, { from: "panel" });
});
// Page → add-on: only a real tab whose committed URL matches the add-on's
// page-inject origins may talk to it, and only through messages the add-on
// registered. Origin is "<scheme>://<host>" (bns:// shown as https://).
function tabForSender(sender) { return tabs.find((t) => t.view.webContents === sender) || null; }
function pageOriginOf(url) {
try {
const u = new URL(String(url).replace(/^bns:\/\//i, "https://"));
return u.protocol && u.host ? `${u.protocol}//${u.host}` : null;
} catch { return null; }
}
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
const tab = tabForSender(e.sender);
if (!tab || !addonHost) throw new Error("not a page");
const url = e.sender.getURL();
const id = String(addonId || "");
if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`);
const origin = pageOriginOf(url);
if (!origin) throw new Error("opaque origin");
return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id });
});
// Synchronous — the inject preload has to know what to run before the page's
// own scripts start. Decided against the sender's committed URL; the href the
// preload reports is only logged when it disagrees.
// Assigning event.returnValue sends the reply at once, so it is set exactly
// once at the end.
function injectionsForSender(e, href) {
const tab = tabForSender(e.sender);
if (!tab || !addonHost) return [];
const url = e.sender.getURL();
if (!url || url.startsWith("file:")) return [];
if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`);
const origin = pageOriginOf(url);
const list = addonHost.injectionsFor(url).map((x) => ({ ...x, origin }));
if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}`);
return list;
}
ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); });
// Approval overlay. One request at a time; later callers queue behind the
// visible one so two dapps can't race each other for the same click.
let approvalPop = null;
const approvalQueue = [];
let approvalCurrent = null; // { reqId, resolve }
let approvalSeq = 0;
function pumpApproval() {
if (approvalCurrent || !approvalQueue.length || !approvalPop) return;
const next = approvalQueue.shift();
approvalCurrent = next;
try {
approvalPop.webContents.send("approval-show", next.req);
approvalPop.setVisible(true);
try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {}
layout();
approvalPop.webContents.focus();
} catch (err) {
approvalCurrent = null;
next.resolve("cancel");
console.warn("[addons] approval show failed:", err?.message);
}
}
function showApprovalModal(opts, addonId) {
const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
const req = {
reqId: ++approvalSeq,
addonId,
addonName: a ? a.manifest.name : addonId,
title: String(opts.title || "Approve?"),
body: opts.body == null ? "" : String(opts.body),
origin: opts.origin == null ? "" : String(opts.origin),
rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [],
actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [],
checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null,
// Optional dropdown; a non-empty chosen value comes back as "+<id>=<value>".
select: opts.select && Array.isArray(opts.select.options) ? {
id: String(opts.select.id || "choice"), label: String(opts.select.label || ""),
options: opts.select.options.map((o) => ({ value: String(o.value ?? ""), label: String(o.label ?? o.value ?? "") })),
} : null,
};
return new Promise((resolve) => {
approvalQueue.push({ req, resolve });
pumpApproval();
});
}
ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => {
if (!approvalPop || e.sender !== approvalPop.webContents) return false;
if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;
const cur = approvalCurrent;
approvalCurrent = null;
approvalPop.setVisible(false);
let result = String(action || "cancel");
if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id;
if (result !== "cancel" && cur.req.select && extra && cur.req.select.options.some((o) => o.value === extra)) {
result += "+" + cur.req.select.id + "=" + extra;
}
cur.resolve(result);
pumpApproval();
return true;
});
// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------
// Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main
// derives the add-on id from the sender's file:// URL so a page can only
// touch its own store; any file:// outside addons/ returns nothing.
ipcMain.handle("addon-storage-get", (e, key, fallback) => {
const id = addonIdForSender(e.sender);
if (!id) return fallback ?? null;
try {
const raw = JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8"));
return key in raw ? raw[key] : (fallback ?? null);
} catch { return fallback ?? null; }
});
ipcMain.handle("addon-storage-set", (e, key, value) => {
const id = addonIdForSender(e.sender);
if (!id) return false;
if (typeof key !== "string" || key.length > 128) return false;
const file = path.join(addonsDataDir(), id + ".json");
let store = {};
try { store = JSON.parse(fs.readFileSync(file, "utf8")); } catch {}
store[key] = value;
try { fs.mkdirSync(addonsDataDir(), { recursive: true }); fs.writeFileSync(file, JSON.stringify(store)); return true; }
catch (err) { console.warn(`[addons] storage.set failed for ${id}:`, err?.message); return false; }
});
ipcMain.handle("addon-storage-all", (e) => {
const id = addonIdForSender(e.sender);
if (!id) return {};
try { return JSON.parse(fs.readFileSync(path.join(addonsDataDir(), id + ".json"), "utf8")); }
catch { return {}; }
});
// 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=<name>; 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;
});
// "Did you mean" for the error page. Returns up to `limit` BCNR-registered
// names within a small edit distance of `host`, same TLD only. Uses the
// warm sharedIndex — no network call, no wait — so an offline user still
// gets suggestions if the index warmed at least once. Ranks by ascending
// distance, then alphabetical for a stable list.
function levenshtein(a, b) {
const m = a.length, n = b.length;
if (Math.abs(m - n) > 3) return 4; // early-out, we only care about ≤2
const prev = new Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j;
const cur = new Array(n + 1);
for (let i = 1; i <= m; i++) {
cur[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= n; j++) prev[j] = cur[j];
}
return prev[n];
}
ipcMain.handle("error-bns-similar", (e, host) => {
if (!isErrorPageSender(e.sender)) return [];
const h = String(host || "").trim().toLowerCase();
if (!h || !sharedIndex || typeof sharedIndex.keys !== "function") return [];
const tld = tldOf(h);
if (!tld) return [];
const cands = [];
const maxDist = 2;
for (const key of sharedIndex.keys()) {
if (typeof key !== "string") continue;
// Same TLD only — a typo like "games.x" → "game.x" or "gaeme.x" → "game.x".
if (!key.endsWith("." + tld)) continue;
if (key === h) continue;
const d = levenshtein(h, key);
if (d <= maxDist) cands.push({ name: key, dist: d });
if (cands.length > 200) break; // hard cap so a huge index doesn't stall the render
}
cands.sort((a, b) => (a.dist - b.dist) || a.name.localeCompare(b.name));
return cands.slice(0, 3).map((c) => c.name);
});
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 → download the installer
// through Theseus itself. session.downloadURL triggers the same
// will-download handler our own downloads panel listens on, so the file
// lands in the user's Downloads folder AND appears in the in-app
// downloads chip with progress + Show-in-folder. No system browser
// jump, no "why did another browser open?" confusion.
ipcMain.handle("open-update-download", (_e, url) => {
const ok = typeof url === "string" && (url.startsWith("https://dl.silentmode.st/") || url.startsWith("https://silentmode.st/"));
if (!ok) return false;
try {
// The downloads toolbar button spins + shows a progress badge as
// will-download / did-update updates fire, so the user sees the
// transfer without us having to force-open the downloads panel.
session.defaultSession.downloadURL(url);
return true;
} catch (e) { console.warn("update download failed:", e?.message); return false; }
});
ipcMain.handle("dismiss-update", () => { updateDismissedThisSession = true; emitUpdateAvailable(); return true; });
ipcMain.handle("recheck-update", async () => { await checkForUpdate(); return updateAvailable; });
// One-click "Install & restart". Requires the silent pre-fetch to have
// finished (updateDownloadState === "ready"). Launches the setup with /S
// (skips the wizard; our nsis/installer.nsh's Ariadne prompt is bypassed
// too on upgrades because the Ariadne registry key is already present),
// then quits Theseus so the installer can overwrite it. When the installer
// finishes, the user re-launches Theseus and lands on the new version.
ipcMain.handle("install-update-now", () => {
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
try {
const p = spawn(updateDownloadPath, ["/S"], { detached: true, stdio: "ignore" });
p.unref();
} catch (e) { console.warn("update spawn failed:", e?.message); return false; }
// Give the child a moment to inherit our arguments before we exit.
setTimeout(() => app.quit(), 400);
return true;
});
// Home page editable cards. Origin-gated to home.html — random pages that
// snoop the preload can't act on the local file.
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; });
ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; });
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
ipcMain.handle("reload", (_e, hard) => {
const wc = activeTab()?.view.webContents;
if (!wc) return;
try { hard ? wc.reloadIgnoringCache() : wc.reload(); } catch {}
});
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
ipcMain.handle("toggle-site-info", (_e, rect) => {
if (popVisible) return showPopover(false);
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showPopover(true);
});
ipcMain.handle("close-site-info", () => showPopover(false));
ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); });
// ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
ipcMain.handle("collision-switch", async (_e, arg) => {
const t = activeTab(); if (!t?.prov?.host) return null;
const host = t.prov.host, tld = tldOf(host);
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
const raw = arg?.choice;
const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr"
: (raw === "icann") ? "icann"
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
// Optional persistent memory ("Always use…" from the popover switcher).
rememberCollision(host, tld, choice, arg?.remember || "no");
const registry = registryOf(tld);
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
// collision-decision path can trigger a re-prompt on an explicit user switch.
// The user clicked the switcher; they've made their choice. Just load it.
setLoading(t, true);
t.internalNav = true;
try {
if (choice === "icann") {
t.url = "https://" + host + "/";
await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web", note: "switched to ICANN", tld, registry };
} else {
t.url = "https://" + host + "/"; // display: https://; internal fetch: bns://
await t.view.webContents.loadURL(`bns://${host}/`);
const rec = entries.get(host);
const r = rec?.entry?.records || {};
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
const _isSub = rec?.entry?.name && host !== rec.entry.name;
const src = (_isSub && r.ip) ? "direct server"
: r.h ? "on-chain (chain)"
: r.s3 ? "Sia network"
: r.ip ? "direct server"
: r.u ? "redirect"
: "record";
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
}
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
finally { t.internalNav = false; setLoading(t, false); }
if (t.id === activeId) pushNav(t.prov);
emitTabs();
});
ipcMain.handle("collision-state", () => ({
policy: settings.collisionPolicy,
byName: collisions.byName,
byTld: collisions.byTld,
bcnrTlds,
}));
ipcMain.handle("collision-set-policy", (_e, p) => {
if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); }
return settings.collisionPolicy;
});
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
// Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
// held in this main-process closure only — never sent to a renderer except
// in the explicit response to password-get(id). Cleared on quit alongside
// the other storage clears (see before-quit hook).
const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault");
let vaultState = null; // { key, purposeRoot, entries, _salt, _iters }
const vaultOk = () => ({ ok: true });
const vaultErr = (m) => ({ ok: false, err: String(m) });
ipcMain.handle("password-status", () => ({
setup: fs.existsSync(vaultFile()),
unlocked: !!vaultState,
}));
ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
try {
if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short");
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
const v = await loadVaultLib();
let purposeRootHex, messengerRootHex;
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
// Same seed, two purpose roots — one for password derivation, one for
// the Nostr messaging identity. Storing both means Hermes can bind to
// the vault so the user never re-enters the mnemonic. Different HKDF
// info strings keep the two subtrees cryptographically disjoint.
const seed = await v.bip39ToSeed(String(seedSource.mnemonic));
purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0"));
messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0"));
} else {
// Independent random seed — 32 bytes of purposeRoot directly. No mnemonic
// means no messenger root; Hermes will fall back to its own mnemonic entry.
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
purposeRootHex = v.bytesToHex(root);
}
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex,
messengerRootHex ? { messengerRootHex } : {});
emitPwAvailability();
return vaultOk();
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-unlock", async (_e, masterPassword) => {
try {
if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
const v = await loadVaultLib();
vaultState = await v.unlockVault(vaultFile(), masterPassword);
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-lock", () => { vaultState = null; emitPwAvailability(); return true; });
ipcMain.handle("password-list", async () => {
if (!vaultState) return { ok: false, err: "locked" };
const v = await loadVaultLib();
return { ok: true, entries: v.listMetadata(vaultState) };
});
ipcMain.handle("password-get", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const password = await v.resolvePassword(vaultState, id);
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-add", async (_e, spec) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const entry = v.newEntry(spec || {});
vaultState.entries.push(entry);
await v.saveVault(vaultFile(), vaultState);
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-update", async (_e, id, patch) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const e = vaultState.entries.find((x) => x.id === id);
if (!e) return vaultErr("no such entry");
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
// Switching between literal and generated: drop the other field.
if (patch && "literal" in patch) delete e.generated;
if (patch && "generated" in patch) delete e.literal;
await v.saveVault(vaultFile(), vaultState);
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-remove", async (_e, id) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
await v.saveVault(vaultFile(), vaultState);
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-generate", async (_e, { domain, username = "", version = 1, rules } = {}) => {
if (!vaultState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const password = await v.derivePassword(vaultState.purposeRoot, { domain, username, version, rules });
return { ok: true, password };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("clear-browsing-data", async (_e, opts) => {
const o = opts || {};
await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage });
if (o.history) await clearHistoryNow();
return true;
});
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
ipcMain.handle("set-search-engine", (_e, id) => {
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
return settings.searchEngine;
});
ipcMain.handle("add-engine", (_e, eng) => {
// A custom engine needs a name and a URL template containing "%s". Adding
// installs it in both the settings list AND the toolbar dropdown.
if (eng && eng.name && eng.url && String(eng.url).includes("%s")) {
const id = "custom-" + Date.now().toString(36);
settings.customEngines = [...(settings.customEngines || []),
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }];
// Custom engines are auto-installed and enabled.
settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])];
settings.searchEngine = id; // select the one just added
saveSettings(); emitEngines();
}
return { engines: allEngines(), current: settings.searchEngine };
});
ipcMain.handle("remove-engine", (_e, id) => {
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine };
});
// Right-click "Remove from list": drops a built-in from installedEngines AND
// enabledEngines so it goes back to the catalog. For custom engines this
// aliases to remove-engine (they don't live in installedEngines).
ipcMain.handle("remove-from-list", (_e, id) => {
if ((settings.customEngines || []).some((e) => e.id === id)) {
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
} else if (SEARCH_ENGINES[id]) {
settings.installedEngines = (settings.installedEngines || DEFAULT_ENABLED).filter((x) => x !== id);
} else {
return { engines: allEngines(), current: settings.searchEngine };
}
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.enabledEngines.length === 0) settings.enabledEngines = ["duckduckgo"]; // never empty
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine };
});
// Enable/disable a built-in engine. Enabling from the catalog also INSTALLS it
// (adds to installedEngines). Disabling only removes it from enabledEngines —
// it stays in installedEngines so the row remains visible with the toggle off.
ipcMain.handle("set-engine-enabled", (_e, id, on) => {
if (SEARCH_ENGINES[id]) {
let installed = (settings.installedEngines || DEFAULT_ENABLED).slice();
let enabled = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (on) {
if (!installed.includes(id)) installed.push(id);
enabled.push(id);
}
settings.installedEngines = installed;
settings.enabledEngines = enabled.length ? enabled : ["duckduckgo"]; // never empty
if (!enabledEnginesList().some((e) => e.id === settings.searchEngine))
settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
}
return { engines: allEngines(), current: settings.searchEngine };
});
ipcMain.handle("set-engine-order", (_e, ids) => {
if (Array.isArray(ids)) { settings.engineOrder = ids.filter((x) => typeof x === "string"); saveSettings(); emitEngines(); }
return { engines: allEngines(), current: settings.searchEngine };
});
// The custom dropdown overlay (real favicons).
ipcMain.handle("toggle-engine-picker", (_e, rect) => {
if (epVisible) return showEnginePicker(false);
if (rect) epPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showEnginePicker(true);
});
ipcMain.handle("close-engine-picker", () => showEnginePicker(false));
ipcMain.handle("ep-resize", (_e, h) => { epH = Math.max(80, Math.min(440, Math.round(h) || 320)); if (epVisible) positionEnginePicker(); });
ipcMain.handle("pick-engine", (_e, id) => {
if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
showEnginePicker(false);
});
ipcMain.handle("picker-open-settings", () => {
showEnginePicker(false);
const focus = (t) => { try { t.view.webContents.send("focus-section", "search"); } catch {} };
const ex = tabs.find((t) => t.settings);
if (ex) { setActive(ex.id); focus(ex); return; }
const id = createTab(null, { settings: true });
const t = tabById(id);
if (t) t.view.webContents.once("did-finish-load", () => focus(t));
});
// ---- Downloads --------------------------------------------------------------
ipcMain.handle("downloads-get", () => downloadsPublic());
ipcMain.handle("toggle-downloads", (_e, rect) => {
if (dlVisible) return showDownloads(false);
if (rect) dlPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showDownloads(true);
});
ipcMain.handle("close-downloads", () => showDownloads(false));
ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); });
// Address-bar suggestions: show/hide, forward arrow-keys to the dropdown.
ipcMain.handle("suggest-address", (_e, query, rect) => {
const suggestions = historySearch(query);
if (!suggestions.length) { showAddressPicker(false); return; }
if (rect) { apPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; apW = Math.max(280, Math.round(rect.w || 520)); }
showAddressPicker(true, suggestions);
});
ipcMain.handle("close-address-picker", () => showAddressPicker(false));
ipcMain.handle("address-picker-resize", (_e, h) => {
apH = Math.max(40, Math.min(400, Math.round(h) || 60));
if (apVisible) positionAddressPicker();
});
// Right-side X on a picker row: drop that URL from history without
// navigating. Sender-URL-gated to our own address-picker.html.
ipcMain.handle("address-forget", (e, url) => {
try { const u = e.sender.getURL() || ""; if (!/address-picker\.html/i.test(u)) return false; } catch { return false; }
if (typeof url !== "string" || !url) return false;
const before = history.length;
history = history.filter((h) => h.url !== url);
if (history.length === before) return false;
saveHistoryDebounced();
// Re-run the current query so the picker rerenders without the removed row.
return true;
});
ipcMain.handle("address-pick", (_e, url) => {
showAddressPicker(false);
if (!url) return;
const u = String(url);
// Push the picked URL to the chrome renderer directly so the address bar
// shows the full URL immediately. The tabs event's focus guard
// (document.activeElement !== $("url"))
// skips its value overwrite while the URL input still has DOM focus, and
// clicking a WebContentsView sibling doesn't always deliver the blur to
// the chrome renderer in time — user was left staring at their 3-letter
// typed query while the picked URL loaded behind it.
try { chrome?.webContents.send("address-picked", u); } catch {}
navigateTab(activeId, u);
});
// Password fill — chip in the toolbar opens a picker of matching credentials
// for the current site. Clicking a match injects the fill script into the
// active tab. Whole flow is user-initiated; no page-load DOM watchers yet.
ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
if (pwfVisible) return showPwFill(false);
const t = activeTab(); const host = t?.prov?.host || "";
const matches = pwMatchesForHost(host);
if (!matches.length) return showPwFill(false);
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showPwFill(true, matches);
});
ipcMain.handle("close-pw-fill", () => showPwFill(false));
ipcMain.handle("link-status-resize", (_e, w, h) => {
linkStatusW = Math.max(60, Math.min(2000, Math.round(w) || 100));
linkStatusH = Math.max(20, Math.min(60, Math.round(h) || 22));
if (linkStatusVisible) positionLinkStatus();
});
ipcMain.handle("pw-fill-resize", (_e, h) => {
pwfH = Math.max(60, Math.min(300, Math.round(h) || 80));
if (pwfVisible) positionPwFill();
});
ipcMain.handle("pw-fill-pick", async (_e, id) => {
showPwFill(false);
if (!vaultState) return { ok: false, err: "locked" };
try {
const v = await loadVaultLib();
const entry = vaultState.entries.find((x) => x.id === id);
if (!entry) return { ok: false, err: "no such entry" };
const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ username: entry.username, password });
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
});
// The chrome sends arrow-up/down/enter through so the picker can move its
// selection cursor without stealing focus from the address input.
ipcMain.handle("address-cursor", (_e, dir) => {
if (apVisible && addressPicker) try { addressPicker.webContents.send("address-cursor", dir); } catch {}
});
ipcMain.handle("download-open", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
});
ipcMain.handle("download-show", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.savePath) { try { shell.showItemInFolder(d.savePath); } catch {} }
});
ipcMain.handle("download-cancel", (_e, id) => {
const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} }
});
// Only clear finished downloads; a progressing one is cancelled first.
ipcMain.handle("download-clear", (_e, id) => {
const i = downloads.findIndex((x) => x.id === id);
if (i < 0) return;
if (downloads[i].state === "progressing") { const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } }
downloads.splice(i, 1); dlItems.delete(id);
emitDownloads();
});
ipcMain.handle("downloads-clear-all", () => {
// Keep any still-progressing ones; drop everything else.
for (let i = downloads.length - 1; i >= 0; i--) if (downloads[i].state !== "progressing") downloads.splice(i, 1);
emitDownloads();
});
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain.handle("add-detected-engine", () => {
const d = activeTab()?.detected;
if (d && d.url && d.url.includes("%s")) {
const id = "custom-" + Date.now().toString(36);
settings.customEngines = [...(settings.customEngines || []), { id, name: d.name.slice(0, 40), sym: "🔍", url: d.url.slice(0, 400) }];
settings.searchEngine = id;
saveSettings(); emitEngines();
}
showEnginePicker(false);
});
ipcMain.handle("bookmarks-get", () => bookmarks);
ipcMain.handle("bookmark-add", (_e, bm) => {
if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) {
const entry = { title: bm.title || bm.url, url: bm.url };
if (bm.favicon) entry.favicon = String(bm.favicon).slice(0, 2048);
bookmarks.push(entry);
saveBookmarks(); emitBookmarks();
}
return bookmarks;
});
// Update fields on an existing bookmark, identified by URL. Used by the
// inline title editor (window.prompt is disabled in Electron BrowserViews,
// so the renderer builds its own modal and calls this). Merges partial
// updates — omitted fields stay as they are, and blank strings are
// rejected for title so a bad edit can't wipe the label.
ipcMain.handle("bookmark-update", (_e, url, patch) => {
if (typeof url !== "string" || !url || !patch || typeof patch !== "object") return bookmarks;
const bm = bookmarks.find((b) => b.url === url);
if (!bm) return bookmarks;
if (typeof patch.title === "string" && patch.title.trim()) bm.title = patch.title.trim().slice(0, 200);
if (typeof patch.favicon === "string" && patch.favicon) bm.favicon = patch.favicon.slice(0, 2048);
saveBookmarks(); emitBookmarks();
return bookmarks;
});
ipcMain.handle("bookmark-remove", (_e, url) => {
bookmarks = bookmarks.filter((b) => b.url !== url);
saveBookmarks(); emitBookmarks();
return bookmarks;
});
ipcMain.handle("settings-get", () => settings);
ipcMain.handle("settings-set", (_e, key, val) => {
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
if (key === "webrtcMode") applyWebRTCPolicy();
if (key === "theme") applyTheme();
if (key === "backgroundThrottle") applyThrottle();
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
"locationMode", "locationRegion", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
return settings;
});
ipcMain.handle("set-chrome-height", (_e, h) => {
const next = Math.max(74, Math.min(260, Math.round(h) || 84));
if (next !== CHROME_H) { CHROME_H = next; layout(); }
});
ipcMain.handle("switch-to-bcnr", () => {
const t = activeTab(); if (!t || !t.bcnrOffer) return;
const { host, rest, tld } = t.bcnrOffer;
t.bcnrOffer = null;
chrome.webContents.send("bcnr-offer", null);
return loadBns(t, activeId, host, rest || "/", tld);
});
// ---- Hermes messages panel -------------------------------------------------
// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses
// the wallet mnemonic to derive the Nostr identity in-memory only — the seed
// and secret key are never written to disk. The panel process holds one
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
// and opens a per-send WebSocket for publishes.
const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"];
const HERMES_INBOX_LIMIT = 200;
let hermesWin = null;
// State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map<url, { ws, ready }> }
// skHex is held instead of the raw Uint8Array so it can be Buffer-restored per operation
// (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about).
let hermesState = null;
const hOk = (o = {}) => ({ ok: true, ...o });
const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) });
function hermesEmit(channel, payload) {
if (hermesWin && !hermesWin.isDestroyed()) hermesWin.webContents.send(channel, payload);
}
function hermesRecord(msg) {
hermesState.inbox.push(msg);
if (hermesState.inbox.length > HERMES_INBOX_LIMIT) {
hermesState.inbox.splice(0, hermesState.inbox.length - HERMES_INBOX_LIMIT);
}
hermesEmit("hermes-message", msg);
}
// Pushed on every relay connect/disconnect so the pill in the panel reflects
// reality without polling. Cheap; sent to the renderer whenever a socket
// transitions ready/not-ready.
function hermesEmitStatus() {
if (!hermesState) return;
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
hermesEmit("hermes-status-update", {
relaysConnected: connected,
relaysTotal: hermesState.relays.size,
});
}
// Reverse-resolve a pkHex → .bch name by scanning the (cached) BNS index.
// Populates senderName in the inbox so incoming DMs render as
// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState
// (dropped on hermes-close) so a re-init starts clean.
async function hermesReverseResolve(pkHex) {
if (!hermesState) return null;
if (!hermesState._pkToName) hermesState._pkToName = new Map();
if (hermesState._pkToName.has(pkHex)) return hermesState._pkToName.get(pkHex);
try {
const R = await getResolver();
const H = await loadHermesLib();
const idx = await R.buildIndex({ WebSocket });
for (const [name, entry] of idx) {
const raw = entry && entry.records && entry.records.np;
if (typeof raw !== "string" || !raw.trim()) continue;
try {
const hex = H.parseNpRecord(raw);
if (hex === pkHex) {
hermesState._pkToName.set(pkHex, name);
return name;
}
} catch { /* malformed np — skip */ }
}
} catch { /* chain unreachable — leave unresolved this round */ }
hermesState._pkToName.set(pkHex, null); // negative-cache so we don't re-scan every message
return null;
}
// One receive subscription per relay. If the socket dies we resurrect it on a
// backoff — a locked / logged-out state tears them all down cleanly.
async function hermesConnectRelay(url) {
const H = await loadHermesLib();
const state = { ws: null, ready: false, subId: "hermes-inbox" };
const open = () => {
if (!hermesState) return; // torn down while reconnecting
const ws = new WebSocket(url);
state.ws = ws;
ws.on("open", () => {
state.ready = true;
hermesEmitStatus();
ws.send(JSON.stringify(["REQ", state.subId, { kinds: [1059], "#p": [hermesState.pkHex] }]));
});
ws.on("message", async (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] !== "EVENT" || msg[1] !== state.subId) return;
try {
const sk = Buffer.from(hermesState.skHex, "hex");
const opened = H.unwrapChat({ receiverSk: sk, wrap: msg[2] });
// Dedupe: a wrap arriving from multiple relays produces the same rumor id
// (kind:14 hash), but since we don't expose the rumor id here we dedupe
// on (senderPkHex, text, createdAt) which is sufficient for MVP.
const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text;
if (hermesState._seen && hermesState._seen.has(key)) return;
hermesState._seen && hermesState._seen.add(key);
// Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget
// wouldn't work — we need the name in the record we push). Await here;
// the cache short-circuits after the first miss per pk.
const senderName = await hermesReverseResolve(opened.senderPkHex);
hermesRecord({
senderPkHex: opened.senderPkHex,
senderName,
text: opened.text,
createdAt: opened.createdAt,
});
} catch { /* unwrap failure = not for us, or malformed — drop silently */ }
});
ws.on("close", () => {
state.ready = false;
hermesEmitStatus();
// Attempt reconnect if we're still supposed to be running.
if (hermesState && hermesState.relays.get(url) === state) {
setTimeout(open, 3000);
}
});
ws.on("error", () => { /* close handler will retry */ });
};
open();
return state;
}
function hermesTeardown() {
if (!hermesState) return;
for (const state of hermesState.relays.values()) {
try { state.ws?.close(1000); } catch {}
}
hermesState = null;
}
ipcMain.handle("hermes-status", () => {
if (!hermesState) return hOk({ ready: false });
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
return hOk({
ready: true,
npub: hermesState.npub,
pkHex: hermesState.pkHex,
relaysConnected: connected,
relaysTotal: hermesState.relays.size,
});
});
// Init modes:
// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user)
// { useVault: true } — reuse the password vault's messenger root; no re-entry
// required. Available iff vault is unlocked AND was set
// up from a mnemonic (so messengerRoot was persisted).
ipcMain.handle("hermes-init", async (_e, opts = {}) => {
try {
hermesTeardown();
const H = await loadHermesLib();
let sk, pkHex, npub;
if (opts.useVault) {
if (!vaultState || !vaultState.messengerRoot) {
return hErr("password vault is locked or was set up without a mnemonic");
}
({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot));
} else {
const mnemonic = opts.mnemonic;
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
return hErr("enter a 12- or 24-word mnemonic");
}
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()));
}
hermesState = {
skHex: Buffer.from(sk).toString("hex"),
pkHex, npub,
inbox: [],
relays: new Map(),
_seen: new Set(),
};
for (const url of HERMES_DEFAULT_RELAYS) {
hermesState.relays.set(url, await hermesConnectRelay(url));
}
// Give sockets a beat to open before reporting connected count.
await new Promise((r) => setTimeout(r, 400));
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic",
relaysConnected: connected, relaysTotal: hermesState.relays.size });
} catch (e) { hermesTeardown(); return hErr(e); }
});
// Cheap query: "is the vault-bound sign-in path available right now?" Panel
// uses this to decide whether to show the 'Use password vault' button.
ipcMain.handle("hermes-can-use-vault", () => hOk({
available: !!(vaultState && vaultState.messengerRoot),
}));
ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); });
ipcMain.handle("hermes-inbox", () => {
if (!hermesState) return hErr("not initialised");
return hOk({ messages: hermesState.inbox.slice() });
});
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
// via the portable resolver (getResolver above), the `np` record parsed, then
// wrapped + published to each relay listed in `nr` (falling back to defaults).
ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => {
if (!hermesState) return hErr("not initialised");
if (!to || !text) return hErr("to and text required");
try {
const H = await loadHermesLib();
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
const trimmed = String(to).trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
recipientPkHex = trimmed.toLowerCase();
} else if (trimmed.startsWith("npub1")) {
recipientPkHex = H.parseNpRecord(trimmed);
} else {
const R = await getResolver();
const name = R.normalizeName(trimmed);
const entry = await R.resolveName(name, { WebSocket });
if (!entry) return hErr(`no on-chain registration for ${name}`);
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
recipientPkHex = parsed.npPk;
relays = parsed.relays;
}
const sk = Buffer.from(hermesState.skHex, "hex");
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
const publishOne = (url) => new Promise((resolve) => {
const ws = new WebSocket(url);
let settled = false;
const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); };
const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000);
ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap])));
ws.on("message", (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] === "OK" && msg[1] === wrap.id) {
clearTimeout(t);
done({ url, ok: !!msg[2], detail: msg[3] || "" });
}
});
ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message }));
});
const results = await Promise.all(relays.map(publishOne));
const accepted = results.filter((r) => r.ok).length;
return hOk({ recipientPkHex, accepted, total: results.length, results });
} catch (e) { return hErr(e); }
});
function openHermesWindow() {
if (hermesWin && !hermesWin.isDestroyed()) {
hermesWin.focus(); return;
}
hermesWin = new BrowserWindow({
width: 720, height: 620, title: "Messages — Theseus",
backgroundColor: "#0b0e14",
webPreferences: {
preload: path.join(__dirname, "messages-preload.js"),
contextIsolation: true, nodeIntegration: false,
},
});
hermesWin.setMenuBarVisibility(false);
hermesWin.loadFile("messages.html");
hermesWin.on("closed", () => { hermesWin = null; });
}
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
// --- BCNR provider (window.bcnr) — read-only surface. See
// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus
// already runs; nothing about the user leaks, so no origin/permission gate.
// A malicious page can call these; the worst it learns is what the chain
// says publicly, which it could equally get through Argus. The preload that
// exposes these lives at bcnr-preload.js and is installed session-wide
// inside whenReady below.
//
// B.2b: eTLD+1 origin binding. The read methods below don't need the origin
// — chain data is public — so they don't compute it. Instead pages that
// want to see how Theseus will bucket their permissions can call
// `window.bcnr.getOrigin()` (handler further down). B.3's write methods
// (signMessage/sendPayment/registerName) will call `callerOrigin(event)` at
// entry and check the result against wallet-permissions.json.
const { originOf } = require("./bcnr-origin.js");
function callerOrigin(event) {
try { return originOf(event.sender.getURL(), { bcnrTlds }); }
catch { return null; }
}
// wallet-permissions.json — per-origin (eTLD+1) grants for B.3's write
// methods. Scaffolded in B.2b so B.3 doesn't have to touch main.js's
// on-disk conventions. Shape is intentionally open — B.3 will define the
// concrete decision values ("always" | "once" | "never", amount caps,
// expiries) as each write method lands.
let walletPermissions = {};
const walletPermissionsFile = () => path.join(app.getPath("userData"), "wallet-permissions.json");
function loadWalletPermissions() {
try {
if (fs.existsSync(walletPermissionsFile())) {
const raw = JSON.parse(fs.readFileSync(walletPermissionsFile(), "utf8"));
if (raw && typeof raw === "object") walletPermissions = raw;
}
} catch (e) { console.error("wallet-permissions load failed:", e.message); }
}
function saveWalletPermissions() {
try { fs.writeFileSync(walletPermissionsFile(), JSON.stringify(walletPermissions, null, 2)); }
catch (e) { console.error("wallet-permissions save failed:", e.message); }
}
// B.3 will use these. Kept here so the storage owner is one place.
function getWalletPermission(origin, method) {
if (!origin || !method) return null;
return walletPermissions[origin]?.[method] ?? null;
}
function setWalletPermission(origin, method, value) {
if (!origin || !method) return;
if (!walletPermissions[origin]) walletPermissions[origin] = {};
walletPermissions[origin][method] = value;
saveWalletPermissions();
}
void getWalletPermission; void setWalletPermission; // silence unused-in-B.2b
function serializeEntry(entry) {
if (!entry) return null;
// Explicit whitelist — records/category/txid/height are the on-chain facts
// the design's `resolveName` promises. `updatedTxid` is included because it
// shifts every UPD and lets `getRecordVersion` distinguish a REG-only entry
// from one that has been updated in place.
return {
name: entry.name,
category: entry.category,
records: entry.records ?? {},
txid: entry.txid,
height: entry.height,
updatedTxid: entry.updatedTxid ?? null,
};
}
ipcMain.handle("bcnr:resolveName", async (_e, name) => {
if (typeof name !== "string" || !name) return null;
try { return serializeEntry(await resolveHost(name)); }
catch { return null; }
});
ipcMain.handle("bcnr:isRegistered", async (_e, name) => {
if (typeof name !== "string" || !name) return false;
try { return (await resolveHost(name)) != null; }
catch { return false; }
});
ipcMain.handle("bcnr:getBcnrTlds", () => bcnrTlds.slice());
// Diagnostic — returns the eTLD+1 permission origin Theseus computes for the
// caller. Same value B.3's write methods will gate on. No leak: a page can
// already read its own location.href; this just tells it how Theseus buckets
// its permissions (so a dApp dev can see that pay.foo.bch and blog.foo.bch
// share one grant).
ipcMain.handle("bcnr:getOrigin", (e) => callerOrigin(e));
ipcMain.handle("bcnr:getRecordVersion", async (_e, name) => {
if (typeof name !== "string" || !name) return null;
try {
const entry = await resolveHost(name);
if (!entry) return null;
// The pair (updatedTxid ?? txid, height) uniquely identifies which reveal
// a dApp is looking at. dApps poll this cheaply and re-fetch records only
// when it changes.
return {
txid: entry.updatedTxid ?? entry.txid,
regTxid: entry.txid,
height: entry.height,
};
} catch { return null; }
});
// App-level keyboard shortcuts. One `web-contents-created` hook covers
// every WebContents (chrome, tab views, overlays) without per-view wiring.
// Reload/hard-reload always target the active tab, regardless of which
// view received the key (URL bar focused, overlay focused, etc.), so the
// user's mental model matches every browser they've ever used.
app.on("web-contents-created", (_event, wc) => {
wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return;
// Ctrl+Shift+M -> Messages panel
if (input.control && input.shift && (input.key === "M" || input.key === "m")) {
openHermesWindow();
return e.preventDefault();
}
// Ctrl+B -> toggle add-on sidebar (matches the VS Code convention).
// Silently no-ops if no add-on has registered a sidebar panel yet.
if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b")) {
toggleSidebar();
return e.preventDefault();
}
// DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active
// TAB (not the chrome/overlay that received the keystroke — a user
// debugging a page wants the page's inspector, always). Detach into
// its own window so we don't shove tools into the tab strip.
const isI = input.key === "I" || input.key === "i";
if (input.key === "F12" || (input.control && input.shift && isI)) {
const t = activeTab();
if (t) {
try {
const twc = t.view.webContents;
if (twc.isDevToolsOpened()) twc.closeDevTools();
else twc.openDevTools({ mode: "detach" });
} catch {}
}
return e.preventDefault();
}
// Reload: F5 or Ctrl+R soft; Ctrl+F5 or Ctrl+Shift+R hard (bypass cache).
// Chromium's built-in accelerators are unreliable once the app menu is
// null (Menu.setApplicationMenu(null) above), and none of them cover
// the hard variants anyway — so we wire all four explicitly.
const isR = input.key === "R" || input.key === "r";
const isF5 = input.key === "F5";
if (isF5 || (input.control && isR)) {
const t = activeTab();
if (t && !t.settings) {
const hard = (input.control && input.shift && isR) || (input.control && isF5);
try {
if (hard) t.view.webContents.reloadIgnoringCache();
else t.view.webContents.reload();
} catch {}
}
return e.preventDefault();
}
});
});
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
if (!process.env.THESEUS_NO_AUTOSTART) {
app.whenReady().then(() => {
Menu.setApplicationMenu(null); // drop the native File/Edit/View/Help menu bar
loadSettings();
applyTheme();
loadBookmarks();
loadHistory();
loadCollisions();
loadWalletPermissions();
applyPermissions();
applyEmbedCookieShim();
applyAcceptLanguage();
// Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView
// preloads (home/settings/popover/etc.), which stack on top of it. Must be
// called before any tab is created; whenReady runs before createWindow().
try {
const bcnrPreload = path.join(__dirname, "bcnr-preload.js");
// Add-on page-inject bridges ride the same session-wide slot; the
// preload asks main which (if any) apply to the tab it runs in.
const injectPreload = path.join(__dirname, "addon-inject-preload.js");
const existing = session.defaultSession.getPreloads();
const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p));
if (wanted.length) session.defaultSession.setPreloads([...existing, ...wanted]);
} catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); }
protocol.handle("bns", serveBns);
installDownloadTracker();
initAddons();
createWindow();
// Multi-source BNS warm-up so the first .bch page opens near-instantly and
// stays fresh for as long as the browser is running. Every source runs in
// parallel — none of them can block a navigation.
// 1) sync: load the on-disk snapshot (user cache > bundled).
// sharedIndex is set BEFORE the first navigation can even fire.
// 2) async, continuous: startBnsPolling() opens one electrum connection
// every 30 s, fetches the beacon history (one call), and only pulls
// the tx bodies we don't already have. Merges into currentSnapshotState
// and persists — so on every page navigation the sharedIndex is at
// most 30 s old with zero user-visible latency.
// 3) async, one-shot: refresh the on-disk snapshot from the operator's
// Sia mirror. Wins the NEXT boot, not this one — after a long idle
// period the browser resumes from a snapshot fresher than the poll
// could catch up on quickly.
// 4) fallback: ensureIndex() still exists for the very first launch
// where the bundled snapshot is absent AND the poll hasn't landed
// yet — a full-walk build.
warmFromSnapshot().catch(() => {});
startBnsPolling();
refreshSnapshotFromSia().catch(() => {});
ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot
// Cheap update check: fetch the releases manifest and, if a newer
// version is out, surface a chip in the toolbar. No auto-install —
// clicking the chip opens the download URL. Recheck every 6h so a
// browser left running for days catches updates without a relaunch.
checkForUpdate().catch(() => {});
setInterval(() => checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000);
// Home cards are also polled from a remote URL — brand copy updates then
// reach every install without a browser release. User's local edits stay
// authoritative (loadHomeCards checks them first).
refreshRemoteHomeCards().catch(() => {});
setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS);
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on("before-quit", async (e) => {
// Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank.
saveSession();
stopTor();
stopBnsPolling(); // silence the background delta refresh before exit
vaultState = null; // drop the in-memory vault key + purposeRoot
try {
await clearBrowsingData({
cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit,
});
if (settings.clearHistoryOnQuit) {
try { fs.unlinkSync(sessionFile()); } catch {}
}
} catch (err) { console.error("before-quit clear failed:", err?.message); }
});
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
}
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf };