Chip switcher now bypasses navigateTab/loadBns entirely and calls loadURL directly. Rationale: the user explicitly clicked a registry chip; that IS the choice. Routing through navigateTab -> loadBns was reaching the collision decision path in some races and could re-show the soft-mode 'Open with...' prompt as an unwanted extra step. Direct load guarantees the switch is atomic. Prov is updated inline using entries.get(host) for the BCDN case (source/records/ category), same shape loadBns would have produced. internalNav flag guards the programmatic load from the will-navigate handler (redirect chains stay clean). Also: address bar blurs on Enter so the tabs-event handler can update it to the actual loaded URL (search keyword no longer sticks in the address bar after search results load).
1197 lines
64 KiB
JavaScript
1197 lines
64 KiB
JavaScript
// Theseus Navigator — Electron main process.
|
|
// Native .bch via a custom `bns://` protocol: resolves names with the shared
|
|
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
|
|
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
|
|
// page, and optional Tor onion routing. No system daemon; the app is the trust
|
|
// boundary.
|
|
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell } = require("electron");
|
|
const path = require("path");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
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;
|
|
// 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");
|
|
// 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) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
|
|
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 = ["duckduckgo", "google", "brave", "bing", "startpage"];
|
|
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
|
|
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
|
|
const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null);
|
|
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
|
|
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
|
|
// Built-in + user-added, as flat metadata for the pickers. Built-in obey
|
|
// enabledEngines; custom engines are always enabled.
|
|
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, 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, enabled: true });
|
|
// 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); }
|
|
function engineUrl(id, q) {
|
|
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q);
|
|
const c = (settings.customEngines || []).find((e) => e.id === id);
|
|
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q);
|
|
}
|
|
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
|
|
// 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: "duckduckgo",// default search engine (built-in id or a custom id)
|
|
enabledEngines: DEFAULT_ENABLED.slice(), // which built-in engines show in the 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",
|
|
};
|
|
// 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); }
|
|
}
|
|
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 {} }
|
|
|
|
// ---- 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); }
|
|
// Accept-Language header follows the locale setting (session-wide, best effort).
|
|
function applyAcceptLanguage() {
|
|
const loc = effLocale() || app.getLocale() || "en-US";
|
|
try {
|
|
const ua = session.defaultSession.getUserAgent();
|
|
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
|
|
} catch {}
|
|
}
|
|
// ---- session restore + background throttling ----
|
|
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
|
|
function saveSession() {
|
|
try { fs.writeFileSync(sessionFile(), JSON.stringify(tabs.filter((t) => !t.settings && t.url).map((t) => t.url))); }
|
|
catch (e) { console.error("session save failed:", e.message); }
|
|
}
|
|
function loadSession() {
|
|
try { if (fs.existsSync(sessionFile())) return JSON.parse(fs.readFileSync(sessionFile(), "utf8")); } catch {}
|
|
return [];
|
|
}
|
|
function applyThrottle() {
|
|
for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} }
|
|
}
|
|
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
|
|
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
|
|
// media-device labels/ids from enumerateDevices. Handlers read settings live.
|
|
// Device permissions with no legitimate need here — always denied.
|
|
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
|
|
// A "media" request may ask for audio, video, or both — allow only if none blocked.
|
|
function mediaAllowed(kinds) {
|
|
if (kinds.includes("video") && settings.blockCamera) return false;
|
|
if (kinds.includes("audio") && settings.blockMicrophone) return false;
|
|
return true;
|
|
}
|
|
function applyPermissions() {
|
|
const ses = session.defaultSession;
|
|
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
|
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
|
|
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
|
|
if (SENSITIVE_DEVICE.has(permission)) return callback(false);
|
|
callback(true); // benign UX permissions (fullscreen, pointerLock, …)
|
|
});
|
|
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
|
|
if (permission === "media") {
|
|
if (details?.mediaType === "video") return !settings.blockCamera;
|
|
if (details?.mediaType === "audio") return !settings.blockMicrophone;
|
|
return !(settings.blockCamera && settings.blockMicrophone);
|
|
}
|
|
if (permission === "geolocation") return effLocation() !== "deny";
|
|
if (SENSITIVE_DEVICE.has(permission)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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()) };
|
|
}
|
|
// 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;
|
|
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; }
|
|
let idx = await ensureIndex();
|
|
let entry = idx.get(key) ?? null;
|
|
// Miss on a possibly-stale index → one fresh build (the name may be newly registered).
|
|
if (!entry && Date.now() - indexBuiltAt > 8_000) { 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;
|
|
try {
|
|
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) {
|
|
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
|
|
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
|
|
}
|
|
if (r.u) return Response.redirect(r.u, 302);
|
|
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
|
|
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
|
|
}
|
|
|
|
// ---- window + tabs ----
|
|
let win, chrome;
|
|
let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown
|
|
// Site-info popover: a floating overlay VIEW on top of the page content, so it
|
|
// never pushes the page down. Positioned under the address-bar badge on demand.
|
|
let popover, popVisible = false, popPos = { x: 8, y: 90 };
|
|
const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content
|
|
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
|
|
// engine favicons, like Firefox — a native <select> can't render images).
|
|
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
|
|
const EP_W = 250; let epH = 320;
|
|
// Downloads popover — a third floating overlay VIEW showing in-flight and
|
|
// recently-finished downloads. Anchored under the toolbar's download button.
|
|
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
|
|
const DL_W = 340; let dlH = 240;
|
|
// In-memory download list. Not persisted: closing the browser clears history
|
|
// (the files are still on disk; only the list of "recent downloads" is dropped).
|
|
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
|
|
const tabs = []; // { id, view, title, url, prov }
|
|
let activeId = null, tabSeq = 0;
|
|
const tabById = (id) => tabs.find((t) => t.id === id);
|
|
const activeTab = () => tabById(activeId);
|
|
|
|
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
|
|
// the bottom status line, so the resolver detail lives on the bottom bar.
|
|
// Enrich prov with a "collision candidate?" flag so the popover switcher can
|
|
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
|
|
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
|
|
function decorate(prov) {
|
|
if (!prov || !prov.tld) return prov;
|
|
return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) };
|
|
}
|
|
function pushNav(prov) {
|
|
const p = decorate(prov);
|
|
chrome?.webContents.send("nav", p);
|
|
if (popVisible) popover?.webContents.send("site-info", p);
|
|
}
|
|
|
|
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);
|
|
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH });
|
|
positionPopover();
|
|
positionEnginePicker();
|
|
positionDownloads();
|
|
}
|
|
function positionPopover() {
|
|
if (!popover) return;
|
|
const { width } = win.getContentBounds();
|
|
const x = Math.max(6, Math.min(popPos.x, width - POP_W - 6));
|
|
popover.setBounds({ x, y: popPos.y, width: POP_W, height: popH });
|
|
}
|
|
function showPopover(show) {
|
|
if (!popover) return;
|
|
if (show) {
|
|
positionPopover();
|
|
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
|
|
win.contentView.removeChildView(popover);
|
|
win.contentView.addChildView(popover);
|
|
popover.setVisible(true); popVisible = true;
|
|
popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" });
|
|
} else { popover.setVisible(false); popVisible = false; }
|
|
}
|
|
function positionEnginePicker() {
|
|
if (!enginePicker) return;
|
|
const { width } = win.getContentBounds();
|
|
const x = Math.max(6, Math.min(epPos.x, width - EP_W - 6));
|
|
enginePicker.setBounds({ x, y: epPos.y, width: EP_W, height: epH });
|
|
}
|
|
function showEnginePicker(show) {
|
|
if (!enginePicker) return;
|
|
if (show) {
|
|
positionEnginePicker();
|
|
win.contentView.removeChildView(enginePicker);
|
|
win.contentView.addChildView(enginePicker);
|
|
enginePicker.setVisible(true); epVisible = true;
|
|
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
|
|
} else { enginePicker.setVisible(false); epVisible = false; }
|
|
}
|
|
function positionDownloads() {
|
|
if (!downloadsPop) return;
|
|
const { width } = win.getContentBounds();
|
|
const x = Math.max(6, Math.min(dlPos.x, width - DL_W - 6));
|
|
downloadsPop.setBounds({ x, y: dlPos.y, width: DL_W, height: dlH });
|
|
}
|
|
function showDownloads(show) {
|
|
if (!downloadsPop) return;
|
|
if (show) {
|
|
positionDownloads();
|
|
win.contentView.removeChildView(downloadsPop);
|
|
win.contentView.addChildView(downloadsPop);
|
|
downloadsPop.setVisible(true); dlVisible = true;
|
|
downloadsPop.webContents.send("downloads", downloadsPublic());
|
|
} else { downloadsPop.setVisible(false); dlVisible = false; }
|
|
}
|
|
// 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 id = nextDlId++;
|
|
const rec = {
|
|
id,
|
|
filename: item.getFilename(),
|
|
url: item.getURL(),
|
|
mime: item.getMimeType(),
|
|
total: item.getTotalBytes() || 0,
|
|
received: 0,
|
|
state: "progressing", // progressing | paused | completed | cancelled | interrupted
|
|
savePath: "",
|
|
startedAt: Date.now(),
|
|
};
|
|
downloads.unshift(rec);
|
|
dlItems.set(id, item);
|
|
emitDownloads();
|
|
item.on("updated", (_ev, state) => {
|
|
rec.state = state; // "progressing" | "interrupted"
|
|
rec.received = item.getReceivedBytes();
|
|
rec.total = item.getTotalBytes() || rec.total;
|
|
rec.savePath = item.getSavePath() || rec.savePath;
|
|
emitDownloads();
|
|
});
|
|
item.once("done", (_ev, state) => {
|
|
rec.state = state; // "completed" | "cancelled" | "interrupted"
|
|
rec.received = item.getReceivedBytes();
|
|
rec.savePath = item.getSavePath() || rec.savePath;
|
|
dlItems.delete(id);
|
|
emitDownloads();
|
|
});
|
|
});
|
|
}
|
|
function setActive(id) {
|
|
activeId = id;
|
|
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
|
|
if (epVisible) showEnginePicker(false);
|
|
for (const t of tabs) t.view.setVisible(t.id === id);
|
|
const t = activeTab();
|
|
if (t?.prov) pushNav(t.prov);
|
|
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
|
|
emitTabs();
|
|
}
|
|
function emitTabs() {
|
|
const t = activeTab();
|
|
const wc = t?.view.webContents;
|
|
chrome?.webContents.send("tabs", {
|
|
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading })),
|
|
url: t?.url || "",
|
|
loading: !!t?.loading,
|
|
canBack: wc ? wc.navigationHistory.canGoBack() : false,
|
|
canForward: wc ? wc.navigationHistory.canGoForward() : false,
|
|
});
|
|
}
|
|
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
|
|
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();
|
|
}
|
|
function createTab(initial, opts = {}) {
|
|
const id = ++tabSeq;
|
|
const view = new WebContentsView(opts.settings ? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } } : {});
|
|
const wc = view.webContents;
|
|
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
|
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
|
applyFingerprint(wc);
|
|
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", prov: null, settings: !!opts.settings };
|
|
tabs.push(tab);
|
|
win.contentView.addChildView(view);
|
|
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
|
|
wc.on("did-navigate", () => emitTabs());
|
|
wc.on("did-navigate-in-page", () => emitTabs());
|
|
wc.on("did-start-loading", () => setLoading(tab, true));
|
|
wc.on("did-stop-loading", () => setLoading(tab, false));
|
|
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)) { e.preventDefault(); navigateTab(id, parsed.hostname + parsed.pathname); }
|
|
} catch {}
|
|
});
|
|
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
|
|
wc.setWindowOpenHandler(({ url, disposition }) => {
|
|
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
|
|
return { action: "deny" };
|
|
});
|
|
// Right-click context menu.
|
|
wc.on("context-menu", (_e, p) => {
|
|
const items = [];
|
|
if (p.linkURL) {
|
|
items.push(
|
|
{ label: "Open link in new tab", click: () => createTab(p.linkURL) },
|
|
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) },
|
|
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
|
|
{ type: "separator" },
|
|
);
|
|
}
|
|
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();
|
|
});
|
|
layout();
|
|
if (opts.background) { view.setVisible(false); emitTabs(); }
|
|
else setActive(id);
|
|
if (opts.settings) {
|
|
tab.prov = { host: "", kind: "home" };
|
|
wc.loadFile("settings.html");
|
|
if (id === activeId) pushNav(tab.prov);
|
|
emitTabs();
|
|
} else if (initial) navigateTab(id, initial);
|
|
else loadHome(id);
|
|
return id;
|
|
}
|
|
function closeTab(id) {
|
|
const i = tabs.findIndex((t) => t.id === id);
|
|
if (i < 0) return;
|
|
const [t] = tabs.splice(i, 1);
|
|
win.contentView.removeChildView(t.view);
|
|
t.view.webContents.destroy?.();
|
|
if (tabs.length === 0) { createTab(); return; }
|
|
if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id);
|
|
else emitTabs();
|
|
}
|
|
|
|
function createWindow() {
|
|
win = new BrowserWindow({ width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: "#0f1420" });
|
|
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
|
|
win.contentView.addChildView(chrome);
|
|
chrome.webContents.loadFile("chrome.html");
|
|
// Floating site-info overlay (hidden until the address-bar badge is clicked).
|
|
popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } });
|
|
try { popover.setBackgroundColor("#00000000"); } catch {}
|
|
win.contentView.addChildView(popover);
|
|
popover.webContents.loadFile("popover.html");
|
|
popover.setVisible(false);
|
|
// Floating engine-picker overlay (custom dropdown with real favicons).
|
|
enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } });
|
|
try { enginePicker.setBackgroundColor("#00000000"); } catch {}
|
|
win.contentView.addChildView(enginePicker);
|
|
enginePicker.webContents.loadFile("engine-picker.html");
|
|
enginePicker.setVisible(false);
|
|
// Floating downloads panel — shows active + recent downloads.
|
|
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
|
|
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
|
|
win.contentView.addChildView(downloadsPop);
|
|
downloadsPop.webContents.loadFile("downloads.html");
|
|
downloadsPop.setVisible(false);
|
|
chrome.webContents.once("did-finish-load", () => {
|
|
const saved = settings.restoreSession ? loadSession() : [];
|
|
if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
|
|
});
|
|
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;
|
|
setLoading(t, true); // show the loading indicator immediately (covers BNS resolution)
|
|
// 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) || "/";
|
|
|
|
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"); }
|
|
t.url = 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}`);
|
|
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
|
|
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
|
|
if (id === activeId) pushNav(t.prov);
|
|
emitTabs();
|
|
}
|
|
|
|
ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input));
|
|
ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
|
|
ipcMain.handle("new-tab", () => createTab());
|
|
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
|
|
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
|
|
ipcMain.handle("go-home", () => loadHome(activeId));
|
|
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", () => activeTab()?.view.webContents.reload());
|
|
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
|
|
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
|
|
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
|
|
ipcMain.handle("toggle-site-info", (_e, rect) => {
|
|
if (popVisible) return showPopover(false);
|
|
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
|
showPopover(true);
|
|
});
|
|
ipcMain.handle("close-site-info", () => showPopover(false));
|
|
ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); });
|
|
|
|
// ---- Collision-mode: per-tab live switcher + policy control -----------------
|
|
// Flip the active tab between BCNR and ICANN for its current host, optionally
|
|
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
|
|
ipcMain.handle("collision-switch", async (_e, arg) => {
|
|
const t = activeTab(); if (!t?.prov?.host) return null;
|
|
const host = t.prov.host, tld = tldOf(host);
|
|
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
|
|
const raw = arg?.choice;
|
|
const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr"
|
|
: (raw === "icann") ? "icann"
|
|
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
|
|
// Optional persistent memory ("Always use…" from the popover switcher).
|
|
rememberCollision(host, tld, choice, arg?.remember || "no");
|
|
const registry = registryOf(tld);
|
|
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
|
|
// collision-decision path can trigger a re-prompt on an explicit user switch.
|
|
// The user clicked the switcher; they've made their choice. Just load it.
|
|
setLoading(t, true);
|
|
t.internalNav = true;
|
|
try {
|
|
if (choice === "icann") {
|
|
t.url = "https://" + host + "/";
|
|
await t.view.webContents.loadURL(t.url);
|
|
t.prov = { host, kind: "web", note: "switched to ICANN", tld, registry };
|
|
} else {
|
|
t.url = host + "/";
|
|
await t.view.webContents.loadURL(`bns://${host}/`);
|
|
const rec = entries.get(host);
|
|
const r = rec?.entry?.records || {};
|
|
const src = r.h ? "on-chain (chain)" : r.s3 ? "Sia network" : r.ip ? "direct server" : r.u ? "redirect" : "record";
|
|
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
|
|
}
|
|
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
|
|
finally { t.internalNav = false; setLoading(t, false); }
|
|
if (t.id === activeId) pushNav(t.prov);
|
|
emitTabs();
|
|
});
|
|
ipcMain.handle("collision-state", () => ({
|
|
policy: settings.collisionPolicy,
|
|
byName: collisions.byName,
|
|
byTld: collisions.byTld,
|
|
bcnrTlds,
|
|
}));
|
|
ipcMain.handle("collision-set-policy", (_e, p) => {
|
|
if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); }
|
|
return settings.collisionPolicy;
|
|
});
|
|
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
|
|
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".
|
|
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) }];
|
|
settings.searchEngine = id; // select the one just added
|
|
saveSettings(); emitEngines();
|
|
}
|
|
return { engines: allEngines(), current: settings.searchEngine };
|
|
});
|
|
ipcMain.handle("remove-engine", (_e, id) => {
|
|
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
|
|
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
|
|
saveSettings(); emitEngines();
|
|
return { engines: allEngines(), current: settings.searchEngine };
|
|
});
|
|
// Enable/disable a built-in engine (which show in the toolbar dropdown).
|
|
ipcMain.handle("set-engine-enabled", (_e, id, on) => {
|
|
if (SEARCH_ENGINES[id]) {
|
|
let list = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
|
|
if (on) list.push(id);
|
|
settings.enabledEngines = list.length ? list : ["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 ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
|
|
// ---- 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(); });
|
|
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)) {
|
|
bookmarks.push({ title: bm.title || bm.url, url: bm.url });
|
|
saveBookmarks(); emitBookmarks();
|
|
}
|
|
return bookmarks;
|
|
});
|
|
ipcMain.handle("bookmark-remove", (_e, url) => {
|
|
bookmarks = bookmarks.filter((b) => b.url !== url);
|
|
saveBookmarks(); emitBookmarks();
|
|
return bookmarks;
|
|
});
|
|
ipcMain.handle("settings-get", () => settings);
|
|
ipcMain.handle("settings-set", (_e, key, val) => {
|
|
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
|
|
if (key === "webrtcMode") applyWebRTCPolicy();
|
|
if (key === "theme") applyTheme();
|
|
if (key === "backgroundThrottle") applyThrottle();
|
|
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
|
|
"locationMode", "locationRegion", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
|
|
return settings;
|
|
});
|
|
ipcMain.handle("set-chrome-height", (_e, h) => {
|
|
const next = Math.max(74, Math.min(260, Math.round(h) || 84));
|
|
if (next !== CHROME_H) { CHROME_H = next; layout(); }
|
|
});
|
|
ipcMain.handle("switch-to-bcnr", () => {
|
|
const t = activeTab(); if (!t || !t.bcnrOffer) return;
|
|
const { host, rest, tld } = t.bcnrOffer;
|
|
t.bcnrOffer = null;
|
|
chrome.webContents.send("bcnr-offer", null);
|
|
return loadBns(t, activeId, host, rest || "/", tld);
|
|
});
|
|
|
|
// 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();
|
|
loadCollisions();
|
|
applyPermissions();
|
|
applyAcceptLanguage();
|
|
protocol.handle("bns", serveBns);
|
|
installDownloadTracker();
|
|
createWindow();
|
|
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
|
|
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
|
});
|
|
app.on("before-quit", () => { saveSession(); stopTor(); });
|
|
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
|
|
}
|
|
|
|
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf };
|