// 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 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"); // 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", () => {}); // 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); } } 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 { try { fs.cpSync(oldDir, newDir, { recursive: true }); return newDir; } catch (e) { console.warn(`[profile] relocate ${candidate} failed:`, e?.message); } } } 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"}&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"; // ---- 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; } 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; const v = entry.dns !== undefined ? entry.dns : 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 //