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