// 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, net } = require("electron"); const path = require("path"); const url = require("url"); 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"); const webapps = require("./webapps"); // A browser has no business dying because its stdout went away. Launched from // a shell — a dev run, a test harness — Theseus inherits that shell's pipe; // when the shell exits the pipe breaks, and the next console.log raises EPIPE // in the main process, which Electron reports as a fatal uncaught exception. // These streams only ever carry diagnostics, and by then nobody is reading // them, so a write that cannot land is not an error worth stopping for. for (const stream of [process.stdout, process.stderr]) stream.on("error", () => {}); // Node's ws turns "closed while still connecting" into a crash: close() on a // CONNECTING socket aborts the handshake and emits an error on the next tick, // and an error event with no listener is an uncaught exception. Browser // WebSockets shrug the same thing off, so libraries written for both do it — // nostr-tools drops its onerror handler and then closes the socket, which is // what the WizardConnect relay teardown in Aegis runs on every wallet // disconnect. Seen 2026-09-27 as the "JavaScript error occurred in the main // process" dialog. Every consumer in this process (messenger, add-ons, // WizardConnect via isomorphic-ws) shares this one ws module, so guard it here. { const close = WebSocket.prototype.close; WebSocket.prototype.close = function (...args) { if (this.readyState === WebSocket.CONNECTING && this.listenerCount("error") === 0) this.once("error", () => {}); return close.apply(this, args); }; } // Anything else that escapes to the top of the main process: Electron would // show a modal "JavaScript error occurred in the main process" and carry on. // Carry on without the modal, and keep the report where it can be found. process.on("uncaughtException", (err) => { const line = `[${new Date().toISOString()}] uncaught: ${(err && err.stack) || err}\n`; try { console.error(line); } catch {} try { const f = path.join(app.getPath("userData"), "main-errors.log"); try { if (fs.statSync(f).size > 512 * 1024) fs.truncateSync(f, 0); } catch {} fs.appendFileSync(f, line); } catch {} }); // 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); } } else { // The profile lives at \Theseus. Electron's default was the // product name ("Theseus Navigator"); a profile from before this change is // moved once, on the first start that finds it: a rename when possible // (same volume — instant, nothing copied), a copy when the rename is // refused (another process still holds the folder, or a junction to // another volume), in which case the old folder is left as it was. try { app.setPath("userData", relocateProfile(app.getPath("appData"))); } catch (e) { console.warn("profile relocation failed:", e?.message); } } // Native boxes (and anything else that reads app.name) say "Theseus Navigator", // not the package name. After the userData paths above: the default profile // location derives from the name, and those are set explicitly already. try { app.setName("Theseus Navigator"); } catch {} function relocateProfile(appData) { const newDir = path.join(appData, "Theseus"); // Two possible previous locations: "Theseus Navigator" (what the code // originally checked for, based on the assumed productName), and // "theseus-navigator" (what Electron ACTUALLY used because package.json // has no top-level productName, so app.getName() falls back to the "name" // field). Whichever exists is the profile the user has been running // against; migrate it in place so 0.3.51 does not silently create a fresh // Theseus\ next to a still-populated old dir the user cannot see. if (fs.existsSync(newDir)) return newDir; for (const candidate of ["Theseus Navigator", "theseus-navigator"]) { const oldDir = path.join(appData, candidate); if (!fs.existsSync(oldDir)) continue; try { fs.renameSync(oldDir, newDir); return newDir; } catch { // Copy beside the target and rename it into place only once complete. // Copying straight into newDir left a partial profile behind on failure // (a file locked by a running old instance, disk full) — and since // newDir then existed, the migration never ran again. const tmp = newDir + ".migrating"; try { fs.rmSync(tmp, { recursive: true, force: true }); fs.cpSync(oldDir, tmp, { recursive: true }); fs.renameSync(tmp, newDir); return newDir; } catch (e) { console.warn(`[profile] relocate ${candidate} failed, staying on it this run:`, e?.message); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} return oldDir; // complete, just not moved yet — next launch retries } } } return newDir; } // 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"}` }, 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", frozen: "Presearch has sent every search into a dead host since 2026-09-28.", 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"]; // A frozen catalog engine (`frozen: ""`) stays visible in Settings so the user sees why it // is gone, but it is never enabled, never offered in the picker and never the default. Unfreeze by // deleting the field; the user's installed list is left alone, so the row comes back as it was. const isFrozen = (id) => !!(SEARCH_ENGINES[id] && SEARCH_ENGINES[id].frozen); // Search-engine icons. The catalog's icons ship inside the build // (engine-icons/.png) so the toolbar, the picker and Settings look the // same offline as online, and opening the picker no longer tells a favicon // service which engines the user has configured — until 0.3.58 every icon // was an pointing at Google's /s2/favicons, re-fetched on each open // and falling through to the emoji whenever the network was away. A custom // engine's icon is fetched once (the site's own /favicon.ico first, Google's // service as the fallback) and cached under the profile; it shows the emoji // until that lands. A catalog engine without a bundled file (Phind: its // site is behind a bot wall and serves no icon) keeps the emoji too. const ENGINE_ICON_DIR = path.join(__dirname, "engine-icons"); const bundledEngineIcons = new Set((() => { try { return fs.readdirSync(ENGINE_ICON_DIR); } catch { return []; } })()); const bundledIcon = (id) => (bundledEngineIcons.has(id + ".png") ? url.pathToFileURL(path.join(ENGINE_ICON_DIR, id + ".png")).href : null); const customIconDir = () => path.join(app.getPath("userData"), "engine-icons"); const ICON_EXTS = ["png", "ico", "jpg", "gif", "webp", "svg"]; function engineHost(u) { try { return new URL(String(u).replace("%s", "x")).hostname.toLowerCase(); } catch { return ""; } } function cachedIconFile(host) { if (!host) return null; for (const ext of ICON_EXTS) { const f = path.join(customIconDir(), `${host}.${ext}`); if (fs.existsSync(f)) return f; } return null; } function customFavicon(u) { const host = engineHost(u); const f = cachedIconFile(host); if (f) return url.pathToFileURL(f).href; if (host) fetchEngineIcon(host).catch(() => {}); return null; } // Drop a removed custom engine's cached icon unless another custom engine on // the same host still uses it. function dropCustomIcon(id) { const eng = (settings.customEngines || []).find((e) => e.id === id); const host = eng ? engineHost(eng.url) : ""; if (!host || (settings.customEngines || []).some((e) => e.id !== id && engineHost(e.url) === host)) return; const f = cachedIconFile(host); if (f) try { fs.unlinkSync(f); } catch {} } const iconFetches = new Map(); // host → in-flight Promise, or the time the last attempt failed const ICON_RETRY_MS = 60 * 60 * 1000; function sniffImage(buf) { if (buf.length < 8) return null; if (buf.slice(1, 4).toString() === "PNG") return "png"; if (buf.readUInt32BE(0) === 0x00000100) return "ico"; if (buf[0] === 0xff && buf[1] === 0xd8) return "jpg"; if (buf.slice(0, 4).toString() === "GIF8") return "gif"; if (buf.slice(0, 4).toString() === "RIFF" && buf.slice(8, 12).toString() === "WEBP") return "webp"; if (/^\s*(<\?xml[^>]*>\s*)?(\s*)*]/i.test(buf.slice(0, 512).toString("utf8"))) return "svg"; return null; } async function fetchIconBytes(u) { const r = await net.fetch(u, { redirect: "follow", signal: AbortSignal.timeout(8000), headers: { accept: "image/*,*/*;q=0.5" } }); if (!r.ok) throw new Error(`http ${r.status}`); const buf = Buffer.from(await r.arrayBuffer()); if (buf.length < 64 || buf.length > 512 * 1024) throw new Error(`size ${buf.length}`); const ext = sniffImage(buf); if (!ext) throw new Error("not an image"); return { buf, ext }; } function fetchEngineIcon(host) { if (!app.isReady()) return Promise.resolve(null); // net.fetch needs the app; the startup pass retries const cur = iconFetches.get(host); if (cur && typeof cur.then === "function") return cur; if (typeof cur === "number" && Date.now() - cur < ICON_RETRY_MS) return Promise.resolve(null); const p = (async () => { let got = null; for (const src of [`https://${host}/favicon.ico`, `https://www.google.com/s2/favicons?domain=${host}&sz=64`]) { try { got = await fetchIconBytes(src); break; } catch {} } if (!got) { iconFetches.set(host, Date.now()); return null; } const dir = customIconDir(); fs.mkdirSync(dir, { recursive: true }); const f = path.join(dir, `${host}.${got.ext}`); fs.writeFileSync(f + ".tmp", got.buf); fs.renameSync(f + ".tmp", f); iconFetches.delete(host); emitEngines(); // the toolbar and the picker repaint with the real icon return f; })(); iconFetches.set(host, p); return p; } 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: bundledIcon(id), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null })); 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"; // ---- Signed DNS records ---------------------------------------------------- // Owners can publish an owner-signed `_records.json` manifest on Sia with // classic DNS data (A/AAAA/MX/TXT/CNAME/NS). The gateway verifies the // signature against the current NFT holder and serves the verified `dns` // block as GET /api/dns/ (Decentralized.DNS/INTEGRATION-signed-records- // clients.md). These records EXTEND on-chain records and never override them: // h/s3/ip/p/u stay authoritative for content. We fetch them in the background // on every BCDN resolution with a 3 s cap and hang the result on the entry as // `entry.dns`; a navigation never waits for the fetch, except when a name has // no on-chain content record at all and a signed A record is the only way to // reach it. Only registered names are looked up, so ICANN hosts the user // visits are never sent to the gateway. const DNS_RECORDS_TTL = 30_000; // matches the gateway's Cache-Control max-age=30 const dnsRecordsCache = new Map(); // name -> { value, at, seq, pending } function dnsRecordsCached(name) { const c = dnsRecordsCache.get(name); return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined; } // The routing half of a name's signed manifest for ONE host, verified by the // gateway (which already holds that code) and cached per host. Only consulted // for a subdomain that Theseus serves itself — an `ip` or inline `h` record — // because anything going through the gateway's /bns/ mount already had the // rule applied there. KEEP IN STEP with public-gateway.mjs serve(). const hostActionCache = new Map(); async function fetchHostAction(host) { const c = hostActionCache.get(host); if (c && Date.now() - c.at < DNS_RECORDS_TTL) return c.value; let value = null; try { const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(host)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" }); if (r.ok) { const j = await r.json(); const a = j?.host_action; if (a && typeof a === "object" && typeof a.kind === "string") value = a; } } catch { /* offline or no manifest — the name keeps its chain behaviour */ } hostActionCache.set(host, { value, at: Date.now() }); return value; } function fetchDnsRecords(name) { const c = dnsRecordsCache.get(name); if (c?.pending) return c.pending; if (c && Date.now() - c.at < DNS_RECORDS_TTL) return Promise.resolve(c.value); const prev = c?.value ?? null; const pending = (async () => { let value = prev; try { const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" }); if (r.ok) { const j = await r.json(); const seq = Number(j?.seq) || 0; // Rollback guard: a manifest with a lower seq than one already seen // for this name is stale (or replayed) — keep what we had. if (j && j.dns && typeof j.dns === "object" && seq >= (c?.seq ?? -1)) { value = { dns: j.dns, seq, updatedAt: j.updated_at || null, owner: j.verified_owner || null }; } } else if (r.status === 404 || r.status === 409) { value = null; // no manifest declared / nowhere to keep one } } catch { /* offline, timeout, bad JSON — records are optional */ } dnsRecordsCache.set(name, { value, at: Date.now(), seq: Math.max(c?.seq ?? -1, value?.seq ?? -1), pending: null }); return value; })(); dnsRecordsCache.set(name, { value: prev, at: c?.at ?? 0, seq: c?.seq ?? -1, pending }); return pending; } // Kick off the fetch for a resolved entry and attach the answer when it lands. // `entry.dns` is undefined while unknown, null when the owner published no // manifest, or { dns, seq, updatedAt, owner }. function attachDnsRecords(entry) { if (!entry || !entry.name) return; const cached = dnsRecordsCached(entry.name); if (cached !== undefined) { entry.dns = cached; return; } fetchDnsRecords(entry.name).then((v) => { entry.dns = v; }, () => {}); } // Record types the manifest actually carries (for the site-info popover). function dnsRecordKinds(entry) { const d = entry?.dns?.dns; if (!d || typeof d !== "object") return []; return Object.keys(d).filter((k) => Array.isArray(d[k]) ? d[k].length > 0 : d[k] != null && d[k] !== ""); } // First signed IPv4 address for a name — the reachability fallback when the // chain carries no content record. Waits for an in-flight fetch (≤ 3 s) only // because there is nothing else to serve. async function dnsAddressFor(entry) { if (!entry?.name) return null; // entry.dns was attached once and never refreshed. Go through the cache so // the TTL holds: a stale answer is served while it revalidates, and only a // name with no answer at all waits for the fetch. const c = dnsRecordsCache.get(entry.name); let v; if (c && c.at > 0) { v = c.value; if (Date.now() - c.at >= DNS_RECORDS_TTL) fetchDnsRecords(entry.name).catch(() => {}); } else v = await fetchDnsRecords(entry.name); const a = v?.dns?.A; const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null; return ip || null; } // ---- 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 //