theseus/main.js
Local Dev d7d127d7b4 fix(theseus/bns): failed content fetches get a real error page naming the upstream and cause
A bns:// fetch that fails after the name resolved (relay unreachable, DNS
stalling, the site's own server down) used to answer with the bare text
"Theseus error: fetch failed", which reads as a broken browser. The
handler now returns a styled page that names the host, the upstream it
tried (navigate.st, the p-record origin or the ip record), the error and
its cause code, explains the likely reason per cause (unreachable vs DNS),
and offers a retry.
2026-09-12 09:09:06 +02:00

5199 lines
268 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
// Search-engine favicon source. DuckDuckGo's icons.duckduckgo.com/ip3/…
// service was returning inconsistent results (Brave, Bing, Yandex etc.
// came back 404 → the settings row fell through to an emoji). Google's
// /s2/favicons service is materially more reliable, returns a real 32×32
// PNG for essentially every host, and doesn't require login. Kept as a
// single point so the fallback source can be swapped again in one place.
const faviconUrl = (domain) => (domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : 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,
// Toolbar bar sizes. The URL bar is flex:1 by default so it eats all
// remaining space; `compact`/`medium` cap it so the extension dock has
// room to grow. The search box is fixed-width; hidden removes it.
urlBarSize: "wide", // compact | medium | wide (default)
searchBoxSize: "normal", // hidden | compact | normal (default) | wide
// Drag-set widths in pixels. Non-zero → override the corresponding size
// preset; zero/null → follow the preset. Set by the toolbar drag handles.
urlBarWidthPx: 0,
searchBoxWidthPx: 0,
// DevTools dock position. "bottom" (default) opens the console under the
// tab, matching Chrome's own default; "sidebar" docks it in the right
// sidebar, replacing the add-on sidebar while it's open; "two-sidebars"
// opens the console AND lets the add-on sidebar stay visible on its own
// right-side dock — Electron's mode:right takes over the right edge, so
// "two-sidebars" is drawn as mode:right and the add-on sidebar is not
// forced closed; the two share the right area (add-on sidebar keeps its
// width, DevTools takes what's left).
devToolsDock: "bottom", // bottom | sidebar | two-sidebars
};
// 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 {}
}
// Strip Electron + Theseus tokens from the User-Agent so Cloudflare's WAF
// (and other bot heuristics) don't flag every request. Verified: sending
// Mozilla/5.0 (…) theseus-navigator/0.3.22 Chrome/… Electron/33.4.11 Safari/537.36
// to whybitcoincash.com got HTTP 503 from Cloudflare; the same request
// without the theseus + Electron tokens returns 200. Brave / Vivaldi / Slack
// (Electron) all do the same strip — a Chromium browser identifying itself
// as vanilla Chrome is standard practice in the Electron ecosystem.
function stockChromeUA() {
try {
return session.defaultSession.getUserAgent()
.replace(/ *theseus-navigator\/\S+/i, "")
.replace(/ *Electron\/\S+/i, "");
} catch { return null; }
}
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() {
const loc = effLocale() || app.getLocale() || "en-US";
try {
const ua = stockChromeUA() || session.defaultSession.getUserAgent();
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
} catch {}
}
// Client-hint headers (sec-ch-ua family) rewritten to look like stock Chrome.
//
// Why: Cloudflare Bot Fight Mode / Turnstile flag "UA claims Chrome but client
// hints don't confirm it" as bot. Electron's default sec-ch-ua reads
// "Chromium";v="130", "Not(A:Brand";v="99"
// — no "Google Chrome" brand (that's the closed-source Google branding
// Chromium doesn't carry). Combined with a UA already stripped of the
// Electron token, the mismatch is the fingerprint. Brave, Vivaldi and Opera
// solved this by shipping their own sec-ch-ua that INCLUDES Chrome-family
// brands so Cloudflare's allow-list catches them; whybitcoincash.com and
// other CF-fronted sites are what we run into without this.
//
// Approach: onBeforeSendHeaders across every session request. Overwrite
// sec-ch-ua and sec-ch-ua-full-version-list to a canonical stock-Chrome
// pair using Chromium's REAL major version from process.versions.chrome
// (so the story stays consistent — no version straddling to fingerprint).
// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to
// the actual OS name so a Linux user still looks like a Linux user.
function applyClientHintsSpoof() {
try {
const chromeVer = String(process.versions.chrome || "130");
const major = chromeVer.split(".")[0] || "130";
const brands = `"Google Chrome";v="${major}", "Chromium";v="${major}", "Not?A_Brand";v="99"`;
const fullList = `"Google Chrome";v="${chromeVer}", "Chromium";v="${chromeVer}", "Not?A_Brand";v="99.0.0.0"`;
const platform = process.platform === "darwin" ? '"macOS"'
: process.platform === "win32" ? '"Windows"'
: '"Linux"';
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const h = details.requestHeaders || {};
// Header names as Chromium sends them are typically kebab-case-lowercase;
// rewrite lowercase and also strip any Case-variant keys Electron
// may have set so we don't double up.
for (const k of Object.keys(h)) {
const kl = k.toLowerCase();
if (kl === "sec-ch-ua" || kl === "sec-ch-ua-full-version-list" ||
kl === "sec-ch-ua-mobile" || kl === "sec-ch-ua-platform") {
delete h[k];
}
}
h["sec-ch-ua"] = brands;
h["sec-ch-ua-full-version-list"] = fullList;
h["sec-ch-ua-mobile"] = "?0";
h["sec-ch-ua-platform"] = platform;
callback({ requestHeaders: h });
});
} catch (e) { console.warn("client-hints spoof setup failed:", e?.message); }
}
// ---- session restore + background throttling ----
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
// v2 format: { v: 2, urls, active }. v1 was a bare array of URLs; reading one
// maps to active = last tab, which is what the old restore loop ended up
// showing (each createTab activated itself, so the rightmost tab won).
function saveSession() {
try {
const live = tabs.filter((t) => !t.settings && t.url);
const active = Math.max(0, live.findIndex((t) => t.id === activeId));
fs.writeFileSync(sessionFile(), JSON.stringify({ v: 2, urls: live.map((t) => t.url), active }));
} catch (e) { console.error("session save failed:", e.message); }
}
function loadSession() {
try {
if (!fs.existsSync(sessionFile())) return { urls: [], active: 0 };
const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8"));
const list = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.urls) ? raw.urls : []);
const urls = list.filter((u) => typeof u === "string" && u);
const last = Math.max(urls.length - 1, 0);
const active = Array.isArray(raw) ? last : Math.min(Math.max(Number(raw.active) || 0, 0), last);
return { urls, active };
} catch { return { urls: [], active: 0 }; }
}
// Session restore, staggered. Creating every saved tab in one synchronous
// burst meant N renderer processes launching on top of chrome.html's first
// paint — on a slow or busy machine the window sat blank for seconds. The
// active tab is created first (so the user's page is on screen right away)
// and the rest arrive one per RESTORE_STAGGER_MS as background tabs, slotted
// into their saved strip positions.
const RESTORE_STAGGER_MS = 150;
function restoreTabs() {
const saved = settings.restoreSession ? loadSession() : { urls: [], active: 0 };
if (!saved.urls.length) { createTab(); return; }
createTab(saved.urls[saved.active]);
const rest = saved.urls.map((url, i) => ({ url, i })).filter((x) => x.i !== saved.active);
const step = () => {
if (!rest.length || !win || win.isDestroyed()) return;
const { url, i } = rest.shift();
try {
createTab(url, { background: true });
// createTab appends; move the new tab to its saved slot. Lower indices
// are created first, so `i` is the right position on both sides of
// the active tab.
const t = tabs.pop();
tabs.splice(Math.min(i, tabs.length), 0, t);
emitTabs();
} catch (e) { console.warn("session restore: tab failed:", e?.message); }
setTimeout(step, RESTORE_STAGGER_MS);
};
setTimeout(step, RESTORE_STAGGER_MS);
}
// ---- deferred overlay loads ----
// The floating overlays (site-info popover, engine picker, downloads,
// address suggestions, password fill, link-status pill, add-on approval)
// are WebContentsViews created with the window, but their HTML used to be
// loaded in the same tick as chrome.html — seven extra renderers racing the
// toolbar for the first paint. They now load shortly after chrome reports
// did-finish-load, or on first use, whichever comes first.
const overlayLoads = []; // [{ view, file }] waiting for loadOverlays()
let overlaysLoaded = false;
function deferOverlayLoad(view, file) { overlayLoads.push({ view, file }); }
function loadOverlays() {
if (overlaysLoaded) return;
overlaysLoaded = true;
for (const { view, file } of overlayLoads.splice(0)) {
try { view.webContents.loadFile(file); } catch (e) { console.warn(`overlay load failed (${file}):`, e?.message); }
}
}
// Resolves once `view` has finished loading its page, loading the overlays
// first if that hasn't happened yet. Bounded so a wedged renderer can't
// hang the caller forever.
function overlayReady(view, timeoutMs = 4000) {
loadOverlays();
const wc = view.webContents;
try { if (wc.getURL() && !wc.isLoading()) return Promise.resolve(); } catch {}
return new Promise((resolve) => {
const timer = setTimeout(done, timeoutMs);
function done() { clearTimeout(timer); wc.removeListener("did-finish-load", done); resolve(); }
wc.on("did-finish-load", done);
});
}
// Post-paint boot. Runs once chrome.html has loaded (createWindow arms a
// fallback timer in case it never does). Everything here used to start in
// whenReady, before the toolbar had painted — the BNS index build, three
// network fetches, every restored tab and seven overlay renderers all piled
// onto the main thread in the same ~300 ms, and the window sat blank with
// a white toolbar strip until they drained.
let chromeReadyDone = false;
let bnsWarm = null; // promise from the warmFromSnapshot() kicked off in whenReady
function onChromeReady() {
if (chromeReadyDone) return;
chromeReadyDone = true;
if (addonHost) addonHost.signalUiReady();
// Tabs wait for the BNS warm-up so sharedIndex is set before the first
// restored tab navigates; the rest of the multi-source refresh follows
// (see the comment block above startBnsPolling for how the sources
// cooperate).
(bnsWarm || warmFromSnapshot().catch(() => null)).then(() => {
try { restoreTabs(); }
catch (e) { console.error("restoreTabs failed:", e?.message); try { createTab(); } catch {} }
startBnsPolling();
ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot
});
// Re-emit any pending update notice — harmless if nothing is pending.
emitUpdateAvailable();
setTimeout(loadOverlays, 250);
refreshSnapshotFromSia().catch(() => {});
checkForUpdate().catch(() => {});
refreshRemoteHomeCards().catch(() => {});
}
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) } });
};
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
// on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and
// sends `Host:` of the upstream so vhost-based origins answer correctly.
// Not applied under subdomain inheritance — see public-gateway.mjs and the
// matching test in record-picker.test.mjs for why. Preserve any path prefix
// in r.p (e.g. `{p:"https://api.host/v1"}` + request "/x" → ".../v1/x").
const serveP = async () => {
const base = new URL(r.p);
const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
const target = base.origin + prefix + reqPath + url.search;
const up = await fetch(target, { redirect: "manual" });
const body = Buffer.from(await up.arrayBuffer());
const ct = up.headers.get("content-type") || guessType(reqPath);
return new Response(body, { status: up.status, headers: { "content-type": ct } });
};
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 (!isSubdomain && r.p) return await serveP();
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) {
// The upstream fetch failed — relay unreachable, DNS stalling, site's
// own server down. A bare "fetch failed" reads as "Theseus is broken";
// name the upstream and the cause code so it reads as what it is,
// and give a retry.
const cause = e?.cause?.code || e?.cause?.message || "";
const upstream = r.s3 ? new URL(GATEWAY).host : r.p ? (() => { try { return new URL(r.p).host; } catch { return r.p; } })() : r.ip ? String(r.ip) : "";
return new Response(bnsErrorHtml({ host, message: e?.message || String(e), cause, upstream }),
{ status: 502, headers: { "content-type": "text/html; charset=utf-8" } });
}
}
// Error surface for a bns:// fetch that failed after the name resolved.
// Self-contained HTML (this is a protocol handler response, not a tab
// navigation, so error.html's loadFile path doesn't apply).
function bnsErrorHtml({ host, message, cause, upstream }) {
const esc = (s) => String(s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const hint = /ETIMEDOUT|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/.test(cause)
? `Theseus resolved <b>${esc(host)}</b> but could not reach <b>${esc(upstream || "its server")}</b> from this network. Check your connection or VPN and try again.`
: /ENOTFOUND|EAI_AGAIN/.test(cause)
? `Your system DNS could not look up <b>${esc(upstream || "the server")}</b>. A stale or unreachable DNS server on one of your network adapters is the usual cause.`
: `Theseus resolved <b>${esc(host)}</b> but the content fetch from <b>${esc(upstream || "its server")}</b> failed.`;
return `<!doctype html><html><head><meta charset="utf-8"><title>Cant reach ${esc(host)}</title>
<style>:root{color-scheme:dark}body{margin:0;background:#0f1420;color:#e8ecf3;font:15px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh}
.card{max-width:560px;padding:32px 36px;background:#1b2330;border:1px solid #ffffff1f;border-radius:14px}h1{font-size:20px;margin:0 0 10px}p{margin:8px 0;color:#b9c2d0}
code{font:12.5px ui-monospace,monospace;color:#D6FF3D;background:#0f1420;padding:2px 6px;border-radius:5px}
a.btn{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:999px;background:#D6FF3D;color:#0f1420;font-weight:600;text-decoration:none}</style></head>
<body><div class="card"><h1>Cant reach ${esc(host)}</h1><p>${hint}</p>
<p><code>${esc(message)}${cause ? " · " + esc(cause) : ""}</code></p>
<a class="btn" href="javascript:location.reload()">Try again</a></div></body></html>`;
}
// ---- 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;
// When a panel asks for "maximize" (screenshot editor wanting the full canvas
// area), we widen the sidebar to fill the window and remember the previous
// width so restore drops us back exactly. Non-persisted: closing/reopening
// Theseus always starts un-maximized.
let sidebarMaximized = false;
let sidebarPreMaxW = SIDEBAR_W_DEFAULT;
function sidebarMaxWidth() {
if (!win) return SIDEBAR_W_MAX;
const { width } = win.getContentBounds();
return Math.max(SIDEBAR_W_MIN, width);
}
// 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");
const addonUpdater = require("./addon-updater.js");
const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js");
function addonsUserDir() { return path.join(app.getPath("userData"), "addons"); }
function addonsDataDir() { return path.join(app.getPath("userData"), "addons-data"); }
function addonsBackupDir() { return path.join(app.getPath("userData"), "addons-backups"); }
function addonsStagedDir() { return path.join(app.getPath("userData"), "addons-updates-staged"); }
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
// addons directory. Users can then edit, disable, or delete them — the
// framework treats bundled and user add-ons identically, no special path
// handling.
//
// Update rule: reseed when the bundled addon.json version differs from the
// user's on-disk addon.json version. Before reseeding, rename the user copy
// to <id>-<oldver>-<stamp>/ under <userData>/addons-backups/ so any local
// edits survive. If the two versions match we leave the folder alone —
// users who fork by bumping their own version stay pinned; users who edit
// files without bumping accept upstream updates.
function readAddonVersion(dir) {
try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8"))?.version ?? null; }
catch { return null; }
}
// One-time migration for the bchwallet → aegis rename + the siawallet
// retirement. Idempotent: after the first run the sources are gone and
// subsequent runs are no-ops.
//
// - addons/bchwallet/ → addons-backups/bchwallet-migrated-<stamp>/
// (bundle folder is now aegis/; leaving the old dir active would load
// the pre-rename copy as a second addon under the same id and clash).
// - addons-data/bchwallet.json → addons-data/aegis.json (COPY, so any
// downgrade to a 0.3.x build can still read its own storage).
// - addons/siawallet/ → addons-backups/siawallet-migrated-<stamp>/
// (folded into Aegis via absorbs: ["siawallet"]; keeping it running
// would show duplicate Sia UI). Its data file stays under
// addons-data/ untouched — Aegis derives its own SC walletdUrl
// per-sub-wallet, so users re-paste their URL in Aegis Settings.
function migrateAegisRename() {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backups = addonsBackupDir();
try { fs.mkdirSync(backups, { recursive: true }); } catch {}
const addonsRoot = addonsUserDir();
const dataRoot = addonsDataDir();
const bchDir = path.join(addonsRoot, "bchwallet");
const aegisDir = path.join(addonsRoot, "aegis");
const bchJson = path.join(dataRoot, "bchwallet.json");
const aegisJson = path.join(dataRoot, "aegis.json");
// Copy legacy storage into the new file exactly once, only when the new
// file doesn't exist yet (a downgrade to 0.3.x would otherwise clobber
// fresh state written by 0.4+).
if (fs.existsSync(bchJson) && !fs.existsSync(aegisJson)) {
try { fs.copyFileSync(bchJson, aegisJson); console.log("[addons] migrated bchwallet.json -> aegis.json"); }
catch (err) { console.warn("[addons] migrate storage failed:", err?.message); }
}
// Retire the bchwallet folder EVERY LAUNCH it exists. The signed OTA
// update endpoint may still advertise `id=bchwallet` updates, and
// promoteStagedUpdates (which runs before us) could reinstall it. Left
// active it would load as a second Aegis under a stale id, doubling
// every wallet in the sidebar.
if (fs.existsSync(bchDir)) {
const backup = path.join(backups, `bchwallet-migrated-${stamp}`);
try { fs.renameSync(bchDir, backup); console.log(`[addons] retired addons/bchwallet -> addons-backups/${path.basename(backup)}${fs.existsSync(aegisDir) ? " (aegis already present)" : " (folder renamed to aegis/)"}`); }
catch (err) { console.warn("[addons] retire bchwallet failed:", err?.message); }
}
const siaDir = path.join(addonsRoot, "siawallet");
if (fs.existsSync(siaDir)) {
const backup = path.join(backups, `siawallet-migrated-${stamp}`);
try { fs.renameSync(siaDir, backup); console.log(`[addons] retired addons/siawallet -> addons-backups/${path.basename(backup)} (absorbed by aegis)`); }
catch (err) { console.warn("[addons] retire siawallet failed:", err?.message); }
}
}
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 from = path.join(src, e.name);
const target = path.join(dst, e.name);
const bundleVer = readAddonVersion(from);
if (!bundleVer) continue; // broken bundle — skip rather than corrupt user state
if (fs.existsSync(target)) {
const userVer = readAddonVersion(target);
if (userVer === bundleVer) continue;
// Backups go in a sibling folder so AddonHost's directory scan doesn't
// pick them up as duplicate addons with the same manifest id.
const backupsRoot = addonsBackupDir();
try { fs.mkdirSync(backupsRoot, { recursive: true }); } catch {}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = path.join(backupsRoot, `${e.name}-${userVer ?? "unknown"}-${stamp}`);
try { fs.renameSync(target, backup); }
catch (err) { console.warn(`[addons] backup ${e.name} failed, skipping reseed:`, err?.message); continue; }
console.log(`[addons] reseed ${e.name}: ${userVer ?? "unknown"} -> ${bundleVer} (previous copy at addons-backups/${path.basename(backup)})`);
}
try { fs.cpSync(from, target, { recursive: true }); }
catch (err) { console.warn(`[addons] seed ${e.name} failed:`, err?.message); }
}
}
function initAddons() {
// Promote any signed add-on update staged by a previous run BEFORE we
// reseed from the bundle — a fresh install of a newer version from
// updateURL should win over the older bundled copy shipped inside the
// Theseus installer.
addonUpdater.promoteStagedUpdates({
addonsDir: addonsUserDir(),
backupsDir: addonsBackupDir(),
stagedDir: addonsStagedDir(),
logger: (...a) => console.log("[addons]", ...a),
});
// Retire the pre-rename bundle layouts before reseeding so the fresh
// aegis/ + siawallet-less state is what AddonHost enumerates.
migrateAegisRename();
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));
},
vaultLifecycle: {
status: async () => ({ setup: fs.existsSync(vaultFile()), unlocked: !!vaultState }),
unlock: async (masterPassword, addonId) => {
if (!fs.existsSync(vaultFile())) throw new Error("no vault");
const v = await loadVaultLib();
vaultState = await v.unlockVault(vaultFile(), masterPassword);
importsState = null;
if (fs.existsSync(importsFile())) {
try { importsState = await v.unlockImports(importsFile(), masterPassword); }
catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); }
}
importsUnlockPw = masterPassword;
emitPwAvailability();
console.log(`[addons] [${addonId}] vault.unlock`);
return { ok: true };
},
setup: async (masterPassword, seedSource, addonId) => {
if (!masterPassword || String(masterPassword).length < 4) throw new Error("master password too short");
if (fs.existsSync(vaultFile())) throw new Error("vault already exists");
const v = await loadVaultLib();
let purposeRootHex, messengerRootHex;
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
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 {
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
purposeRootHex = v.bytesToHex(root);
}
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex,
messengerRootHex ? { messengerRootHex } : {});
importsUnlockPw = masterPassword;
emitPwAvailability();
console.log(`[addons] [${addonId}] vault.setup`);
return { ok: true };
},
lock: async (addonId) => {
vaultState = null; importsState = null; importsUnlockPw = null;
emitPwAvailability();
console.log(`[addons] [${addonId}] vault.lock`);
return { ok: true };
},
},
vaultImports: {
list: async () => {
if (!vaultState) throw new Error("password vault is locked");
const v = await loadVaultLib();
return importsState ? v.listImportsMetadata(importsState) : [];
},
add: async (spec, addonId) => {
if (!vaultState) throw new Error("password vault is locked");
if (!importsUnlockPw) throw new Error("imports session credential missing (relock and unlock)");
if (!spec || typeof spec !== "object") throw new Error("spec required");
const kind = String(spec.kind || "");
if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`);
const cashaddr = String(spec.cashaddr || "").trim();
if (!cashaddr) throw new Error("cashaddr required (caller derives)");
const label = String(spec.label || "").trim().slice(0, 120);
if (!label) throw new Error("label required");
const category = String(spec.category || "").trim().slice(0, 40) || "operational";
const source = String(spec.source || "").trim().slice(0, 500);
const v = await loadVaultLib();
if (!importsState) importsState = await v.createImports(importsFile(), importsUnlockPw);
const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet";
let id = rawId, n = 1;
while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; }
const rec = { kind, cashaddr, label, category, source, createdAt: Date.now() };
if (kind === "seed") {
if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed");
rec.seed = String(spec.seed); rec.path = String(spec.path);
} else {
if (!spec.wif) throw new Error("wif required for kind=wif");
rec.wif = String(spec.wif);
}
importsState.accounts[id] = rec;
await v.saveImports(importsFile(), importsState);
console.log(`[addons] [${addonId}] vault.imports.add ${kind}${id}`);
return { id, entries: v.listImportsMetadata(importsState) };
},
remove: async (id, addonId) => {
if (!vaultState || !importsState) throw new Error("password vault is locked");
if (!importsState.accounts[id]) throw new Error("no such import");
delete importsState.accounts[id];
const v = await loadVaultLib();
await v.saveImports(importsFile(), importsState);
console.log(`[addons] [${addonId}] vault.imports.remove ${id}`);
return { entries: v.listImportsMetadata(importsState) };
},
signer: async (id, addonId) => {
if (!vaultState || !importsState) throw new Error("password vault is locked");
const v = await loadVaultLib();
console.log(`[addons] [${addonId}] vault.imports.signer ${id}`);
return v.getImportSigner(importsState, id);
},
},
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); },
// openSettings: routes through the existing "open-settings" IPC handler
// so the same section-hint validation applies. Add-ons hit this when they
// want to point users at Passwords, Extensions, etc.
openSettings: (section) => {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
const ex = tabs.find((t) => t.settings);
if (ex) {
setActive(ex.id);
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
return;
}
createTab(null, { settings: true, settingsSection: slug });
},
// open-tab (addon-file variant): open one of the add-on's OWN files in a
// full tab. The path is joined against the resolved add-on folder and
// rejected if the result escapes it — belt-and-braces with the sanity
// check the api wrapper already does. The tab uses addon-tab-preload so
// window.silentmode.invoke() reaches the same handler surface as a
// sidebar panel; the sender-URL gate on addon-msg then confines the
// page to its own add-on's storage/handlers.
openAddonTab: (addonId, relPath, queryString) => {
if (!win) return;
const folder = addonHost && addonHost.folderOf(addonId);
if (!folder) throw new Error(`openAddonTab: no such active add-on "${addonId}"`);
const base = path.resolve(folder);
const abs = path.resolve(base, relPath);
const norm = abs.replace(/\\/g, "/").toLowerCase();
const baseNorm = base.replace(/\\/g, "/").toLowerCase();
if (norm !== baseNorm && !norm.startsWith(baseNorm + "/")) {
throw new Error(`openAddonTab: path "${relPath}" escapes add-on folder`);
}
if (!fs.existsSync(abs)) throw new Error(`openAddonTab: file not found: ${abs}`);
console.log(`[addons] [${addonId}] openAddonTab -> ${path.basename(abs)}${queryString ? "?"+queryString.slice(0,80)+(queryString.length>80?"…":"") : ""}`);
createTab(null, { addonFile: { absPath: abs, query: queryString || "", addonId } });
},
// capture-tab: three modes.
// visible — one WebContents.capturePage() of the current viewport.
// full — temporarily grow the tab's WebContentsView to the page's
// scrollHeight, capture, restore. Cheap and works for most
// pages; fixed-position headers/footers will repeat because
// they anchor to the viewport, which is a known trade-off
// (documented in the panel). Alternative would be a scroll-
// and-stitch pass; kept for a later revision.
// region — run the caller-supplied overlay source in the tab, wait
// for a rect (or null = cancel), then capturePage(rect).
captureTab: async (opts, addonId) => {
// Prefer the currently-active tab, BUT if that's an add-on-owned page
// (e.g. the screenshot editor is already up when the user re-picks a
// mode from the dropdown), fall back to the most-recently-active real
// tab. Otherwise a second capture snapshots the editor's still-blank
// canvas and every follow-up produces a white PNG.
let t = activeTab();
if (t && (t.addonId || t.settings)) {
const fallback = tabById(lastCapturableTabId);
if (fallback && !fallback.addonId && !fallback.settings) t = fallback;
else {
// Last resort: any non-addon non-settings tab in the list.
t = tabs.find((x) => !x.addonId && !x.settings) || t;
}
}
if (!t) throw new Error("no active tab");
if (t.addonId || t.settings) {
throw new Error("no capturable tab — open a page you'd like to shoot first");
}
const wc = t.view.webContents;
const host = t?.prov?.host || (() => { try { return new URL(wc.getURL()).host; } catch { return ""; } })();
const mode = String(opts?.mode || "visible");
const format = opts?.format === "jpeg" ? "jpeg" : "png";
const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90));
// CDP-based capture. Page.captureScreenshot forces a fresh composite
// regardless of occlusion state, so it doesn't blank out when the tab
// view is marked hidden (which happened right after a native menu
// popup closed — the compositor stays throttled for a few frames and
// WebContents.capturePage() would snapshot a stale/transparent frame
// at the correct dimensions, which no size-based retry could catch).
// Reads PNG width/height from the IHDR chunk so we don't need a
// NativeImage roundtrip.
function pngDims(b64) {
const buf = Buffer.from(b64, "base64");
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
async function cdpCapture({ full = false, rect = null } = {}) {
const wasAttached = wc.debugger.isAttached();
if (!wasAttached) {
try { wc.debugger.attach("1.3"); }
catch (e) {
if (!/already attached/i.test(String(e?.message))) throw e;
}
}
try {
const p = { format: format === "jpeg" ? "jpeg" : "png" };
if (format === "jpeg") p.quality = quality;
if (rect) p.clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
if (full) p.captureBeyondViewport = true;
const { data } = await wc.debugger.sendCommand("Page.captureScreenshot", p);
const dataUrl = `data:image/${p.format};base64,${data}`;
const dims = p.format === "png" ? pngDims(data) : (rect ? { width: rect.width, height: rect.height } : null);
return { dataUrl, ...(dims || {}) };
} finally {
// Only detach if WE attached; leave a pre-existing DevTools/other
// consumer's attachment alone.
if (!wasAttached) { try { wc.debugger.detach(); } catch {} }
}
}
if (mode === "visible") {
const r = await cdpCapture();
console.log(`[addons] [${addonId}] captureTab visible ${r.width}x${r.height}`);
return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
}
if (mode === "full") {
// Page.captureScreenshot with captureBeyondViewport does the whole
// scrollable page. Before we shoot, force the layout viewport to the
// window's full content width via Emulation.setDeviceMetricsOverride
// so an open sidebar (or any other on-screen chrome that narrowed
// the tab view) doesn't clip the capture — the shot always comes
// back at the page's natural full width, not the visible width.
const wasAttached = wc.debugger.isAttached();
if (!wasAttached) {
try { wc.debugger.attach("1.3"); }
catch (e) { if (!/already attached/i.test(String(e?.message))) throw e; }
}
let overrode = false;
try {
const winW = (win?.getContentBounds()?.width) || 0;
const tabB = t.view.getBounds();
const need = winW > tabB.width + 24 ? winW : 0;
if (need > 0) {
// dsf 0 = "let Chromium keep the real device scale factor".
// mobile false, deviceScaleFactor 0 keeps typography sane;
// height 0 tells CDP "use the current viewport height".
await wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", {
width: need, height: 0, deviceScaleFactor: 0, mobile: false,
});
overrode = true;
// A frame or two so the reflow settles before we snapshot.
await new Promise((r) => setTimeout(r, 250));
}
const r = await cdpCapture({ full: true });
console.log(`[addons] [${addonId}] captureTab full ${r.width}x${r.height}${overrode ? ` (viewport widened to ${need}px)` : ""}`);
return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
} finally {
if (overrode) {
try { await wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride"); } catch {}
}
if (!wasAttached) { try { wc.debugger.detach(); } catch {} }
}
}
if (mode === "region") {
const src = String(opts?.overlaySource || "");
if (!src) throw new Error("region capture needs opts.overlaySource");
// Overlay script runs in the target tab's world. It's expected to
// resolve (as the executeJavaScript result) with {x,y,w,h} in CSS
// pixels, or null when the user hits Escape / right-clicks.
const rectRaw = await wc.executeJavaScript(src, true);
if (!rectRaw || typeof rectRaw !== "object") {
console.log(`[addons] [${addonId}] captureTab region cancelled`);
return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true };
}
const rect = {
x: Math.max(0, Math.floor(rectRaw.x)),
y: Math.max(0, Math.floor(rectRaw.y)),
width: Math.max(1, Math.floor(rectRaw.w)),
height: Math.max(1, Math.floor(rectRaw.h)),
};
const r = await cdpCapture({ rect });
const s = { width: r.width || rect.width, height: r.height || rect.height };
console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${rect.x},${rect.y}`);
return { dataUrl: r.dataUrl, width: s.width, height: s.height, host, format };
}
throw new Error(`unknown capture mode: ${mode}`);
},
// saveCapture writes the bytes to Downloads and synthesizes a completed
// download record so the chip shows the file with a Show-in-folder link,
// just like an HTTP save. session.downloadURL(dataUrl) would go through
// will-download, but data URLs come across with a synthetic filename that
// Electron won't let us override in-flight without gymnastics — writing
// directly is deterministic and produces the same user-facing artifact.
saveCapture: async (opts, addonId) => {
const dataUrl = String(opts?.dataUrl || "");
const m = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl);
if (!m) throw new Error("saveCapture: dataUrl must be base64-encoded");
const mime = m[1];
const bytes = Buffer.from(m[2], "base64");
const raw = String(opts?.filename || "screenshot.png");
// Strip path separators — add-on-provided filename must not escape the
// downloads folder.
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png";
const dlDir = app.getPath("downloads");
let target = path.join(dlDir, safe);
// Uniquify: append " (n)" before the extension if the name is taken.
if (fs.existsSync(target)) {
const ext = path.extname(safe);
const stem = safe.slice(0, safe.length - ext.length);
for (let i = 2; i < 10000; i++) {
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
if (!fs.existsSync(cand)) { target = cand; break; }
}
}
try { fs.writeFileSync(target, bytes); }
catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); }
const id = nextDlId++;
const rec = {
id,
filename: path.basename(target),
url: `internal://addons/${addonId}/${path.basename(target)}`,
mime,
total: bytes.length,
received: bytes.length,
state: "completed",
savePath: target,
startedAt: Date.now(),
};
downloads.unshift(rec);
emitDownloads();
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
return { savePath: target };
},
});
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);
// The most-recently-active non-addon tab. captureTab falls back to this when
// the currently-active tab is an add-on-owned page (e.g. the screenshot
// editor itself) — otherwise a re-triggered capture snapshots the editor's
// still-blank canvas instead of the page the user actually wants to shoot.
let lastCapturableTabId = null;
// 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) {
loadOverlays();
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) {
loadOverlays();
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) {
loadOverlays();
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();
// The pill may grow to (almost) the full width of the tab area — long
// URLs stay readable — and ellipsises past that. It never runs under
// the sidebar.
const tabW = Math.max(0, width - (sidebarVisible ? sidebarW : 0));
const w = Math.min(Math.max(120, linkStatusW), Math.max(200, tabW - 16));
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, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } 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, toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [] }); } catch {}
}
}
function showPwFill(show, matches) {
if (!pwFillPop) return;
if (show) {
loadOverlays();
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);
loadOverlays();
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();
// Downloads initiated from an addon-file tab (the Screenshot editor's
// "Save" hits this via `<a download>` on a blob: URL) go straight to
// Downloads with a uniquified filename — Electron's default is to pop
// a Save As dialog, which the user has no way to answer from inside
// an add-on tab. This matches the ergonomics of the older, sidebar-
// driven saveCapture path.
try {
const addonTab = wc && tabs.find((t) => t.view && t.view.webContents === wc && t.addonId);
if (addonTab) {
const raw = item.getFilename() || "download.bin";
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "download.bin";
const dlDir = app.getPath("downloads");
let target = path.join(dlDir, safe);
if (fs.existsSync(target)) {
const ext = path.extname(safe);
const stem = safe.slice(0, safe.length - ext.length);
for (let i = 2; i < 10000; i++) {
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
if (!fs.existsSync(cand)) { target = cand; break; }
}
}
try { item.setSavePath(target); } catch {}
}
} catch {}
// 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") {
updateDownloadState = "failed";
console.warn(`[update] silent fetch ${state}`);
emitUpdateAvailable();
return;
}
// NEVER mark "ready" without verifying the file hashes to what the
// manifest promised. Electron's DownloadItem has been observed to
// fire done/completed on truncated payloads (bad Content-Length,
// CDN cache truncation, mid-stream TLS reset the runtime swallowed),
// and 0.3.31's in-app updater then spawned a half-file as setup —
// NSIS integrity check failed silently and the browser was gone.
const savedPath = item.getSavePath() || dst;
const expected = String(updateAvailable && updateAvailable.setupHash || "").toLowerCase();
if (!expected) {
updateDownloadState = "failed";
console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`);
try { fs.unlinkSync(savedPath); } catch {}
emitUpdateAvailable();
return;
}
const crypto = require("node:crypto");
const hash = crypto.createHash("sha256");
const rs = fs.createReadStream(savedPath);
rs.on("data", (c) => hash.update(c));
rs.once("error", (e) => {
updateDownloadState = "failed";
console.warn(`[update] hash read failed: ${e.message}`);
try { fs.unlinkSync(savedPath); } catch {}
emitUpdateAvailable();
});
rs.once("end", () => {
const got = hash.digest("hex").toLowerCase();
if (got !== expected) {
updateDownloadState = "failed";
console.warn(`[update] SHA-256 mismatch: got ${got}, want ${expected} — refusing to arm install`);
try { fs.unlinkSync(savedPath); } catch {}
emitUpdateAvailable();
return;
}
updateDownloadPath = savedPath;
updateDownloadState = "ready";
// Drop the mark-of-the-web Chromium stamps on downloads. Our
// hash check above is the trust decision; the zone marker only
// makes Windows raise a security prompt if the file is ever
// started through the shell.
try { fs.unlinkSync(savedPath + ":Zone.Identifier"); } catch {}
console.log(`[update] silent fetch complete + verified: ${savedPath}`);
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) {
const switching = id !== activeId;
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
// A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
// the incoming tab hidden behind a maximized sidebar. Auto-restore the
// sidebar to its pre-max width so the tab is actually visible; the
// user can re-maximize when they're done.
if (switching && sidebarMaximized) setSidebarMaximized(false);
// Show the NEW active tab first, THEN hide the others. Reversing this
// order eliminates the "no tab is visible" frame on switch that made the
// tab strip flash — the compositor always has at least one tab view up.
const target = tabs.find((x) => x.id === id);
if (target) target.view.setVisible(true);
for (const t of tabs) if (t.id !== id) t.view.setVisible(false);
const t = activeTab();
// Track the last active tab that isn't an add-on-owned page so captureTab
// has a sensible fallback when the user re-triggers the dropdown from
// inside (say) the screenshot editor.
if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id;
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,
zoom: zoomPercent(t),
});
}
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
// ---- page zoom ----
// Chrome's preset ladder. Zoom is applied with setZoomFactor, which
// Chromium keys per host for the session — so every tab on the same site
// shares the level, and navigating back to a site restores it, exactly
// like Chrome. Settings and add-on tabs never zoom.
const ZOOM_STEPS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500];
function zoomPercent(t) {
if (!t || t.settings || t.addonId) return 100;
try { return Math.round(t.view.webContents.getZoomFactor() * 100); } catch { return 100; }
}
function zoomStep(t, dir) {
if (!t || t.settings || t.addonId) return;
const cur = zoomPercent(t);
const next = dir > 0
? (ZOOM_STEPS.find((z) => z > cur) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1])
: ([...ZOOM_STEPS].reverse().find((z) => z < cur) ?? ZOOM_STEPS[0]);
zoomSet(t, next);
}
function zoomSet(t, percent) {
if (!t || t.settings || t.addonId) return;
try { t.view.webContents.setZoomFactor(Math.max(25, Math.min(500, percent)) / 100); } catch {}
emitTabs();
}
ipcMain.handle("zoom-step", (_e, dir) => zoomStep(activeTab(), Number(dir) > 0 ? 1 : -1));
ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100));
// 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();
}
// Scrollbar theme injected into every webContents we own — tabs, chrome,
// sidebar, all the floating popovers, and every add-on panel host. Track
// picks up a subtle neutral grey (works on both dark and light surfaces
// without hardcoding either); thumb is the BCH primary #0AC18E so every
// scroll surface reads as Silent Mode's. `scrollbar-color` is the modern
// standard (Chromium ≥ 121); the ::-webkit- fallback gives us fine control
// over width, radius and hover state on older engines. `!important` on the
// track / thumb wins over per-page overrides so the branding stays visible
// even on sites that theme their own scrollbars — but we deliberately don't
// force `scrollbar-width` so a page that has hidden its scrollbars entirely
// keeps that behaviour.
const SCROLLBAR_CSS = `
html { scrollbar-color: #0AC18E rgba(120,130,150,0.18); }
::-webkit-scrollbar { width: 12px; height: 12px; background: rgba(120,130,150,0.18) !important; }
::-webkit-scrollbar-track { background: rgba(120,130,150,0.18) !important; }
::-webkit-scrollbar-thumb { background: #0AC18E !important; border-radius: 6px;
border: 2px solid transparent; background-clip: padding-box !important; }
::-webkit-scrollbar-thumb:hover { background: #14e0a5 !important; background-clip: padding-box !important; }
::-webkit-scrollbar-corner { background: transparent !important; }
`;
function styleScrollbars(wc) {
if (!wc) return;
const inject = () => { try { wc.insertCSS(SCROLLBAR_CSS); } catch {} };
wc.on("dom-ready", inject);
// For a wc that's already past dom-ready when we attach (fixed views load
// fast during startup), fire once explicitly.
try { if (!wc.isLoading()) inject(); } catch {}
}
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.
// Preload picker: settings and add-on-file tabs each need their own IPC
// surface; everything else gets home-preload (superset of a plain web
// page's needs, plus the home-page card wiring).
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
: path.join(__dirname, "home-preload.js");
const view = new WebContentsView({ webPreferences: { preload: preloadPath } });
// Explicit solid background: transparent (Electron default) makes the tab
// view flash to whatever's underneath (which can be the just-hidden tab or
// black) between setVisible(true) and the first paint on tab switch. A
// solid ground kills that flash. Colour tracks the system theme so light-
// mode users don't get a dark stub while a page paints.
try { view.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
const wc = view.webContents;
styleScrollbars(wc);
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(); }
});
// Chromium fires found-in-page on every findInPage call + on subsequent
// match walks. Forward to chrome so the find bar shows "N of M".
wc.on("found-in-page", (_e, r) => {
if (tab.id !== activeId) return;
try { chrome?.webContents.send("find-result", { activeMatchOrdinal: r.activeMatchOrdinal, matches: r.matches, finalUpdate: r.finalUpdate }); } catch {}
});
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); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
// Linux, the zoom itself is up to us.
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
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: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
{ 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" };
const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined;
wc.loadFile("settings.html", settingsOpts);
if (id === activeId) pushNav(tab.prov);
emitTabs();
} else if (opts.addonFile) {
// Same treatment as settings: leave the address bar empty (refreshTabUrl
// skips file:// anyway), title arrives via page-title-updated. loadFile
// takes the query as `search` (Node's url.format shape) without the ?.
tab.prov = { host: "", kind: "home" };
tab.addonId = opts.addonFile.addonId;
wc.loadFile(opts.addonFile.absPath, opts.addonFile.query ? { search: opts.addonFile.query } : undefined);
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() {
chromeReadyDone = false;
overlaysLoaded = false;
// Window ground + chrome view ground both match chrome.html's --bg for the
// active theme. The chrome view used to sit on Chromium's default white
// until chrome.html painted, which is the "white strip over a dark
// window" users saw on a slow launch.
const uiBg = nativeTheme.shouldUseDarkColors ? "#0f1420" : "#e6e8ec";
win = new BrowserWindow({
width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg,
// 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") } });
try { chrome.setBackgroundColor(uiBg); } catch {}
win.contentView.addChildView(chrome);
styleScrollbars(chrome.webContents);
chrome.webContents.loadFile("chrome.html");
// The overlays below are created now (cheap) but their pages load via
// deferOverlayLoad → loadOverlays() after chrome has painted.
// 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);
styleScrollbars(popover.webContents);
deferOverlayLoad(popover, "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);
styleScrollbars(enginePicker.webContents);
deferOverlayLoad(enginePicker, "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);
styleScrollbars(downloadsPop.webContents);
deferOverlayLoad(downloadsPop, "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);
styleScrollbars(addressPicker.webContents);
deferOverlayLoad(addressPicker, "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);
styleScrollbars(pwFillPop.webContents);
deferOverlayLoad(pwFillPop, "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);
styleScrollbars(linkStatus.webContents);
deferOverlayLoad(linkStatus, "link-status.html");
linkStatus.setVisible(false);
// Add-on sidebar host. Doesn't loadFile until an add-on panel is opened —
// styleScrollbars hooks dom-ready, which fires per navigation, so every
// panel loaded into this view (Aegis, Screenshot, etc.) picks up the
// brand scrollbar the moment its DOM is ready.
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
win.contentView.addChildView(sidebar);
styleScrollbars(sidebar.webContents);
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);
styleScrollbars(approvalPop.webContents);
deferOverlayLoad(approvalPop, "approval.html");
approvalPop.setVisible(false);
// Everything that isn't needed to paint the toolbar waits for chrome.html.
// dom-ready, NOT did-finish-load: the load event also waits for every
// subresource, and the bookmarks bar pulls its favicons over bns:// —
// a BNS lookup plus a network fetch each. On a slow link that held the
// load event (and with it every restored tab) for seconds while the
// toolbar sat blank. By dom-ready the toolbar's scripts have run and its
// IPC listeners exist, which is all the rest of boot needs. The fallback
// timer covers a chrome.html that fails to load at all.
chrome.webContents.once("dom-ready", onChromeReady);
setTimeout(onChromeReady, 8000);
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"
: (!_isSub && entry.records.p) ? "mirror"
: 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;
});
// Native tab context-menu popup. Anchored at the (chrome-view-relative)
// point the renderer sends. Prevents the visible "chrome view expands to
// hold a DOM menu" gap between the tabs and the tab content — the OS-owned
// popup floats above every WebContentsView and doesn't affect layout at
// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) /
// Add to Bookmarks / Mute / Close.
ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender");
const t = tabById(tabId);
if (!t) return false;
const colorLabels = [
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
{ id: "purple", label: "Purple" },
];
const groupSubmenu = [
{ label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } },
{ type: "separator" },
...colorLabels.map((c) => ({
label: c.label + (t.group === c.id ? " ✓" : ""),
click: () => {
t.group = c.id;
// Cluster tabs of the same colour, matching the "tab-group" IPC.
const idx = tabs.indexOf(t);
let insertAfter = -1;
for (let i = 0; i < tabs.length; i++) {
if (i !== idx && tabs[i].group === c.id) insertAfter = i;
}
if (insertAfter !== -1) {
tabs.splice(idx, 1);
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
tabs.splice(dst, 0, t);
}
emitTabs();
},
})),
];
const bmDisabled = !t.url;
const template = [
{ label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } },
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } },
{ label: "Group", submenu: groupSubmenu },
{ label: "Add to Bookmarks", enabled: !bmDisabled, click: () => {
if (bmDisabled) return;
const url = t.url, title = t.title || url;
if (!bookmarks.some((b) => b.url === url)) {
bookmarks.unshift({ url, title, addedAt: Date.now() });
saveBookmarks(); emitBookmarks();
}
} },
{ label: t.muted ? "Unmute" : "Mute", click: () => {
try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {}
} },
{ type: "separator" },
{ label: "Close", click: () => closeTab(t.id) },
];
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y });
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;
// Drag-to-resize exits maximize mode — the user is asking for a specific
// width. We snap out of maximize first so the delta lands on the pre-max
// width rather than on the (huge) maximized value.
if (sidebarMaximized) {
sidebarMaximized = false;
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
try { sidebar?.webContents.send("sidebar-max-change", false); } catch {}
}
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; });
// Panel-driven sidebar maximize: fills the window with the sidebar (tab
// area shrinks to zero-width), remembering the pre-max width so restore
// returns cleanly. Doesn't persist across sessions — a fresh launch
// always starts at the saved settings.sidebarWidth. Layout runs so tab
// views and other floating popovers reposition against the new bounds.
function setSidebarMaximized(next) {
const wanted = !!next;
if (wanted === sidebarMaximized) return sidebarMaximized;
if (wanted) {
sidebarPreMaxW = sidebarW;
sidebarW = sidebarMaxWidth();
} else {
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
}
sidebarMaximized = wanted;
layout();
try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {}
return sidebarMaximized;
}
ipcMain.handle("sidebar-maximize", () => setSidebarMaximized(true));
ipcMain.handle("sidebar-restore", () => setSidebarMaximized(false));
ipcMain.handle("sidebar-toggle-max", () => setSidebarMaximized(!sidebarMaximized));
ipcMain.handle("sidebar-is-max", () => sidebarMaximized);
ipcMain.handle("sidebar-state", () => ({
visible: sidebarVisible,
active: sidebarActivePanelId,
panels: addonHost ? addonHost.getSidebarPanels() : [],
toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [],
}));
// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the
// user picked. Route to the add-on's registered "menu-select" handler. We
// trust chrome as the sender (same convention as sidebar-toggle et al) —
// it's the only WebContents we ever load chrome.html into.
ipcMain.handle("addon-menu-select", async (e, addonId, itemId) => {
if (!addonHost) throw new Error("addon host not ready");
if (chrome && e.sender !== chrome.webContents) throw new Error("addon-menu-select: untrusted sender");
const id = String(addonId || "");
const iid = String(itemId || "");
if (!id || !iid) throw new Error("addon-menu-select: addonId and itemId required");
// Confirm the menu item was actually declared by this add-on — a rogue
// renderer message can't invoke a handler with an item id the manifest
// never listed.
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
if (!menu) throw new Error(`addon-menu-select: no toolbar menu for "${id}"`);
if (!menu.items.find((it) => it.id === iid)) throw new Error(`addon-menu-select: item "${iid}" not declared by "${id}"`);
return addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" });
});
// Toolbar-menu popup — render as a NATIVE OS menu anchored at the button.
// A renderer-DOM popover in chrome.html gets clipped by the chrome view's
// own bounds (height = CHROME_H) and then hidden behind the tab view below
// it. Menu.popup uses an OS window, so it can extend anywhere.
ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => {
if (!addonHost) throw new Error("addon host not ready");
if (chrome && e.sender !== chrome.webContents) throw new Error("toolbar-menu-popup: untrusted sender");
const id = String(addonId || "");
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
if (!menu) throw new Error(`toolbar-menu-popup: no menu for "${id}"`);
// Capture the clicked item id here; dispatch runs from the popup's
// `callback` below, AFTER the menu is torn down. Firing synchronously
// in the click handler catches the parent window still non-foreground
// (the OS menu popup is on top), which leaves Chromium's occlusion
// tracker marking the tab view as hidden — WebContents.capturePage()
// then snapshots a blank frame at the correct dimensions (not a 0x0
// that our retry could catch). Deferring until after callback lets
// focus return to the parent so the compositor is live at capture time.
let picked = null;
const template = menu.items.map((it) => ({
label: (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id),
click: () => { picked = it.id; },
}));
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y, callback: () => {
try { chrome?.webContents.send("toolbar-menu-closed"); } catch {}
if (!picked) return; // user hit Escape or clicked outside
// Explicitly return foreground to the parent window; on some Windows
// configurations Electron's popup teardown alone leaves the app in a
// "not-quite-foreground" state until the next OS message pump tick.
try { win?.focus(); } catch {}
// Settle before the handler runs. Windows takes several frames to
// restore foreground and un-throttle the compositor; the previous
// 120 ms was too short on slower / higher-latency setups and led to
// blank frames. 250 ms is what the "full page" mode already uses.
// captureTab itself no longer relies on capturePage anyway (it goes
// through CDP Page.captureScreenshot, which forces a fresh composite),
// but the delay still helps addons that do their own DOM work in the
// click handler before capture.
const iid = picked;
setTimeout(() => {
addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" })
.catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err));
}, 250);
}});
return true;
});
// Close the tab a full-tab add-on page lives in. Sender identifies the
// webContents; we match it against our tab list and close that tab only.
// A page hosted elsewhere (or a spoofed sender not in the tab set) gets
// nothing.
ipcMain.handle("addon-tab-close", (e) => {
const senderId = e.sender.id;
const tab = tabs.find((t) => t.view?.webContents?.id === senderId);
if (!tab) return false;
closeTab(tab.id);
return true;
});
// Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => {
if (!addonHost) return { installed: [], sidebarPanels: [] };
const snap = addonHost.snapshot();
// Mark bundled add-ons so the extensions UI can render them differently
// (Aegis + friends look built-in rather than removable extensions). A
// sibling folder in bundled-addons/ with the same manifest id is proof
// the addon ships with Theseus; seedBundledAddons keeps them in sync.
let bundledIds = new Set();
try {
for (const e of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), e.name, "addon.json"), "utf8"));
if (m && m.id) bundledIds.add(String(m.id));
} catch {}
}
} catch {}
snap.installed = snap.installed.map((a) => ({ ...a, bundled: a.id ? bundledIds.has(a.id) : false }));
return snap;
});
// 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; }
});
// Manual "Check for updates" from Settings > Extensions. Runs the same
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
// staged dir so the UI can render "Update to <ver> — restart to apply".
ipcMain.handle("addons-check-updates", async () => {
const stagedDir = addonsStagedDir();
let report = [];
let skipped = null;
try {
const result = await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(),
stagedDir,
pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a),
});
report = result?.report || [];
skipped = result?.skipped || null;
} catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); }
return { report, skipped, staged: listStagedAddons(stagedDir) };
});
ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir()));
function listStagedAddons(stagedDir) {
const out = [];
let entries = [];
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } catch { return out; }
for (const de of entries) {
if (!de.isDirectory()) continue;
try {
const m = JSON.parse(fs.readFileSync(path.join(stagedDir, de.name, "addon.json"), "utf8"));
if (m?.id && m?.version) out.push({ id: m.id, name: m.name || m.id, version: m.version, folder: path.join(stagedDir, de.name) });
} catch {}
}
return out;
}
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;
// The overlay's page loads lazily after first paint; a dapp on a restored
// tab can ask for approval before that, so wait for the page rather than
// sending into an empty renderer (the request would silently hang).
overlayReady(approvalPop).then(() => {
if (approvalCurrent !== next) return;
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; });
// Find-in-page. Chrome renderer's find bar drives this: the bar sends
// `find-in-page` with the query + direction; main proxies to the active
// tab's webContents.findInPage. Chromium raises `found-in-page` on each
// keystroke with the running match count / active match ordinal; we
// forward that back to chrome so the bar can render "3 of 12".
ipcMain.handle("find-in-page", (_e, query, opts = {}) => {
const t = activeTab(); if (!t) return false;
if (typeof query !== "string" || !query) { try { t.view.webContents.stopFindInPage("clearSelection"); } catch {} return false; }
try {
t.view.webContents.findInPage(query, {
forward: opts.forward !== false,
findNext: !!opts.findNext, // false = new query; true = jump next/prev
matchCase: !!opts.matchCase,
});
return true;
} catch { return false; }
});
ipcMain.handle("find-stop", () => {
const t = activeTab(); if (!t) return false;
try { t.view.webContents.stopFindInPage("clearSelection"); return true; } catch { return false; }
});
// Just the app version — no network. Used by Settings > General so the
// user can see which Theseus they're on without clicking "Check for updates".
ipcMain.handle("app-version", () => app.getVersion());
// Clean relaunch — used by the Aegis card to apply a staged add-on update
// (promotion happens on next boot; this is just how the user gets there).
ipcMain.handle("app-restart", () => { try { app.relaunch(); } catch {} app.quit(); });
ipcMain.handle("recheck-update", async () => {
// Manual "Check for updates" also un-dismisses any chip the user closed
// in this session — they're actively asking to see the status, so honour
// that. Report current app version too so the UI can render "you're on
// vN.M.O" when no newer build is out.
updateDismissedThisSession = false;
await checkForUpdate();
return { updateAvailable, currentVersion: app.getVersion() };
});
// One-click "Install & restart". Requires the silent pre-fetch to have
// finished (updateDownloadState === "ready"). Launches the setup with
// /S (silent, skips the wizard) and --force-run (electron-builder's NSIS
// convention for "start the app when the install finishes"), then quits
// Theseus so the installer can overwrite it. The user sees the browser
// disappear for a couple of seconds and come back on the new version —
// no manual relaunch needed. Verified E2E on 0.3.33 → 0.3.34 in the
// 2026-09-08 session; the 0.3.37 → 0.3.39 update ran WITHOUT --force-run
// (the flag was dropped in the 0.3.31 rewrite once it was clear /S alone
// installs correctly) and the user reported the missing relaunch — this
// commit puts --force-run back on so the auto-restart is part of the
// standard flow again. --updated stays out: the earlier E2E showed it
// wasn't load-bearing correctness for our NSIS config.
//
// 2026-09-11: an update ran while the app was still shutting down, both NSIS
// processes died ~8 s in, and the install was left without app.asar. The
// installer is no longer spawned from here: install-update-now only records
// the setup path and quits; will-quit then starts a detached batch helper
// that waits for this PID to be gone, runs the installer, and re-runs it
// once if app.asar is missing afterwards. See lib/update-helper.cjs for
// the script, the console-less cmd.exe traps, and the reasoning.
let pendingInstallerPath = null;
ipcMain.handle("install-update-now", () => {
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
pendingInstallerPath = updateDownloadPath;
app.quit();
return true;
});
app.on("will-quit", () => {
if (!pendingInstallerPath) return;
const setupPath = pendingInstallerPath;
pendingInstallerPath = null;
try {
const { buildUpdateHelperCmd } = require("./lib/update-helper.cjs");
const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd");
fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir: path.dirname(process.execPath) }));
// A detached cmd.exe outlives this process (verified) and is in no job
// of ours; the batch file itself waits for our PID to disappear.
const helper = spawn("cmd.exe", [`/d /c "${cmdPath}"`],
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true });
helper.unref();
console.log(`[update] helper armed for ${setupPath}`);
} catch (e) { console.warn("[update] helper spawn failed:", e?.message); }
});
// 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(); });
// Open (or focus) the Settings tab. Optional section slug ("passwords",
// "addons", …) lands directly on that sidebar entry when the page loads.
// settings.html watches for a fragment on load and an IPC message when
// already loaded.
function openSettingsTab(section) {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
const ex = tabs.find((t) => t.settings);
if (ex) {
setActive(ex.id);
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
return;
}
createTab(null, { settings: true, settingsSection: slug });
}
ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section));
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", (_e, arg) => switchRegistry(arg));
async function switchRegistry(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"
: (!_isSub && r.p) ? "mirror"
: 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();
}
// Ariadne's Thread menu — the registry button at the end of the address
// bar. Replaces the BCDN/ICANN segmented chips: one icon that never
// overflows the bar, a native popup with the switch, the per-name / per-TLD
// memory, the collision policy, and a jump to the resolver settings.
ipcMain.handle("registry-menu-popup", (e, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("registry-menu-popup: untrusted sender");
const t = activeTab();
const prov = t?.prov || null;
const host = String(prov?.host || "");
const tld = String(prov?.tld || (host ? tldOf(host) : "") || "");
const onBcdn = prov?.kind === "ok";
const onIcann = prov?.kind === "web";
const isCand = !!tld && (onBcdn || onIcann) && !isBcnrNativeTld(tld);
const current = onBcdn ? "bcnr" : "icann";
const policyItem = (value, label) => ({
label, type: "radio", checked: settings.collisionPolicy === value,
click: () => { settings.collisionPolicy = value; saveSettings(); },
});
const template = [
{ label: host ? `Ariadne's Thread · ${host}` : "Ariadne's Thread", enabled: false },
{ type: "separator" },
{ label: "Serve from BCDN", type: "radio", checked: onBcdn, enabled: isCand || onBcdn,
click: () => { if (!onBcdn) switchRegistry({ choice: "bcnr", remember: "no" }).catch(() => {}); } },
{ label: "Serve from ICANN", type: "radio", checked: onIcann, enabled: isCand || onIcann,
click: () => { if (!onIcann) switchRegistry({ choice: "icann", remember: "no" }).catch(() => {}); } },
{ label: "Remember", enabled: !!host, submenu: [
{ label: host ? `Always open ${host} this way` : "Always open this site this way", enabled: isCand,
click: () => rememberCollision(host, tld, current, "name") },
{ label: tld ? `Always open .${tld} names this way` : "Always open this TLD this way", enabled: isCand,
click: () => rememberCollision(host, tld, current, "tld") },
{ type: "separator" },
{ label: "Forget remembered choices", click: () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); } },
] },
{ type: "separator" },
{ label: "When a name exists on both registries", submenu: [
policyItem("bcnr-first", "BCDN first"),
policyItem("icann-first", "ICANN first"),
policyItem("soft", "Ask each time"),
] },
{ type: "separator" },
{ label: "Ariadne's Thread settings…", click: () => openSettingsTab("plugins") },
];
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y });
});
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; });
// Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe
// as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge").
// Turning them off means non-Theseus browsers stop resolving BCDN names on
// this machine; Theseus itself uses its own in-process resolver so it's
// unaffected. Toggling requires admin (tasks run as SYSTEM) — start/stop go
// through an elevated powershell that UAC-prompts once per action.
//
// As of 0.3.23 Ariadne is NOT bundled inside Theseus. Install / Update stream
// AriadneResolver-Setup-<v>.exe directly from silentmode.st and verify its
// SHA-256 against the on-site releases manifest before spawning it, so
// Ariadne's release cadence is decoupled from ours.
const ARIADNE_TASKS = ["BNS Resolver Daemon", "BNS Sia Bridge"];
// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both
// native and WOW6432 in case Inno installed either way.
const ARIADNE_UNINSTALL_KEYS = [
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1',
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1',
];
// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on
// silentmode.st is a mirror that has lagged behind dl.silentmode.st (2026-09-09:
// it still listed Theseus 0.3.31 while dl had 0.3.44), so the Ariadne "Update"
// button was checking against a stale version list.
const ARIADNE_MANIFEST_URL = "https://dl.silentmode.st/releases-manifest.json";
const ARIADNE_DL_ORIGIN = "https://dl.silentmode.st";
const ARIADNE_MANIFEST_TTL_MS = 30 * 60 * 1000;
let ariadneManifestCache = null; // { at:number, entry:{version,filename,sha256,url}|null }
// GET the releases manifest, find the win-x64 ariadne-resolver entry, return
// {version, filename, sha256, url}. Cached 30 min in-process; opening the
// Settings panel every few seconds does not spam silentmode.st. On any
// failure (offline, 5xx, malformed JSON) returns null and the caller shows
// bundledVersion:null / canUpdate:false gracefully.
function ariadneManifestFetch() {
const now = Date.now();
if (ariadneManifestCache && now - ariadneManifestCache.at < ARIADNE_MANIFEST_TTL_MS) {
return Promise.resolve(ariadneManifestCache.entry);
}
return new Promise((resolve) => {
const https = require("node:https");
const req = https.request(ARIADNE_MANIFEST_URL, {
method: "GET", timeout: 8000,
headers: { "user-agent": "TheseusNavigator/ariadne-updater" },
}, (r) => {
if (r.statusCode !== 200) { r.resume(); ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
const chunks = [];
r.on("data", (c) => chunks.push(c));
r.on("end", () => {
try {
const j = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const rel = (j.releases || []).find((x) => x.id === "ariadne-resolver" && x.platform === "win-x64");
if (!rel) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
const filename = Object.keys(rel.files || {}).find((f) => /^AriadneResolver-Setup-.*\.exe$/i.test(f));
if (!filename) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
const entry = { version: rel.version, filename, sha256: String(rel.files[filename] || "").toLowerCase(), url: ARIADNE_DL_ORIGIN + "/" + filename };
ariadneManifestCache = { at: now, entry };
resolve(entry);
} catch { ariadneManifestCache = { at: now, entry: null }; resolve(null); }
});
});
req.on("timeout", () => req.destroy(new Error("manifest timeout")));
req.on("error", () => { ariadneManifestCache = { at: now, entry: null }; resolve(null); });
req.end();
});
}
// Stream the .exe to a per-session temp file, hashing as we go. Reject on
// hash mismatch (and delete the file) so a wrong-hash binary is never spawned.
// The SHA-256 is authoritative because the manifest itself is served over
// HTTPS -- silentmode.st TLS -> manifest.json -> hash -> verified .exe.
function ariadneDownloadInstaller(entry) {
return new Promise((resolve, reject) => {
const https = require("node:https");
const crypto = require("node:crypto");
const dst = path.join(app.getPath("temp"), `ariadne-${entry.version}-${Date.now()}.exe`);
const req = https.request(entry.url, {
method: "GET", timeout: 60000,
headers: { "user-agent": "TheseusNavigator/ariadne-updater" },
}, (r) => {
if (r.statusCode !== 200) { r.resume(); return reject(new Error(`download ${entry.url} -> HTTP ${r.statusCode}`)); }
const hash = crypto.createHash("sha256");
const out = fs.createWriteStream(dst);
r.on("data", (c) => hash.update(c));
r.pipe(out);
out.on("finish", () => {
const got = hash.digest("hex").toLowerCase();
if (got !== entry.sha256) {
try { fs.unlinkSync(dst); } catch {}
return reject(new Error(`SHA-256 mismatch: got ${got}, want ${entry.sha256}`));
}
resolve(dst);
});
out.on("error", (e) => { try { fs.unlinkSync(dst); } catch {}; reject(e); });
});
req.on("timeout", () => req.destroy(new Error("download timeout")));
req.on("error", reject);
req.end();
});
}
function ariadneQueryState() {
return new Promise((resolve) => {
const { spawn } = require("child_process");
// One shell round-trip for both bits of info:
// - task states for BNS Resolver Daemon + BNS Sia Bridge
// - installed version + quiet-uninstall string from Inno's registry key
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
"$names=@('BNS Resolver Daemon','BNS Sia Bridge');" +
"$names | ForEach-Object { $t = Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue;" +
" if ($t) { \"$_=$($t.State)\" } else { \"$_=MISSING\" } };" +
"$keys=@(" + ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",") + ");" +
"foreach ($k in $keys) { if (Test-Path $k) { $r=Get-ItemProperty $k; \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\"; break } }"
], { windowsHide: true });
let out = "";
ps.stdout.on("data", (d) => { out += d; });
ps.on("close", async () => {
const lines = out.trim().split(/\r?\n/).filter(Boolean);
const map = Object.fromEntries(lines.map((l) => { const i = l.lastIndexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
const primary = map["BNS Resolver Daemon"];
const installedVersion = map.__VER__ || null;
const quietUninstall = map.__UNINSTALL__ || null;
const latest = await ariadneManifestFetch();
const latestVersion = latest ? latest.version : null;
const canUpdate = !!(installedVersion && latestVersion && cmpVersions(latestVersion, installedVersion) > 0);
let state;
if (!primary || primary === "MISSING") state = installedVersion ? "stopped" : "not-installed";
else if (primary === "Running") state = "running";
else state = "stopped";
// The "bundledVersion" field name is kept for renderer compatibility --
// it now carries the latest version advertised by silentmode.st's
// releases manifest, not a version physically bundled with Theseus.
resolve({ state, installedVersion, bundledVersion: latestVersion, canUpdate, hasUninstaller: !!quietUninstall });
});
ps.on("error", () => resolve({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false }));
});
}
function cmpVersions(a, b) {
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
const n = Math.max(pa.length, pb.length);
for (let i = 0; i < n; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function ariadneSetState(on) {
return new Promise((resolve, reject) => {
const { spawn } = require("child_process");
// Off = stop AND disable, on = enable AND start. The daemon task has an
// at-startup trigger, so a plain Stop-ScheduledTask came back on the
// next reboot and "Turn off" silently didn't stick.
//
// The inner script goes across as -EncodedCommand (base64 UTF-16LE). The
// previous version embedded it in a double-quoted string on the OUTER
// powershell's command line, which interpolated `$t` to nothing before
// the elevated shell ever saw it — the elevated shell got
// `foreach ( in ...)`, failed to parse, and the outer shell still exited
// 0, so the toggle reported success while doing nothing.
const inner = on
? `$ok = $true
foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') {
try { Enable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null; Start-ScheduledTask -TaskName $t -ErrorAction Stop }
catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } }
}
if ($ok) { exit 0 } else { exit 2 }`
: `$ok = $true
foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') {
try { Stop-ScheduledTask -TaskName $t -ErrorAction Stop } catch {}
try { Disable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null }
catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } }
}
if ($ok) { exit 0 } else { exit 2 }`;
const encoded = Buffer.from(inner, "utf16le").toString("base64");
// -PassThru + exit $p.ExitCode: the elevated shell's exit code (0 ok,
// 2 = daemon task missing) reaches us; a declined UAC prompt makes
// Start-Process throw, which exits the outer shell non-zero.
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
`$p = Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}'; exit $p.ExitCode`], { windowsHide: true });
ps.on("close", (code) => {
if (code === 0) resolve(true);
else if (code === 2) reject(new Error("the 'BNS Resolver Daemon' scheduled task is missing — reinstall Ariadne's Thread"));
else reject(new Error("UAC declined or task failed (exit " + code + ")"));
});
ps.on("error", (e) => reject(e));
});
}
// Fetch manifest -> download+verify AriadneResolver-Setup-<v>.exe -> spawn
// Inno silently+elevated (/VERYSILENT /SUPPRESSMSGBOXES /NORESTART). The
// downloaded .exe is deleted whether the install succeeds or fails, so a
// wrong-hash abort never leaves a suspect binary behind.
async function ariadneInstall() {
const entry = await ariadneManifestFetch();
if (!entry) throw new Error("could not reach silentmode.st releases manifest");
const exePath = await ariadneDownloadInstaller(entry);
const { spawn } = require("child_process");
return new Promise((resolve, reject) => {
// -PassThru + exit $p.ExitCode so Inno's own exit code reaches us; a bare
// -Wait always returned 0 and a failed silent install looked like success.
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
`$p = Start-Process -FilePath '${exePath.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`
], { windowsHide: true });
const cleanup = () => { try { fs.unlinkSync(exePath); } catch {} };
ps.on("close", (code) => { cleanup(); code === 0 ? resolve(true) : reject(new Error("installer exited " + code)); });
ps.on("error", (e) => { cleanup(); reject(e); });
});
}
// Run Inno's own quiet uninstaller. Reads the QuietUninstallString from the
// registry (already ends in / VERYSILENT) and spawns it elevated.
function ariadneUninstall() {
const { spawn } = require("child_process");
return new Promise((resolve, reject) => {
// Read the uninstall string fresh — a stale cache could point at a moved
// file. Extract the path (may be quoted) + any trailing args.
const cmd = `$keys=@(${ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",")});foreach ($k in $keys){if(Test-Path $k){$r=Get-ItemProperty $k;Write-Host $r.QuietUninstallString;break}}`;
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true });
let out = "";
ps.stdout.on("data", (d) => { out += d; });
ps.on("close", () => {
const uninstallCmd = out.trim();
if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered"));
// uninstallCmd typically: "C:\Program Files (x86)\...\unins000.exe" /VERYSILENT
// Spawn it elevated. Inno's silent uninstall respects /VERYSILENT so no UI.
const runCmd = `$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${uninstallCmd.replace(/'/g, "''")} /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
const ps2 = spawn("powershell.exe", ["-NoProfile", "-Command", runCmd], { windowsHide: true });
ps2.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code)));
ps2.on("error", reject);
});
ps.on("error", reject);
});
}
// Update = run the bundled installer over the top. Inno Setup detects the
// same AppId and upgrades in place. Same UAC dance as install.
const ariadneUpdate = ariadneInstall;
ipcMain.handle("ariadne-state", () => ariadneQueryState().catch(() => ({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false })));
ipcMain.handle("ariadne-toggle", (_e, on) => ariadneSetState(!!on).catch((e) => ({ ok: false, error: e?.message || String(e) })));
ipcMain.handle("ariadne-install", () => ariadneInstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
ipcMain.handle("ariadne-update", () => ariadneUpdate().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
// 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 }
// Imports live in a SEPARATE encrypted file (design §3.2) so a bug in one
// vault can't destroy the other, and so an attacker holding the primary
// purposeRoot in RAM never yields the imports' seeds/WIFs. Same master
// password, different KDF salt = disjoint AES keys.
const importsFile = () => path.join(app.getPath("userData"), "wallet-imports.enc");
let importsState = null; // { key, accounts, _salt, _iters }
let importsUnlockPw = null; // held only if we may need to write the file this session
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);
// Same master password unlocks wallet-imports.enc when it exists. A
// mismatched password wouldn't get us here (the primary decrypt would
// have thrown), so this second decrypt is guaranteed to succeed with
// the same input — differ only in the salt.
importsState = null;
if (fs.existsSync(importsFile())) {
try { importsState = await v.unlockImports(importsFile(), masterPassword); }
catch (ie) { console.error("[imports] unlock failed:", ie?.message); }
}
importsUnlockPw = masterPassword;
emitPwAvailability();
return { ok: true, entries: v.listMetadata(vaultState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("password-lock", () => {
vaultState = null;
importsState = null;
importsUnlockPw = 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); }
});
// ---- wallet imports (DESIGN-wallet-multi-account-amendment.md §3.2/§3.3) ---
// Read-only listing — safe for any renderer, does not leak seeds/WIFs.
ipcMain.handle("wallet-imports-list", async () => {
if (!vaultState) return vaultErr("locked");
const v = await loadVaultLib();
const entries = importsState ? v.listImportsMetadata(importsState) : [];
return { ok: true, entries };
});
// Add an import. Add-ons (Aegis) call this via api.vault.imports.add(spec).
// The seed/WIF stay in main-process memory — never re-emitted to renderers.
// The caller is expected to have already derived cashaddr client-side; we
// store it verbatim, and Aegis's safety-net check re-derives on load and
// warns on mismatch (design §6).
ipcMain.handle("wallet-imports-add", async (_e, spec) => {
if (!vaultState) return vaultErr("locked");
if (!importsUnlockPw) return vaultErr("locked");
try {
if (!spec || typeof spec !== "object") throw new Error("spec required");
const kind = String(spec.kind || "");
if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`);
const cashaddr = String(spec.cashaddr || "").trim();
if (!cashaddr) throw new Error("cashaddr required (caller derives)");
const label = String(spec.label || "").trim().slice(0, 120);
if (!label) throw new Error("label required");
const category = String(spec.category || "").trim().slice(0, 40) || "operational";
const source = String(spec.source || "").trim().slice(0, 500);
const v = await loadVaultLib();
if (!importsState) {
importsState = await v.createImports(importsFile(), importsUnlockPw);
}
// Choose a URL-safe id: user-provided or derived from the label. Collision-
// safe: append a short suffix if it already exists.
const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet";
let id = rawId, n = 1;
while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; }
const rec = {
kind, cashaddr, label, category, source,
createdAt: Date.now(),
};
if (kind === "seed") {
if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed");
rec.seed = String(spec.seed);
rec.path = String(spec.path);
} else {
if (!spec.wif) throw new Error("wif required for kind=wif");
rec.wif = String(spec.wif);
}
importsState.accounts[id] = rec;
await v.saveImports(importsFile(), importsState);
return { ok: true, id, entries: v.listImportsMetadata(importsState) };
} catch (e) { return vaultErr(e?.message || e); }
});
ipcMain.handle("wallet-imports-remove", async (_e, id) => {
if (!vaultState || !importsState) return vaultErr("locked");
try {
if (!importsState.accounts[id]) return vaultErr("no such import");
delete importsState.accounts[id];
const v = await loadVaultLib();
await v.saveImports(importsFile(), importsState);
return { ok: true, entries: v.listImportsMetadata(importsState) };
} catch (e) { return vaultErr(e?.message || e); }
});
// Signer material for one import — only for add-ons that already have
// vault-derive-equivalent trust. NEVER called from a page renderer directly;
// gated by addon-msg the same way api.vault.derive is.
ipcMain.handle("wallet-imports-signer", async (_e, id) => {
if (!vaultState || !importsState) return vaultErr("locked");
try {
const v = await loadVaultLib();
const signer = v.getImportSigner(importsState, id);
return { ok: true, signer };
} 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;
});
// Reorder: pull `fromUrl` out of the list and reinsert it before or after
// `targetUrl`. Renderer picks the side by which half of the target chip the
// pointer is on, same convention the tab strip uses. A missing entry or a
// self-drop is a no-op, so noisy drag events don't corrupt the list.
ipcMain.handle("bookmark-move", (_e, fromUrl, targetUrl, place) => {
if (typeof fromUrl !== "string" || typeof targetUrl !== "string" || fromUrl === targetUrl) return bookmarks;
const from = bookmarks.findIndex((b) => b.url === fromUrl);
if (from < 0) return bookmarks;
const [moved] = bookmarks.splice(from, 1);
let to = bookmarks.findIndex((b) => b.url === targetUrl);
if (to < 0) { bookmarks.splice(from, 0, moved); return bookmarks; }
if (place === "after") to += 1;
bookmarks.splice(to, 0, moved);
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(); }
// Push updates chrome-side reactively so toolbar-size changes (urlBarSize,
// searchBoxSize) take effect without a relaunch. Whole settings object
// fits in one message and chrome only cares about a handful of fields.
try { chrome?.webContents.send("settings-update", settings); } catch {}
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); }
});
// ---- "Open link in new window" ----
// A standalone page window: same session (cookies, the bns:// protocol, the
// session-wide bcnr preload), Theseus's fingerprint + WebRTC policy, no
// toolbar. Loads BCNR-first like a tab does: a dotted host with a BCNR
// record goes over bns://, anything else over clearnet. Collision names
// follow the configured policy without the "Open with…" interstitial.
// Add-on page bridges (the wallet inject) are tab-scoped and don't run here.
const linkWindows = new Set();
async function targetUrlFor(input) {
let u;
try { u = new URL(String(input).includes("://") ? String(input) : "https://" + String(input)); } catch { return null; }
if (u.protocol !== "http:" && u.protocol !== "https:") return u.href;
const host = u.hostname.toLowerCase();
if (!isBnsHost(host)) return u.href;
let entry = null;
try { entry = await resolveHost(host); } catch {}
if (!entry) return u.href;
const policy = settings.collisionPolicy || "bcnr-first";
if (!isBcnrNativeTld(tldOf(host)) && policy === "icann-first") return u.href;
return `bns://${host}${u.pathname}${u.search}${u.hash}`;
}
async function openLinkWindow(input) {
const target = await targetUrlFor(input);
if (!target) return null;
const w = new BrowserWindow({
width: 1100, height: 760, title: "Theseus Navigator",
backgroundColor: nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff",
icon: app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico"),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
w.setMenuBarVisibility(false);
const wc = w.webContents;
styleScrollbars(wc);
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
wc.on("page-title-updated", (_e, title) => { try { w.setTitle(title ? `${title} — Theseus` : "Theseus Navigator"); } catch {} });
// Cross-host navigations inside the window stay BCNR-first too. Same-host
// ones go through Chromium untouched (form POSTs must survive).
wc.on("will-navigate", (e, u) => {
try {
const p = new URL(u);
if (p.protocol !== "http:" && p.protocol !== "https:") return;
if (!isBnsHost(p.hostname)) return;
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
if (cur === p.hostname) return;
e.preventDefault();
targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
} catch {}
});
// Popups from a page here go to the main window's tabs when it exists —
// one place for tabs — otherwise to another plain window.
wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") {
if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} }
else openLinkWindow(url);
}
return { action: "deny" };
});
wc.on("context-menu", (_e, p) => {
const items = [];
const haveMain = !!win && !win.isDestroyed();
if (p.linkURL) {
items.push(
{ label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } },
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
items.push(
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
{ label: "Reload", click: () => wc.reload() },
);
Menu.buildFromTemplate(items).popup({ window: w });
});
linkWindows.add(w);
w.on("closed", () => linkWindows.delete(w));
wc.loadURL(target).catch(() => {});
return w;
}
function openHermesWindow() {
if (hermesWin && !hermesWin.isDestroyed()) {
hermesWin.focus(); return;
}
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();
}
// Zoom: Ctrl + / Ctrl = / numpad + zoom in, Ctrl - / numpad - zoom out,
// Ctrl 0 reset. Always targets the active tab, whichever view has focus.
if (input.control && !input.alt) {
const k = input.key, code = input.code;
if (k === "+" || k === "=" || code === "NumpadAdd") { zoomStep(activeTab(), 1); return e.preventDefault(); }
if (k === "-" || k === "_" || code === "NumpadSubtract") { zoomStep(activeTab(), -1); return e.preventDefault(); }
if ((k === "0" || code === "Numpad0") && !input.shift) { zoomSet(activeTab(), 100); return e.preventDefault(); }
}
// Find-in-page: Ctrl+F opens the find bar in chrome. Chrome renderer
// owns the UI (input, next/prev, count, close); it drives the active
// tab's webContents.findInPage via IPC (find-in-page / find-stop).
const isF = input.key === "F" || input.key === "f";
if (input.control && !input.shift && !input.alt && isF) {
try { chrome?.webContents.send("find-open"); } catch {}
return e.preventDefault();
}
// DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active
// TAB. Position follows the devToolsDock setting: "bottom" docks under
// the tab (Chrome's own default, matches most web-dev muscle memory);
// "sidebar" docks on the right; "two-sidebars" also docks on the right
// but leaves the add-on sidebar in place — Electron's mode:right shares
// the right edge with whatever we've already positioned there. Users
// who prefer a detached window can still drag out from inside the
// DevTools frontend itself.
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 {
const dock = settings.devToolsDock === "sidebar" ? "right"
: settings.devToolsDock === "two-sidebars" ? "right"
: "bottom";
twc.openDevTools({ mode: dock });
}
} 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();
applyClientHintsSpoof();
// 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();
// Kick off signed add-on update polling 30 s after boot so it never
// slows launch. Any staged update lands in <userData>/addons-updates-
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
setTimeout(() => {
addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(),
stagedDir: addonsStagedDir(),
pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a),
}).catch(() => {});
}, 30_000);
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. Source 1 starts now
// (chrome.html's bookmark favicons are bns:// fetches, so the index has
// to be warm by the time the toolbar asks for them); 24 are kicked off
// from onChromeReady() once the toolbar has painted.
// 1) sync: load the on-disk snapshot (user cache > bundled).
// sharedIndex is set BEFORE the first restored tab navigates.
// 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.
bnsWarm = warmFromSnapshot().catch(() => null);
// 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. First check runs from
// onChromeReady(); recheck every 6h so a browser left running for days
// catches updates without a relaunch.
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).
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, openLinkWindow };