The translator client now ships with the right defaults for the actual deployment: silentmode.st/libre (ICANN, via the main cert and no new subdomain) is the primary peer; libre.x and lingua.x are registered on BNS with `p` records that reverse-proxy back to the same backend; the public LibreTranslate.com key-gated tier stays as the last-resort entry. Two wiring fixes make the BNS fallback actually usable from Theseus: 1. translatorPostOnce rewrites the request URL through targetUrlFor before fetching, so a peer whose host is a BNS name (libre.x) is dispatched via the in-process bns:// handler — Chromium's net stack has no way to resolve `.x` by itself. 2. serveBns's p-record branch now forwards the method, headers and body of the original request to the upstream, not just a GET. Without that, a POST /translate against libre.x arrived at the backend as a GET with no body and 400'd — now the proxy is actually a reverse- proxy, as the record type's name promises. Verified end-to-end against the live silentmode.st/libre instance from a fresh Theseus profile with a Spanish test page: both the direct silentmode.st/libre peer and the libre.x -> bns:// -> serveP -> upstream path translate the page and the revert path restores the originals.
7754 lines
420 KiB
JavaScript
7754 lines
420 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, net, utilityProcess } = require("electron");
|
||
const path = require("path");
|
||
const url = require("url");
|
||
const http = require("http");
|
||
const https = require("https");
|
||
const tls = require("tls");
|
||
const { spawn } = require("child_process");
|
||
const fs = require("fs");
|
||
const WebSocket = require("ws");
|
||
const webapps = require("./webapps");
|
||
|
||
// A browser has no business dying because its stdout went away. Launched from
|
||
// a shell — a dev run, a test harness — Theseus inherits that shell's pipe;
|
||
// when the shell exits the pipe breaks, and the next console.log raises EPIPE
|
||
// in the main process, which Electron reports as a fatal uncaught exception.
|
||
// These streams only ever carry diagnostics, and by then nobody is reading
|
||
// them, so a write that cannot land is not an error worth stopping for.
|
||
for (const stream of [process.stdout, process.stderr]) stream.on("error", () => {});
|
||
|
||
// Node's ws turns "closed while still connecting" into a crash: close() on a
|
||
// CONNECTING socket aborts the handshake and emits an error on the next tick,
|
||
// and an error event with no listener is an uncaught exception. Browser
|
||
// WebSockets shrug the same thing off, so libraries written for both do it —
|
||
// nostr-tools drops its onerror handler and then closes the socket, which is
|
||
// what the WizardConnect relay teardown in Aegis runs on every wallet
|
||
// disconnect. Seen 2026-09-27 as the "JavaScript error occurred in the main
|
||
// process" dialog. Every consumer in this process (messenger, add-ons,
|
||
// WizardConnect via isomorphic-ws) shares this one ws module, so guard it here.
|
||
{
|
||
const close = WebSocket.prototype.close;
|
||
WebSocket.prototype.close = function (...args) {
|
||
if (this.readyState === WebSocket.CONNECTING && this.listenerCount("error") === 0) this.once("error", () => {});
|
||
return close.apply(this, args);
|
||
};
|
||
}
|
||
// Anything else that escapes to the top of the main process: Electron would
|
||
// show a modal "JavaScript error occurred in the main process" and carry on.
|
||
// Carry on without the modal, and keep the report where it can be found.
|
||
process.on("uncaughtException", (err) => {
|
||
const line = `[${new Date().toISOString()}] uncaught: ${(err && err.stack) || err}\n`;
|
||
try { console.error(line); } catch {}
|
||
try {
|
||
const f = path.join(app.getPath("userData"), "main-errors.log");
|
||
try { if (fs.statSync(f).size > 512 * 1024) fs.truncateSync(f, 0); } catch {}
|
||
fs.appendFileSync(f, line);
|
||
} catch {}
|
||
});
|
||
|
||
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
|
||
// run from inside app.asar); dev runs read them from the repo.
|
||
const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
|
||
// THESEUS_USER_DATA points a dev run at a throwaway profile so it never
|
||
// touches (or races) the real install's settings, vault and add-ons.
|
||
if (process.env.THESEUS_USER_DATA) {
|
||
try { app.setPath("userData", path.resolve(process.env.THESEUS_USER_DATA)); } catch (e) { console.warn("userData override failed:", e?.message); }
|
||
} else {
|
||
// The profile lives at <appData>\Theseus. Electron's default was the
|
||
// product name ("Theseus Navigator"); a profile from before this change is
|
||
// moved once, on the first start that finds it: a rename when possible
|
||
// (same volume — instant, nothing copied), a copy when the rename is
|
||
// refused (another process still holds the folder, or a junction to
|
||
// another volume), in which case the old folder is left as it was.
|
||
try { app.setPath("userData", relocateProfile(app.getPath("appData"))); }
|
||
catch (e) { console.warn("profile relocation failed:", e?.message); }
|
||
}
|
||
// Native boxes (and anything else that reads app.name) say "Theseus Navigator",
|
||
// not the package name. After the userData paths above: the default profile
|
||
// location derives from the name, and those are set explicitly already.
|
||
try { app.setName("Theseus Navigator"); } catch {}
|
||
function relocateProfile(appData) {
|
||
const newDir = path.join(appData, "Theseus");
|
||
// Two possible previous locations: "Theseus Navigator" (what the code
|
||
// originally checked for, based on the assumed productName), and
|
||
// "theseus-navigator" (what Electron ACTUALLY used because package.json
|
||
// has no top-level productName, so app.getName() falls back to the "name"
|
||
// field). Whichever exists is the profile the user has been running
|
||
// against; migrate it in place so 0.3.51 does not silently create a fresh
|
||
// Theseus\ next to a still-populated old dir the user cannot see.
|
||
if (fs.existsSync(newDir)) return newDir;
|
||
for (const candidate of ["Theseus Navigator", "theseus-navigator"]) {
|
||
const oldDir = path.join(appData, candidate);
|
||
if (!fs.existsSync(oldDir)) continue;
|
||
try { fs.renameSync(oldDir, newDir); return newDir; }
|
||
catch {
|
||
// Copy beside the target and rename it into place only once complete.
|
||
// Copying straight into newDir left a partial profile behind on failure
|
||
// (a file locked by a running old instance, disk full) — and since
|
||
// newDir then existed, the migration never ran again.
|
||
const tmp = newDir + ".migrating";
|
||
try {
|
||
fs.rmSync(tmp, { recursive: true, force: true });
|
||
fs.cpSync(oldDir, tmp, { recursive: true });
|
||
fs.renameSync(tmp, newDir);
|
||
return newDir;
|
||
} catch (e) {
|
||
console.warn(`[profile] relocate ${candidate} failed, staying on it this run:`, e?.message);
|
||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||
return oldDir; // complete, just not moved yet — next launch retries
|
||
}
|
||
}
|
||
}
|
||
return newDir;
|
||
}
|
||
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
|
||
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
|
||
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
|
||
const RESOLVER = app.isPackaged
|
||
? path.join(RES_DIR, "resolver-web.mjs")
|
||
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
|
||
// Password vault — same .mjs-in-resources pattern as the resolver.
|
||
const VAULT_MOD = app.isPackaged
|
||
? path.join(RES_DIR, "password-vault.mjs")
|
||
: path.join(__dirname, "..", "Argus", "src", "lib", "password-vault.js");
|
||
let vaultLib;
|
||
async function loadVaultLib() {
|
||
if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`);
|
||
return vaultLib;
|
||
}
|
||
// Hermes messaging module — same .mjs-in-resources / .js-in-dev pattern.
|
||
const HERMES_MOD = app.isPackaged
|
||
? path.join(RES_DIR, "lib", "hermes.mjs")
|
||
: path.join(__dirname, "lib", "hermes.js");
|
||
let hermesLib;
|
||
async function loadHermesLib() {
|
||
if (!hermesLib) hermesLib = await import(`file://${HERMES_MOD.replace(/\\/g, "/")}`);
|
||
return hermesLib;
|
||
}
|
||
// Built-in engines. Users can also add their own (settings.customEngines,
|
||
// each { id, name, url } where the url contains "%s" for the query).
|
||
// Catalog of built-in engines (users pick which to enable + can add their own).
|
||
// fav = the domain to load a real favicon from; sym = emoji fallback.
|
||
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
|
||
// tier = "catalog" (curated first-class options shown in the primary Add
|
||
// panel) or "extra" (a wider bank hidden behind a filter box for
|
||
// discovery). Missing tier defaults to "catalog".
|
||
// URL routing is identical for all — kind and tier are display-only grouping.
|
||
const SEARCH_ENGINES = {
|
||
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
|
||
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q, h = {}) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=${h.hl || "en"}&gl=${h.gl || "us"}` },
|
||
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
|
||
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
|
||
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
|
||
yandex: { kind: "search", tier: "catalog", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
|
||
ecosia: { kind: "search", tier: "catalog", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
|
||
mojeek: { kind: "search", tier: "catalog", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
|
||
// SearXNG is federated (dozens of public instances at searx.space); any single
|
||
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
|
||
// Users who want SearXNG add their preferred instance via the custom URL form.
|
||
wikipedia: { kind: "search", tier: "catalog", name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
|
||
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
|
||
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
|
||
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
|
||
perplexity: { kind: "llm", tier: "catalog", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
|
||
phind: { kind: "llm", tier: "catalog", name: "Phind", sym: "🧑💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
|
||
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
|
||
// These are known-working engines that don't require sign-in on ?q= but aren't
|
||
// first-class enough to sit in the primary catalog. Keep the list vetted — if
|
||
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
|
||
marginalia: { kind: "search", tier: "extra", name: "Marginalia", sym: "🕸", fav: "search.marginalia.nu", url: (q) => "https://search.marginalia.nu/search?query=" + encodeURIComponent(q) },
|
||
stract: { kind: "search", tier: "extra", name: "Stract", sym: "🧵", fav: "stract.com", url: (q) => "https://stract.com/search?q=" + encodeURIComponent(q) },
|
||
yep: { kind: "search", tier: "extra", name: "Yep", sym: "✳️", fav: "yep.com", url: (q) => "https://yep.com/web?q=" + encodeURIComponent(q) },
|
||
presearch: { kind: "search", tier: "extra", name: "Presearch", sym: "🔷", fav: "presearch.com", frozen: "Presearch has sent every search into a dead host since 2026-09-28.", url: (q) => "https://presearch.com/search?q=" + encodeURIComponent(q) },
|
||
metager: { kind: "search", tier: "extra", name: "MetaGer", sym: "🇩🇪", fav: "metager.org", url: (q) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent(q) },
|
||
qwant: { kind: "search", tier: "extra", name: "Qwant", sym: "🇫🇷", fav: "www.qwant.com", url: (q) => "https://www.qwant.com/?q=" + encodeURIComponent(q) },
|
||
swisscows: { kind: "search", tier: "extra", name: "Swisscows", sym: "🐄", fav: "swisscows.com", url: (q) => "https://swisscows.com/en/web?query=" + encodeURIComponent(q) },
|
||
naver: { kind: "search", tier: "extra", name: "Naver", sym: "🇰🇷", fav: "www.naver.com", url: (q) => "https://search.naver.com/search.naver?query=" + encodeURIComponent(q) },
|
||
baidu: { kind: "search", tier: "extra", name: "Baidu", sym: "🇨🇳", fav: "www.baidu.com", url: (q) => "https://www.baidu.com/s?wd=" + encodeURIComponent(q) },
|
||
};
|
||
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
|
||
// catalog and can be turned on from Settings. Custom + detected engines are always on.
|
||
const DEFAULT_ENABLED = ["startpage", "duckduckgo", "google", "brave", "bing"];
|
||
// A frozen catalog engine (`frozen: "<reason>"`) stays visible in Settings so the user sees why it
|
||
// is gone, but it is never enabled, never offered in the picker and never the default. Unfreeze by
|
||
// deleting the field; the user's installed list is left alone, so the row comes back as it was.
|
||
const isFrozen = (id) => !!(SEARCH_ENGINES[id] && SEARCH_ENGINES[id].frozen);
|
||
// Search-engine icons. The catalog's icons ship inside the build
|
||
// (engine-icons/<id>.png) so the toolbar, the picker and Settings look the
|
||
// same offline as online, and opening the picker no longer tells a favicon
|
||
// service which engines the user has configured — until 0.3.58 every icon
|
||
// was an <img> pointing at Google's /s2/favicons, re-fetched on each open
|
||
// and falling through to the emoji whenever the network was away. A custom
|
||
// engine's icon is fetched once (the site's own /favicon.ico first, Google's
|
||
// service as the fallback) and cached under the profile; it shows the emoji
|
||
// until that lands. A catalog engine without a bundled file (Phind: its
|
||
// site is behind a bot wall and serves no icon) keeps the emoji too.
|
||
const ENGINE_ICON_DIR = path.join(__dirname, "engine-icons");
|
||
const bundledEngineIcons = new Set((() => { try { return fs.readdirSync(ENGINE_ICON_DIR); } catch { return []; } })());
|
||
const bundledIcon = (id) => (bundledEngineIcons.has(id + ".png") ? url.pathToFileURL(path.join(ENGINE_ICON_DIR, id + ".png")).href : null);
|
||
const customIconDir = () => path.join(app.getPath("userData"), "engine-icons");
|
||
const ICON_EXTS = ["png", "ico", "jpg", "gif", "webp", "svg"];
|
||
function engineHost(u) { try { return new URL(String(u).replace("%s", "x")).hostname.toLowerCase(); } catch { return ""; } }
|
||
function cachedIconFile(host) {
|
||
if (!host) return null;
|
||
for (const ext of ICON_EXTS) { const f = path.join(customIconDir(), `${host}.${ext}`); if (fs.existsSync(f)) return f; }
|
||
return null;
|
||
}
|
||
function customFavicon(u) {
|
||
const host = engineHost(u);
|
||
const f = cachedIconFile(host);
|
||
if (f) return url.pathToFileURL(f).href;
|
||
if (host) fetchEngineIcon(host).catch(() => {});
|
||
return null;
|
||
}
|
||
// Drop a removed custom engine's cached icon unless another custom engine on
|
||
// the same host still uses it.
|
||
function dropCustomIcon(id) {
|
||
const eng = (settings.customEngines || []).find((e) => e.id === id);
|
||
const host = eng ? engineHost(eng.url) : "";
|
||
if (!host || (settings.customEngines || []).some((e) => e.id !== id && engineHost(e.url) === host)) return;
|
||
const f = cachedIconFile(host);
|
||
if (f) try { fs.unlinkSync(f); } catch {}
|
||
}
|
||
const iconFetches = new Map(); // host → in-flight Promise, or the time the last attempt failed
|
||
const ICON_RETRY_MS = 60 * 60 * 1000;
|
||
function sniffImage(buf) {
|
||
if (buf.length < 8) return null;
|
||
if (buf.slice(1, 4).toString() === "PNG") return "png";
|
||
if (buf.readUInt32BE(0) === 0x00000100) return "ico";
|
||
if (buf[0] === 0xff && buf[1] === 0xd8) return "jpg";
|
||
if (buf.slice(0, 4).toString() === "GIF8") return "gif";
|
||
if (buf.slice(0, 4).toString() === "RIFF" && buf.slice(8, 12).toString() === "WEBP") return "webp";
|
||
if (/^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(buf.slice(0, 512).toString("utf8"))) return "svg";
|
||
return null;
|
||
}
|
||
async function fetchIconBytes(u) {
|
||
const r = await net.fetch(u, { redirect: "follow", signal: AbortSignal.timeout(8000), headers: { accept: "image/*,*/*;q=0.5" } });
|
||
if (!r.ok) throw new Error(`http ${r.status}`);
|
||
const buf = Buffer.from(await r.arrayBuffer());
|
||
if (buf.length < 64 || buf.length > 512 * 1024) throw new Error(`size ${buf.length}`);
|
||
const ext = sniffImage(buf);
|
||
if (!ext) throw new Error("not an image");
|
||
return { buf, ext };
|
||
}
|
||
function fetchEngineIcon(host) {
|
||
if (!app.isReady()) return Promise.resolve(null); // net.fetch needs the app; the startup pass retries
|
||
const cur = iconFetches.get(host);
|
||
if (cur && typeof cur.then === "function") return cur;
|
||
if (typeof cur === "number" && Date.now() - cur < ICON_RETRY_MS) return Promise.resolve(null);
|
||
const p = (async () => {
|
||
let got = null;
|
||
for (const src of [`https://${host}/favicon.ico`, `https://www.google.com/s2/favicons?domain=${host}&sz=64`]) {
|
||
try { got = await fetchIconBytes(src); break; } catch {}
|
||
}
|
||
if (!got) { iconFetches.set(host, Date.now()); return null; }
|
||
const dir = customIconDir();
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const f = path.join(dir, `${host}.${got.ext}`);
|
||
fs.writeFileSync(f + ".tmp", got.buf); fs.renameSync(f + ".tmp", f);
|
||
iconFetches.delete(host);
|
||
emitEngines(); // the toolbar and the picker repaint with the real icon
|
||
return f;
|
||
})();
|
||
iconFetches.set(host, p);
|
||
return p;
|
||
}
|
||
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
|
||
// Two-tier state: an engine is INSTALLED if it's in the user's Additional
|
||
// list (visible in Settings), and ENABLED if it's currently toggled on
|
||
// (visible in the toolbar dropdown). Toggle flips enabled only; right-click
|
||
// "Remove from list" is what actually removes an installed engine.
|
||
function isInstalled(id) {
|
||
if ((settings.customEngines || []).some((e) => e.id === id)) return true; // customs are always installed
|
||
return (settings.installedEngines || DEFAULT_ENABLED).includes(id);
|
||
}
|
||
function allEngines() {
|
||
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
|
||
({ id, name: e.name, sym: e.sym, favicon: bundledIcon(id), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null }));
|
||
for (const c of settings.customEngines || [])
|
||
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) });
|
||
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
|
||
const order = settings.engineOrder || [];
|
||
return list.slice().sort((a, b) => {
|
||
const ia = order.indexOf(a.id), ib = order.indexOf(b.id);
|
||
if (ia === -1 && ib === -1) return 0;
|
||
if (ia === -1) return 1;
|
||
if (ib === -1) return -1;
|
||
return ia - ib;
|
||
});
|
||
}
|
||
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
|
||
// Region → 2-letter country code, for engines that accept a `gl`-style hint
|
||
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
|
||
// a consent redirect or the region-detect start page instead of results).
|
||
const REGION_TO_COUNTRY = {
|
||
europe: "de", asia: "jp", north_america: "us", south_america: "br",
|
||
africa: "ke", middle_east: "ae", australia: "au",
|
||
};
|
||
function searchHints() {
|
||
const loc = effLocale(); // e.g. "en-US" (or null → show real)
|
||
const region = settings.locationMode === "spoof" ? settings.locationRegion : null;
|
||
return {
|
||
hl: (loc || app.getLocale() || "en").split("-")[0],
|
||
gl: REGION_TO_COUNTRY[region] || (loc && loc.split("-")[1]?.toLowerCase()) || "us",
|
||
};
|
||
}
|
||
function engineUrl(id, q) {
|
||
const h = searchHints();
|
||
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q, h);
|
||
const c = (settings.customEngines || []).find((e) => e.id === id);
|
||
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q, h);
|
||
}
|
||
const SEARCH = (q) => engineUrl(settings.searchEngine, q);
|
||
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
|
||
const GATEWAY = "https://navigate.st";
|
||
|
||
// ---- Signed DNS records ----------------------------------------------------
|
||
// Owners can publish an owner-signed `_records.json` manifest on Sia with
|
||
// classic DNS data (A/AAAA/MX/TXT/CNAME/NS). The gateway verifies the
|
||
// signature against the current NFT holder and serves the verified `dns`
|
||
// block as GET /api/dns/<name> (Decentralized.DNS/INTEGRATION-signed-records-
|
||
// clients.md). These records EXTEND on-chain records and never override them:
|
||
// h/s3/ip/p/u stay authoritative for content. We fetch them in the background
|
||
// on every BCDN resolution with a 3 s cap and hang the result on the entry as
|
||
// `entry.dns`; a navigation never waits for the fetch, except when a name has
|
||
// no on-chain content record at all and a signed A record is the only way to
|
||
// reach it. Only registered names are looked up, so ICANN hosts the user
|
||
// visits are never sent to the gateway.
|
||
const DNS_RECORDS_TTL = 30_000; // matches the gateway's Cache-Control max-age=30
|
||
const dnsRecordsCache = new Map(); // name -> { value, at, seq, pending }
|
||
function dnsRecordsCached(name) {
|
||
const c = dnsRecordsCache.get(name);
|
||
return c && Date.now() - c.at < DNS_RECORDS_TTL ? c.value : undefined;
|
||
}
|
||
// The routing half of a name's signed manifest for ONE host, verified by the
|
||
// gateway (which already holds that code) and cached per host. Only consulted
|
||
// for a subdomain that Theseus serves itself — an `ip` or inline `h` record —
|
||
// because anything going through the gateway's /bns/ mount already had the
|
||
// rule applied there. KEEP IN STEP with public-gateway.mjs serve().
|
||
const hostActionCache = new Map();
|
||
async function fetchHostAction(host) {
|
||
const c = hostActionCache.get(host);
|
||
if (c && Date.now() - c.at < DNS_RECORDS_TTL) return c.value;
|
||
let value = null;
|
||
try {
|
||
const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(host)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" });
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
const a = j?.host_action;
|
||
if (a && typeof a === "object" && typeof a.kind === "string") value = a;
|
||
}
|
||
} catch { /* offline or no manifest — the name keeps its chain behaviour */ }
|
||
hostActionCache.set(host, { value, at: Date.now() });
|
||
return value;
|
||
}
|
||
function fetchDnsRecords(name) {
|
||
const c = dnsRecordsCache.get(name);
|
||
if (c?.pending) return c.pending;
|
||
if (c && Date.now() - c.at < DNS_RECORDS_TTL) return Promise.resolve(c.value);
|
||
const prev = c?.value ?? null;
|
||
const pending = (async () => {
|
||
let value = prev;
|
||
try {
|
||
const r = await fetch(`${GATEWAY}/api/dns/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(3000), cache: "no-store" });
|
||
if (r.ok) {
|
||
const j = await r.json();
|
||
const seq = Number(j?.seq) || 0;
|
||
// Rollback guard: a manifest with a lower seq than one already seen
|
||
// for this name is stale (or replayed) — keep what we had.
|
||
if (j && j.dns && typeof j.dns === "object" && seq >= (c?.seq ?? -1)) {
|
||
value = { dns: j.dns, seq, updatedAt: j.updated_at || null, owner: j.verified_owner || null };
|
||
}
|
||
} else if (r.status === 404 || r.status === 409) {
|
||
value = null; // no manifest declared / nowhere to keep one
|
||
}
|
||
} catch { /* offline, timeout, bad JSON — records are optional */ }
|
||
dnsRecordsCache.set(name, { value, at: Date.now(), seq: Math.max(c?.seq ?? -1, value?.seq ?? -1), pending: null });
|
||
return value;
|
||
})();
|
||
dnsRecordsCache.set(name, { value: prev, at: c?.at ?? 0, seq: c?.seq ?? -1, pending });
|
||
return pending;
|
||
}
|
||
// Kick off the fetch for a resolved entry and attach the answer when it lands.
|
||
// `entry.dns` is undefined while unknown, null when the owner published no
|
||
// manifest, or { dns, seq, updatedAt, owner }.
|
||
function attachDnsRecords(entry) {
|
||
if (!entry || !entry.name) return;
|
||
const cached = dnsRecordsCached(entry.name);
|
||
if (cached !== undefined) { entry.dns = cached; return; }
|
||
fetchDnsRecords(entry.name).then((v) => { entry.dns = v; }, () => {});
|
||
}
|
||
// Record types the manifest actually carries (for the site-info popover).
|
||
function dnsRecordKinds(entry) {
|
||
const d = entry?.dns?.dns;
|
||
if (!d || typeof d !== "object") return [];
|
||
return Object.keys(d).filter((k) => Array.isArray(d[k]) ? d[k].length > 0 : d[k] != null && d[k] !== "");
|
||
}
|
||
// First signed IPv4 address for a name — the reachability fallback when the
|
||
// chain carries no content record. Waits for an in-flight fetch (≤ 3 s) only
|
||
// because there is nothing else to serve.
|
||
async function dnsAddressFor(entry) {
|
||
if (!entry?.name) return null;
|
||
// entry.dns was attached once and never refreshed. Go through the cache so
|
||
// the TTL holds: a stale answer is served while it revalidates, and only a
|
||
// name with no answer at all waits for the fetch.
|
||
const c = dnsRecordsCache.get(entry.name);
|
||
let v;
|
||
if (c && c.at > 0) { v = c.value; if (Date.now() - c.at >= DNS_RECORDS_TTL) fetchDnsRecords(entry.name).catch(() => {}); }
|
||
else v = await fetchDnsRecords(entry.name);
|
||
const a = v?.dns?.A;
|
||
const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null;
|
||
return ip || null;
|
||
}
|
||
|
||
// ---- BNS name detection (multi-TLD) --------------------------------------
|
||
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
|
||
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
|
||
// <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
|
||
}
|
||
// Local files typed or pasted into the address bar: a file:// URL, a Windows
|
||
// drive path (D:\x\y.html, mixed slashes allowed), a UNC share, or on POSIX
|
||
// an absolute / ~ path. Returns the file:// URL to load, or null when the
|
||
// input isn't a local path. Checked before looksLikeUrl — a path has no dotted
|
||
// host, so the URL heuristic would otherwise hand it to the search engine.
|
||
function localFileUrl(q) {
|
||
if (!q) return null;
|
||
if (/^file:/i.test(q)) { try { return new URL(q).href; } catch { return null; } }
|
||
const isWin = process.platform === "win32";
|
||
const winPath = /^[a-z]:[\\/]/i.test(q) || /^\\\\[^\\]/.test(q);
|
||
const posixPath = !isWin && (q.startsWith("/") || q.startsWith("~/"));
|
||
if (!winPath && !posixPath) return null;
|
||
let p = q;
|
||
if (posixPath && p.startsWith("~/")) p = path.join(app.getPath("home"), p.slice(2));
|
||
try { return url.pathToFileURL(path.resolve(p)).href; } catch { return null; }
|
||
}
|
||
|
||
// ---- 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
|
||
freezeBackgroundTabs: true, // a tab you switch away from stops (JS, timers, media) unless marked "Keep running"
|
||
// 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-GB", // spoof = top-10 pick, manual = free text (English = UK original; US variant retired from the picker)
|
||
locationMode: "hide", locationRegion: "europe", locationCountry: "DE", locationLat: "40.7128", locationLon: "-74.0060", // manual = pick a country (locationCountry drives lat/lon); legacy spoof/region still handled by effLocation() for old profiles
|
||
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: [],
|
||
// Toolbar extension dock: user-chosen button order (keys "p:<panelId>" /
|
||
// "m:<addonId>", unknown keys keep registration order after these) and
|
||
// buttons hidden from the toolbar via the dock's right-click menu.
|
||
dockOrder: [],
|
||
dockHidden: [],
|
||
dockAutoHidden: [], // add-ons already started hidden once (manifest dock:"hidden")
|
||
// DNS over HTTPS: off | automatic (encrypt when the system resolver has a
|
||
// known DoH endpoint, otherwise plain) | secure (always the chosen provider,
|
||
// no plain fallback). Provider is a preset id or "custom" + a URL.
|
||
dohMode: "automatic", dohProvider: "quad9", dohCustom: "",
|
||
// Global Privacy Control: the Sec-GPC header + navigator.globalPrivacyControl,
|
||
// a legally meaningful "do not sell or share" signal in several jurisdictions.
|
||
gpc: true,
|
||
// Page translator. Converts a page's visible text to the user's own
|
||
// language — Accept-Language only asks the server for a translated body
|
||
// (and many static sites, including BCNR names, serve only one). The
|
||
// engine is swappable; v0.3.70 ships a LibreTranslate client that talks
|
||
// to any API-compatible endpoint. On-device Bergamot/WASM is a follow-up
|
||
// (same contract: a function that takes an array of strings and a target
|
||
// tag, returns an array of translations).
|
||
translateBackend: "libretranslate", // libretranslate | (future) bergamot
|
||
// Ordered peer list — tried in sequence, the first one that answers
|
||
// wins. The free public LibreTranslate tiers go dark every few months
|
||
// (the libretranslate.com free tier moved behind an API key in late
|
||
// 2026, several mirrors 502 at any given time), so a list beats one
|
||
// endpoint: a dead mirror doesn't kill the feature, it just loses the
|
||
// round. Silent Mode's own instances come first: silentmode.st/libre
|
||
// under ICANN (served as a sub-path to re-use the main cert instead
|
||
// of standing up a subdomain + separate TLS) and libre.x on BNS
|
||
// (lingua.x is registered as an alias of libre.x — same ip record,
|
||
// same backend — so it's a resolution convenience, not another
|
||
// independent peer). Users can edit the list in Settings › General
|
||
// › Translate pages.
|
||
translateEndpoints: [
|
||
"https://silentmode.st/libre/translate",
|
||
"https://libre.x/translate",
|
||
"https://libretranslate.com/translate",
|
||
],
|
||
translateApiKey: "",
|
||
// Light up the URL-bar translate chip when the loaded page's language
|
||
// differs from the user's preferred one. Clicking the chip translates
|
||
// the page in place; clicking it again reverts.
|
||
translateAutoOffer: true,
|
||
// Hosts the user never wants auto-offered translation on — their own
|
||
// webmail, docs apps, anything they prefer in its original language.
|
||
translateExcludedHosts: [],
|
||
// 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,
|
||
// Quick-links strip on the left edge (Opera-style). Thin vertical column of
|
||
// service shortcuts; click opens the URL in a new tab. Toggle via settings.
|
||
quickLinksShow: true,
|
||
quickLinks: [
|
||
{ id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
|
||
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
|
||
{ id: "x", url: "https://x.com/", title: "X" },
|
||
{ id: "youtube", url: "https://www.youtube.com/", title: "YouTube" },
|
||
],
|
||
// Toolbar bar sizes. The URL bar is flex:1 by default so it eats all
|
||
// remaining space; `compact`/`medium` cap it so the extension dock has
|
||
// room to grow. The search box is fixed-width; hidden removes it.
|
||
urlBarSize: "wide", // compact | medium | wide (default)
|
||
searchBoxSize: "normal", // hidden | compact | normal (default) | wide
|
||
// Drag-set widths in pixels. Non-zero → override the corresponding size
|
||
// preset; zero/null → follow the preset. Set by the toolbar drag handles.
|
||
urlBarWidthPx: 0,
|
||
searchBoxWidthPx: 0,
|
||
// DevTools dock position. "bottom" (default) opens the console under the
|
||
// tab, matching Chrome's own default; "sidebar" docks it in the right
|
||
// sidebar, replacing the add-on sidebar while it's open; "two-sidebars"
|
||
// opens the console AND lets the add-on sidebar stay visible on its own
|
||
// right-side dock — Electron's mode:right takes over the right edge, so
|
||
// "two-sidebars" is drawn as mode:right and the add-on sidebar is not
|
||
// forced closed; the two share the right area (add-on sidebar keeps its
|
||
// width, DevTools takes what's left).
|
||
devToolsDock: "bottom", // bottom | sidebar | two-sidebars
|
||
};
|
||
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
|
||
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
|
||
function applyTheme() {
|
||
try { nativeTheme.themeSource = ["dark", "light", "system"].includes(settings.theme) ? settings.theme : "dark"; } catch {}
|
||
}
|
||
// Auto decoys used by "spoof" mode (plausible but not the user's real values).
|
||
const SPOOF = { tz: "America/New_York", lang: "en-US", lat: 40.7128, lon: -74.0060 };
|
||
let settings = { ...SETTINGS_DEFAULTS };
|
||
const settingsFile = () => path.join(app.getPath("userData"), "settings.json");
|
||
function loadSettings() {
|
||
try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; }
|
||
catch (e) { console.error("settings load failed:", e.message); }
|
||
// Restore the persisted sidebar width so the first-open of a session
|
||
// uses whatever the user left it at last time.
|
||
const w = Number(settings.sidebarWidth) || SIDEBAR_W_DEFAULT;
|
||
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, w));
|
||
// Normalize the search-engine state so the toolbar picker and Settings tab
|
||
// can never disagree. Two invariants:
|
||
// 1. Every enabledEngines id must also be in installedEngines. If a user
|
||
// manually edited settings.json (or an upgrade left the two out of
|
||
// sync), we add the missing installed rows now.
|
||
// 2. settings.searchEngine must be an enabled engine. If the default from
|
||
// SETTINGS_DEFAULTS points at an id the user has disabled, fall back
|
||
// to the first currently-enabled engine.
|
||
try {
|
||
const enabled = Array.isArray(settings.enabledEngines) ? settings.enabledEngines : DEFAULT_ENABLED.slice();
|
||
const installed = Array.isArray(settings.installedEngines) ? settings.installedEngines : DEFAULT_ENABLED.slice();
|
||
settings.installedEngines = [...new Set([...installed, ...enabled])];
|
||
if (!enabled.includes(settings.searchEngine) || isFrozen(settings.searchEngine)) {
|
||
settings.searchEngine = enabled.find((x) => !isFrozen(x)) || DEFAULT_ENABLED[0];
|
||
}
|
||
} catch (e) { console.warn("engine normalize:", e?.message); }
|
||
// Quick-links default set changed in 0.3.70: drop Messenger + Spotify and
|
||
// reorder to Telegram, WhatsApp, X, YouTube. Users who kept the previous
|
||
// untouched default (same 6 ids, same order) move to the new set; any
|
||
// customisation (reorder, add, remove) is left alone.
|
||
try {
|
||
const PRIOR = ["messenger", "whatsapp", "telegram", "x", "youtube", "spotify"];
|
||
const have = Array.isArray(settings.quickLinks) ? settings.quickLinks.map((L) => String(L?.id || "")) : [];
|
||
if (have.length === PRIOR.length && have.every((id, i) => id === PRIOR[i])) {
|
||
settings.quickLinks = SETTINGS_DEFAULTS.quickLinks.map((L) => ({ ...L }));
|
||
}
|
||
} catch (e) { console.warn("quickLinks migrate:", e?.message); }
|
||
// Translator moved from a single `translateEndpoint` to a peer list in
|
||
// the same release that shipped the chip. A custom endpoint comes along
|
||
// as the primary peer; the historical LibreTranslate.com default is
|
||
// dropped so the user picks up the fresh default list (silentmode.st +
|
||
// the BNS name + the public mirror).
|
||
try {
|
||
const legacy = typeof settings.translateEndpoint === "string" ? settings.translateEndpoint.trim() : "";
|
||
if (legacy && legacy !== "https://libretranslate.com/translate") {
|
||
const have = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : SETTINGS_DEFAULTS.translateEndpoints.slice();
|
||
if (!have.includes(legacy)) settings.translateEndpoints = [legacy, ...have];
|
||
}
|
||
if ("translateEndpoint" in settings) delete settings.translateEndpoint;
|
||
} catch (e) { console.warn("translateEndpoints migrate:", 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() {
|
||
// Dev harness guard: a throwaway instance (THESEUS_USER_DATA) that finds a
|
||
// newer release on the mirror shows the same one-click "Install & restart"
|
||
// chip as a real install, and that installer targets the REAL install dir.
|
||
// 2026-09-15 a test run reinstalled the user's Theseus that way.
|
||
// Returns true when the manifest was read (whatever it said), false when
|
||
// the check itself failed — the startup scheduler retries on false.
|
||
if (process.env.THESEUS_NO_UPDATE_CHECK) return true;
|
||
const t0 = Date.now();
|
||
try {
|
||
// 20 s, not 5: the startup check shares the main thread with snapshot
|
||
// parsing, tab restore and add-on activation. Measured 2026-10-03: 3.2 s
|
||
// for this fetch on an empty profile, 1.6 s of it the event loop being
|
||
// busy — a real profile went past 5 s, the abort won, and (the failure
|
||
// being silent with no retry) the update only appeared after a manual
|
||
// "Check for updates".
|
||
const r = await fetch(UPDATE_MANIFEST_URL, { signal: AbortSignal.timeout(20000), cache: "no-store" });
|
||
if (!r.ok) { console.warn(`[update] manifest HTTP ${r.status}`); return false; }
|
||
const manifest = await r.json();
|
||
const rel = (manifest.releases || []).find((x) => x.id === "theseus-navigator");
|
||
if (!rel || !rel.version) return true;
|
||
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 true;
|
||
}
|
||
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 || updateDownloadState === "failed") {
|
||
// New candidate — or the same one whose download failed (truncated,
|
||
// hash mismatch, network drop), which used to stay failed until the
|
||
// next release. Reset and kick off a fresh silent background fetch so
|
||
// the chip lands as "ready to install".
|
||
updateDownloadState = "idle";
|
||
updateDownloadPath = null;
|
||
autoDownloadUpdate();
|
||
}
|
||
emitUpdateAvailable();
|
||
return true;
|
||
} catch (e) {
|
||
console.warn(`[update] check failed after ${Date.now() - t0} ms: ${e?.name || ""} ${e?.cause?.code || e?.message || e}`);
|
||
return false;
|
||
}
|
||
}
|
||
// Startup check: wait for the boot rush to pass, then retry with backoff on
|
||
// failure — a flaky network at launch must not mean "no update until the
|
||
// 6-hourly recheck".
|
||
const UPDATE_STARTUP_DELAYS_MS = [8_000, 30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];
|
||
function scheduleStartupUpdateCheck(attempt = 0) {
|
||
if (attempt >= UPDATE_STARTUP_DELAYS_MS.length) return;
|
||
setTimeout(() => {
|
||
checkForUpdate().then((ok) => { if (!ok) scheduleStartupUpdateCheck(attempt + 1); }, () => scheduleStartupUpdateCheck(attempt + 1));
|
||
}, UPDATE_STARTUP_DELAYS_MS[attempt]);
|
||
}
|
||
// 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 app pages. Compared against
|
||
// the exact file:// URL of the shipped page, so a downloaded
|
||
// file:///…/Downloads/home.html (or …/x.html#home.html) doesn't pass.
|
||
const appPageUrl = (name) => url.pathToFileURL(path.join(__dirname, name)).href.toLowerCase();
|
||
function isAppPage(sender, name) {
|
||
try { return String(sender.getURL() || "").split(/[?#]/)[0].toLowerCase() === appPageUrl(name); }
|
||
catch { return false; }
|
||
}
|
||
// Rejects third-party pages that see the API shape via the preload.
|
||
function isHomePageSender(sender) { return isAppPage(sender, "home.html"); }
|
||
// Same origin-gating pattern for the branded error page.
|
||
function isErrorPageSender(sender) { return isAppPage(sender, "error.html"); }
|
||
// settings-preload is the only bridge to these channels, but a preload belongs
|
||
// to the WebContents, not the page — so the caller is checked as well: it must
|
||
// be a Settings tab currently showing our settings.html. SETTINGS_SHARED are
|
||
// also called by the toolbar (preload.js).
|
||
const SETTINGS_ONLY = new Set([
|
||
"addon-invoke", "addons-check-updates", "addons-community-catalog", "addons-install-community",
|
||
"addons-list", "addons-open-dir", "addons-reload", "addons-remove", "addons-reveal", "addons-set-enabled",
|
||
"ariadne-get-policy", "ariadne-get-status", "ariadne-install", "ariadne-set-policy", "ariadne-set-source",
|
||
"ariadne-state", "ariadne-toggle", "ariadne-uninstall", "ariadne-update",
|
||
"clear-browsing-data", "collision-reset", "collision-set-policy",
|
||
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
|
||
"password-setup", "password-status", "password-unlock", "password-update",
|
||
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
|
||
"settings-open-panel", "settings-section", "tor-state",
|
||
]);
|
||
const SETTINGS_SHARED = new Set([
|
||
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state",
|
||
"remove-engine", "settings-get", "settings-set", "toggle-tor",
|
||
]);
|
||
function isSettingsPage(sender) {
|
||
return tabs.some((t) => t.settings && t.view?.webContents === sender) && isAppPage(sender, "settings.html");
|
||
}
|
||
{
|
||
const handle = ipcMain.handle.bind(ipcMain);
|
||
ipcMain.handle = (channel, fn) => handle(channel,
|
||
SETTINGS_ONLY.has(channel) || SETTINGS_SHARED.has(channel)
|
||
? (e, ...args) => {
|
||
const ok = isSettingsPage(e.sender) || (SETTINGS_SHARED.has(channel) && chrome && e.sender === chrome.webContents);
|
||
if (!ok) throw new Error(`${channel}: settings only`);
|
||
return fn(e, ...args);
|
||
}
|
||
: fn);
|
||
}
|
||
|
||
// ---- 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.
|
||
// Persisted (bcnr-tlds.json) so a cold start with no index yet still knows
|
||
// which TLDs belong to BCNR — that decides which names may use the quick
|
||
// lookup (see coldLookup).
|
||
let bcnrTlds = ["bch"];
|
||
let bcnrTldsLoaded = false;
|
||
const bcnrTldsFile = () => path.join(app.getPath("userData"), "bcnr-tlds.json");
|
||
function loadKnownBcnrTlds() {
|
||
if (bcnrTldsLoaded) return;
|
||
bcnrTldsLoaded = true;
|
||
try { const l = JSON.parse(fs.readFileSync(bcnrTldsFile(), "utf8")); if (Array.isArray(l) && l.length) bcnrTlds = l.map((x) => String(x).toLowerCase()); } catch {}
|
||
}
|
||
function isBcnrNativeTld(tld) { loadKnownBcnrTlds(); 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) {
|
||
bcnrTldsLoaded = true;
|
||
const changed = list.join(" ") !== bcnrTlds.join(" ");
|
||
bcnrTlds = list;
|
||
if (changed) { try { fs.writeFileSync(bcnrTldsFile(), JSON.stringify(list)); } catch {} }
|
||
}
|
||
}
|
||
} 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();
|
||
// Identity: stock-Chrome UA plus matching client hints on BOTH sides —
|
||
// the request headers and navigator.userAgentData — see chromeBrandMetadata.
|
||
try {
|
||
const meta = chromeBrandMetadata();
|
||
const lang = loc || app.getLocale() || "en-US";
|
||
await wc.debugger.sendCommand("Emulation.setUserAgentOverride", {
|
||
userAgent: stockChromeUA() || session.defaultSession.getUserAgent(),
|
||
acceptLanguage: acceptLanguageList(lang),
|
||
platform: meta.navigatorPlatform,
|
||
userAgentMetadata: {
|
||
brands: meta.brands, fullVersionList: meta.fullVersionList,
|
||
platform: meta.platform, platformVersion: meta.platformVersion,
|
||
architecture: meta.architecture, model: meta.model, mobile: meta.mobile,
|
||
bitness: meta.bitness, wow64: meta.wow64,
|
||
},
|
||
});
|
||
} catch (e) { console.warn("userAgent override failed:", e?.message); }
|
||
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){}`;
|
||
}
|
||
// Global Privacy Control's JavaScript half; the header is added in applyClientHintsSpoof.
|
||
if (settings.gpc) src += `try{Object.defineProperty(navigator,'globalPrivacyControl',{get:()=>true,configurable:true})}catch(e){}`;
|
||
// Always on: Chrome-shaped window.chrome (see CHROME_SHIM_SRC).
|
||
src += CHROME_SHIM_SRC;
|
||
if (src) {
|
||
const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src });
|
||
wc._langScript = res.identifier;
|
||
try { await wc.executeJavaScript(src); } catch {} // apply to the current page too
|
||
}
|
||
} catch { /* debugger busy (e.g. devtools) — best effort */ }
|
||
}
|
||
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
|
||
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
|
||
// cache, localStorage, IndexedDB, service workers, cache API by default.
|
||
// This wipes whichever the caller asked for. The `storages` list mirrors
|
||
// Chromium's clearStorageData taxonomy — we group them into a small user-
|
||
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
|
||
async function clearBrowsingData({ cookies = false, cache = false, storage = false } = {}) {
|
||
const ses = session.defaultSession;
|
||
if (cache) { try { await ses.clearCache(); } catch (e) { console.warn("clearCache:", e.message); } }
|
||
const storages = [];
|
||
if (cookies) storages.push("cookies");
|
||
if (storage) storages.push("localstorage", "indexdb", "serviceworkers", "cachestorage", "shadercache");
|
||
if (storages.length) {
|
||
try { await ses.clearStorageData({ storages }); }
|
||
catch (e) { console.warn("clearStorageData:", e.message); }
|
||
}
|
||
// navigation history lives in each webContents; drop it too when history-clear was asked.
|
||
// (called separately by the before-quit hook, since history-clear also deletes session.json)
|
||
}
|
||
async function clearHistoryNow() {
|
||
for (const t of tabs) {
|
||
try { t.view.webContents.navigationHistory.clear(); } catch {}
|
||
}
|
||
try { fs.unlinkSync(sessionFile()); } catch {}
|
||
// Address-bar suggestions history — wipe both in-memory + on-disk.
|
||
history = [];
|
||
clearTimeout(historySaveTimer); historySaveTimer = null;
|
||
try { fs.unlinkSync(historyFile()); } catch {}
|
||
}
|
||
// Strip Electron + Theseus tokens from the User-Agent so Cloudflare's WAF
|
||
// (and other bot heuristics) don't flag every request. Verified: sending
|
||
// Mozilla/5.0 (…) theseus-navigator/0.3.22 Chrome/… Electron/33.4.11 Safari/537.36
|
||
// to whybitcoincash.com got HTTP 503 from Cloudflare; the same request
|
||
// without the theseus + Electron tokens returns 200. Brave / Vivaldi / Slack
|
||
// (Electron) all do the same strip — a Chromium browser identifying itself
|
||
// as vanilla Chrome is standard practice in the Electron ecosystem.
|
||
// The Chrome version we claim. Normally the embedded Chromium's own; the
|
||
// env override exists to test how sites react to a different version
|
||
// (a year-old Chromium build is itself a bot signal to some filters).
|
||
const CHROME_VERSION_CLAIMED = /^\d+(\.\d+){3}$/.test(process.env.THESEUS_CHROME_VERSION || "")
|
||
? process.env.THESEUS_CHROME_VERSION : String(process.versions.chrome || "130.0.0.0");
|
||
function stockChromeUA() {
|
||
try {
|
||
return session.defaultSession.getUserAgent()
|
||
.replace(/ *theseus-navigator\/\S+/i, "")
|
||
.replace(/ *Electron\/\S+/i, "")
|
||
.replace(/Chrome\/\d+(\.\d+){3}/, "Chrome/" + CHROME_VERSION_CLAIMED);
|
||
} catch { return null; }
|
||
}
|
||
// Client-hint identity of stock Google Chrome for the Chromium we embed —
|
||
// computed the way Chromium itself does it, so it is indistinguishable from
|
||
// the real thing:
|
||
// - the GREASE brand ("Not?A_Brand";v="99" for 130) is derived from the
|
||
// major version with Chromium's character/version tables;
|
||
// - the order of the three brands is Chromium's per-major permutation
|
||
// (130 → Chromium, Google Chrome, Not?A_Brand).
|
||
// The previous hand-written list put "Google Chrome" first — a permutation
|
||
// real Chrome 130 never sends — and the page-side navigator.userAgentData
|
||
// still said "Chromium" only. DataDome ("AI Threats Detection") blocked on
|
||
// exactly that header/JS mismatch (estore.asus.com, 2026-09-20), so the same
|
||
// metadata is now also installed in every tab via Emulation.setUserAgentOverride.
|
||
// Electron hands every page an EMPTY window.chrome. Real Chrome's carries
|
||
// app / csi / loadTimes / runtime, and bot checks — Google sign-in's among
|
||
// them — look for exactly those to tell Chrome from an embedded Chromium.
|
||
// Same shapes and return types as Chrome; nothing here is reachable by the
|
||
// site beyond what a stock Chrome would also give it.
|
||
const CHROME_SHIM_SRC = `try{(()=>{const c=window.chrome;if(!c||typeof c!=="object"||Object.keys(c).length)return;
|
||
const t=()=>performance.timing;
|
||
c.app={isInstalled:false,InstallState:{DISABLED:"disabled",INSTALLED:"installed",NOT_INSTALLED:"not_installed"},RunningState:{CANNOT_RUN:"cannot_run",READY_TO_RUN:"ready_to_run",RUNNING:"running"},getDetails(){return null},getIsInstalled(){return false},runningState(){return "cannot_run"}};
|
||
c.csi=function(){const p=t();return{startE:p.navigationStart,onloadT:p.domContentLoadedEventEnd,pageT:performance.now(),tran:15}};
|
||
c.loadTimes=function(){const p=t();return{requestTime:p.navigationStart/1000,startLoadTime:p.navigationStart/1000,commitLoadTime:p.responseStart/1000,finishDocumentLoadTime:p.domContentLoadedEventEnd/1000,finishLoadTime:p.loadEventEnd/1000,firstPaintTime:p.responseEnd/1000,firstPaintAfterLoadTime:0,navigationType:"Other",wasFetchedViaSpdy:true,wasNpnNegotiated:true,npnNegotiatedProtocol:"h2",wasAlternateProtocolAvailable:false,connectionInfo:"h2"}};
|
||
c.runtime={OnInstalledReason:{CHROME_UPDATE:"chrome_update",INSTALL:"install",SHARED_MODULE_UPDATE:"shared_module_update",UPDATE:"update"},OnRestartRequiredReason:{APP_UPDATE:"app_update",OS_UPDATE:"os_update",PERIODIC:"periodic"},PlatformArch:{ARM:"arm",ARM64:"arm64",MIPS:"mips",MIPS64:"mips64",X86_32:"x86-32",X86_64:"x86-64"},PlatformNaclArch:{ARM:"arm",MIPS:"mips",MIPS64:"mips64",X86_32:"x86-32",X86_64:"x86-64"},PlatformOs:{ANDROID:"android",CROS:"cros",LINUX:"linux",MAC:"mac",OPENBSD:"openbsd",WIN:"win"},RequestUpdateCheckStatus:{NO_UPDATE:"no_update",THROTTLED:"throttled",UPDATE_AVAILABLE:"update_available"},id:undefined,connect(){throw new TypeError("chrome.runtime.connect() called from a webpage must specify an Extension ID (string) for its first argument.")},sendMessage(){throw new TypeError("chrome.runtime.sendMessage() called from a webpage must specify an Extension ID (string) for its first argument.")}};
|
||
})()}catch(e){}`;
|
||
function chromeBrandMetadata() {
|
||
const full = CHROME_VERSION_CLAIMED;
|
||
const major = parseInt(full.split(".")[0], 10) || 130;
|
||
const chars = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
|
||
const greaseBrand = "Not" + chars[major % chars.length] + "A" + chars[(major + 1) % chars.length] + "Brand";
|
||
const greaseVer = ["8", "99", "24"][major % 3];
|
||
const base = [[greaseBrand, greaseVer, greaseVer + ".0.0.0"], ["Chromium", String(major), full], ["Google Chrome", String(major), full]];
|
||
const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
|
||
const order = orders[major % 6];
|
||
const out = [];
|
||
base.forEach((b, i) => { out[order[i]] = b; });
|
||
const platform = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux";
|
||
return {
|
||
brands: out.map(([brand, version]) => ({ brand, version })),
|
||
fullVersionList: out.map(([brand, , version]) => ({ brand, version })),
|
||
headerBrands: out.map(([b, v]) => `"${b}";v="${v}"`).join(", "),
|
||
headerFullList: out.map(([b, , v]) => `"${b}";v="${v}"`).join(", "),
|
||
platform,
|
||
platformVersion: process.platform === "win32" ? "10.0.0" : process.platform === "darwin" ? "14.0.0" : "6.0.0",
|
||
navigatorPlatform: process.platform === "darwin" ? "MacIntel" : process.platform === "win32" ? "Win32" : "Linux x86_64",
|
||
architecture: "x86", bitness: "64", model: "", mobile: false, wow64: false,
|
||
};
|
||
}
|
||
// "en-US" → "en-US,en"; "de" → "de". Chromium turns the list into the header
|
||
// with its own q-values (en-US,en;q=0.9), exactly like stock Chrome.
|
||
function acceptLanguageList(loc) {
|
||
const l = String(loc || "en-US");
|
||
const base = l.split("-")[0];
|
||
return base && base !== l ? `${l},${base}` : l;
|
||
}
|
||
// Accept-Language header follows the locale setting (session-wide, best effort).
|
||
function applyAcceptLanguage() {
|
||
const loc = effLocale() || app.getLocale() || "en-US";
|
||
try {
|
||
const ua = stockChromeUA() || session.defaultSession.getUserAgent();
|
||
// A plain comma list: Chromium adds the q-values itself. Passing our own
|
||
// ";q=0.8" produced "en-US,en;q=0.8;q=0.9" on the wire — malformed, and
|
||
// one more thing that reads as "not a browser" to bot filters.
|
||
session.defaultSession.setUserAgent(ua, acceptLanguageList(loc));
|
||
} catch {}
|
||
}
|
||
// Client-hint headers (sec-ch-ua family) rewritten to look like stock Chrome.
|
||
//
|
||
// Why: Cloudflare Bot Fight Mode / Turnstile flag "UA claims Chrome but client
|
||
// hints don't confirm it" as bot. Electron's default sec-ch-ua reads
|
||
// "Chromium";v="130", "Not(A:Brand";v="99"
|
||
// — no "Google Chrome" brand (that's the closed-source Google branding
|
||
// Chromium doesn't carry). Combined with a UA already stripped of the
|
||
// Electron token, the mismatch is the fingerprint. Brave, Vivaldi and Opera
|
||
// solved this by shipping their own sec-ch-ua that INCLUDES Chrome-family
|
||
// brands so Cloudflare's allow-list catches them; whybitcoincash.com and
|
||
// other CF-fronted sites are what we run into without this.
|
||
//
|
||
// Approach: onBeforeSendHeaders across every session request. Overwrite
|
||
// sec-ch-ua and sec-ch-ua-full-version-list to a canonical stock-Chrome
|
||
// pair using Chromium's REAL major version from process.versions.chrome
|
||
// (so the story stays consistent — no version straddling to fingerprint).
|
||
// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to
|
||
// the actual OS name so a Linux user still looks like a Linux user.
|
||
// ---- DNS over HTTPS ----
|
||
// Chromium's secure DNS lives in its built-in resolver, so any DoH mode
|
||
// also turns that resolver on (as Chrome does). BCNR names never touch DNS
|
||
// (bns:// is served in-process), and with Tor on the SOCKS proxy resolves
|
||
// remotely, so neither path leaks around this.
|
||
const DOH_PROVIDERS = {
|
||
quad9: { name: "Quad9", url: "https://dns.quad9.net/dns-query" },
|
||
cloudflare: { name: "Cloudflare", url: "https://cloudflare-dns.com/dns-query" },
|
||
mullvad: { name: "Mullvad", url: "https://dns.mullvad.net/dns-query" },
|
||
adguard: { name: "AdGuard", url: "https://dns.adguard-dns.com/dns-query" },
|
||
};
|
||
function dohServerUrl() {
|
||
const p = String(settings.dohProvider || "quad9");
|
||
const url = p === "custom" ? String(settings.dohCustom || "").trim() : (DOH_PROVIDERS[p] || DOH_PROVIDERS.quad9).url;
|
||
return /^https:\/\/[^\s]+$/i.test(url) ? url : "";
|
||
}
|
||
function applyDoh() {
|
||
const mode = ["off", "automatic", "secure"].includes(settings.dohMode) ? settings.dohMode : "automatic";
|
||
const url = dohServerUrl();
|
||
const opts = mode === "off"
|
||
? { secureDnsMode: "off", secureDnsServers: [] }
|
||
: mode === "secure" && url ? { enableBuiltInResolver: true, secureDnsMode: "secure", secureDnsServers: [url] }
|
||
: { enableBuiltInResolver: true, secureDnsMode: "automatic", secureDnsServers: url ? [url] : [] };
|
||
try { app.configureHostResolver(opts); console.log(`[dns] secure DNS ${opts.secureDnsMode}${opts.secureDnsServers.length ? " via " + opts.secureDnsServers[0] : ""}`); }
|
||
catch (e) { console.warn("[dns] configureHostResolver failed:", e?.message); }
|
||
}
|
||
function applyClientHintsSpoof() {
|
||
try {
|
||
const meta = chromeBrandMetadata();
|
||
const brands = meta.headerBrands;
|
||
const fullList = meta.headerFullList;
|
||
const platform = `"${meta.platform}"`;
|
||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||
const h = details.requestHeaders || {};
|
||
// Header names as Chromium sends them are typically kebab-case-lowercase;
|
||
// rewrite lowercase and also strip any Case-variant keys Electron
|
||
// may have set so we don't double up.
|
||
for (const k of Object.keys(h)) {
|
||
const kl = k.toLowerCase();
|
||
if (kl === "sec-ch-ua" || kl === "sec-ch-ua-full-version-list" ||
|
||
kl === "sec-ch-ua-mobile" || kl === "sec-ch-ua-platform") {
|
||
delete h[k];
|
||
}
|
||
}
|
||
h["sec-ch-ua"] = brands;
|
||
h["sec-ch-ua-full-version-list"] = fullList;
|
||
h["sec-ch-ua-mobile"] = "?0";
|
||
h["sec-ch-ua-platform"] = platform;
|
||
// Global Privacy Control (read live so the switch applies at once).
|
||
for (const k of Object.keys(h)) if (k.toLowerCase() === "sec-gpc") delete h[k];
|
||
if (settings.gpc) h["Sec-GPC"] = "1";
|
||
callback({ requestHeaders: h });
|
||
});
|
||
} catch (e) { console.warn("client-hints spoof setup failed:", e?.message); }
|
||
}
|
||
// ---- session restore + background throttling ----
|
||
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
|
||
// v3 format: { v: 3, tabs: [{url, title, favicon}], active }.
|
||
// v2 was { v: 2, urls, active }; v1 was a bare array of URLs.
|
||
// Carrying title + favicon lets restoreTabs paint the full strip at launch
|
||
// without any tab having to load first — background tabs stay DORMANT (no
|
||
// loadURL, no renderer activity) and come to life only when activated.
|
||
let sessionSavedAtClose = false;
|
||
let sessionDroppedForQuit = false; // clear-history-on-quit already deleted it; the window's close must not rewrite it
|
||
function saveSession() {
|
||
if (sessionDroppedForQuit) return;
|
||
if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed
|
||
try {
|
||
const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url));
|
||
const active = Math.max(0, live.findIndex((t) => t.id === activeId));
|
||
const data = live.map((t) => {
|
||
// A still-dormant tab's URL + title + favicon live under `pending` —
|
||
// the user never activated it, so the view never paid a renderer cost.
|
||
// Save what we would have shown either way.
|
||
const url = t.pending?.url ?? t.url ?? "";
|
||
const title = t.pending?.title ?? t.title ?? "";
|
||
const favicon = t.pending?.favicon ?? t.favicon ?? null;
|
||
return { url, title, favicon };
|
||
});
|
||
fs.writeFileSync(sessionFile(), JSON.stringify({ v: 3, tabs: data, active }));
|
||
} catch (e) { console.error("session save failed:", e.message); }
|
||
}
|
||
function loadSession() {
|
||
const empty = { tabs: [], active: 0 };
|
||
try {
|
||
if (!fs.existsSync(sessionFile())) return empty;
|
||
const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8"));
|
||
// v3: {v:3, tabs:[{url,title,favicon}], active}
|
||
if (raw && raw.v === 3 && Array.isArray(raw.tabs)) {
|
||
const t = raw.tabs.filter((x) => x && typeof x.url === "string" && x.url).map((x) => ({
|
||
url: x.url, title: typeof x.title === "string" ? x.title : "", favicon: typeof x.favicon === "string" ? x.favicon : null,
|
||
}));
|
||
const active = Math.min(Math.max(Number(raw.active) || 0, 0), Math.max(t.length - 1, 0));
|
||
return { tabs: t, active };
|
||
}
|
||
// v2 / v1: just URL lists. Title + favicon arrive once the user activates the tab.
|
||
const list = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.urls) ? raw.urls : []);
|
||
const urls = list.filter((u) => typeof u === "string" && u);
|
||
const last = Math.max(urls.length - 1, 0);
|
||
const active = Array.isArray(raw) ? last : Math.min(Math.max(Number(raw.active) || 0, 0), last);
|
||
return { tabs: urls.map((url) => ({ url, title: "", favicon: null })), active };
|
||
} catch { return empty; }
|
||
}
|
||
// Session restore, FULLY LAZY: nothing navigates at launch. Every restored
|
||
// tab — including the one that was active at close — comes up as a dormant
|
||
// WebContentsView in the strip, painted from the saved title + favicon, and
|
||
// only triggers a page load when the user asks for it specifically (clicks
|
||
// the chip, hits reload, types in the URL bar). This drops the whole startup
|
||
// renderer load to zero page renderers (chrome.html + the overlays + the
|
||
// strip only), so launch is near-flat whether the session has 2 or 50 tabs
|
||
// and the RAM-at-launch footprint is just the chrome plus the strip —
|
||
// a saved heavy page doesn't come back as a 500 MB renderer the user
|
||
// didn't even ask to see. The previously active tab stays highlighted in
|
||
// the strip so the user can one-click it back; its content area sits with
|
||
// the tab's solid background until that happens.
|
||
function restoreTabs() {
|
||
const saved = settings.restoreSession ? loadSession() : { tabs: [], active: 0 };
|
||
if (!saved.tabs.length) { createTab(); return; }
|
||
for (let i = 0; i < saved.tabs.length; i++) {
|
||
try { createTab(null, { background: true, pending: saved.tabs[i] }); }
|
||
catch (e) { console.warn("session restore: tab failed:", e?.message); }
|
||
}
|
||
// Pick the saved active slot as the chip the strip highlights at launch.
|
||
// A plain setActive() would materialise it (defeating the point of fully-
|
||
// lazy restore), so apply activeId + visibility by hand — the user
|
||
// clicking that same chip is what triggers the first navigation.
|
||
const idx = Math.min(Math.max(0, saved.active | 0), tabs.length - 1);
|
||
const target = tabs[idx];
|
||
if (target) {
|
||
activeId = target.id;
|
||
try { target.view.setVisible(true); } catch {}
|
||
for (const t of tabs) if (t.id !== activeId) { try { t.view.setVisible(false); } catch {} }
|
||
}
|
||
emitTabs();
|
||
}
|
||
// ---- deferred overlay loads ----
|
||
// The floating overlays (site-info popover, engine picker, downloads,
|
||
// address suggestions, password fill, link-status pill, add-on approval,
|
||
// page dialogs) are WebContentsViews created with the window. A view whose
|
||
// page was never loaded has no renderer process; loading its page starts
|
||
// one (~20–30 MB each). They used to load all together shortly after the
|
||
// toolbar — nine renderers most sessions never use. Now each one loads on
|
||
// its first use, except the few used in nearly every session (address
|
||
// suggestions, the link-status pill, site info), which are prewarmed one at a
|
||
// time once the first page is up (prewarmOverlays).
|
||
const overlayLoads = []; // [{ view, file }] not loaded yet
|
||
function deferOverlayLoad(view, file) { overlayLoads.push({ view, file }); }
|
||
function loadOverlay(view) {
|
||
const i = overlayLoads.findIndex((o) => o.view === view);
|
||
if (i < 0) return;
|
||
const { file } = overlayLoads.splice(i, 1)[0];
|
||
view.webContents.once("did-finish-load", () => overlayDone.add(view));
|
||
try { view.webContents.loadFile(file); } catch (e) { console.warn(`overlay load failed (${file}):`, e?.message); }
|
||
}
|
||
// Pages whose load has finished. Recorded from did-finish-load because
|
||
// isLoading() still reports true while that event is being delivered — a
|
||
// show deferred to it would then defer again and wait out overlayReady's
|
||
// timeout before appearing.
|
||
const overlayDone = new WeakSet();
|
||
function overlayLoaded(view) {
|
||
if (overlayDone.has(view)) return true;
|
||
try { return !!view.webContents.getURL() && !view.webContents.isLoading(); } catch { return false; }
|
||
}
|
||
// The show functions send their data right after showing, which a page still
|
||
// loading would drop. On first use: load the page, then run `again` (the
|
||
// same show call) once it is ready — unless the overlay was closed, or shown
|
||
// again, in the meantime (the hide paths call cancelOverlayShow).
|
||
const overlayWant = new Map(); // view -> token of the latest deferred show
|
||
function deferUntilOverlayLoaded(view, again) {
|
||
if (overlayLoaded(view)) return false;
|
||
const token = {};
|
||
overlayWant.set(view, token);
|
||
overlayReady(view).then(() => { if (overlayWant.get(view) === token) { overlayWant.delete(view); again(); } });
|
||
return true;
|
||
}
|
||
function cancelOverlayShow(view) { overlayWant.delete(view); }
|
||
// Click-away for the toolbar popups (downloads, site info, engine picker):
|
||
// shown, they take focus; when focus moves anywhere else — a tab, the
|
||
// toolbar, another app — they close. The click on their own toolbar button
|
||
// is one of those focus moves, so a toggle right after a click-away close is
|
||
// that same click and must not reopen the popup.
|
||
const clickAwayClosedAt = new Map(); // view -> time it was closed by losing focus
|
||
const clickAwayPopups = []; // [{ isOpen, hide }] — also closed when the window loses focus
|
||
function closeOnClickAway(view, isOpen, hide) {
|
||
clickAwayPopups.push({ isOpen, hide });
|
||
view.webContents.on("blur", () => {
|
||
// Only a focus move inside an active window is a click elsewhere in
|
||
// Theseus. A popup shown while another app is in front gets focus and
|
||
// blur together, and must not close the moment it opens; switching to
|
||
// another app is handled by the window's own blur (closePopupsOnWindowBlur).
|
||
// The show functions only move focus into a popup while the window is
|
||
// active: focusing it from the background makes Windows touch the window,
|
||
// whose blur would close the popup it just opened.
|
||
if (!isOpen() || !win || win.isDestroyed() || !win.isFocused()) return;
|
||
clickAwayClosedAt.set(view, Date.now());
|
||
hide();
|
||
});
|
||
}
|
||
function closePopupsOnWindowBlur() {
|
||
for (const p of clickAwayPopups) { try { if (p.isOpen()) p.hide(); } catch {} }
|
||
}
|
||
const justClosedByClickAway = (view) => Date.now() - (clickAwayClosedAt.get(view) || 0) < 400;
|
||
// Idle prewarm, one at a time, of the overlays used in nearly every session.
|
||
let overlaysPrewarmed = false;
|
||
async function prewarmOverlays() {
|
||
if (overlaysPrewarmed) return;
|
||
overlaysPrewarmed = true;
|
||
for (const view of [addressPicker, linkStatus, popover]) {
|
||
if (!view || !winAlive()) return;
|
||
if (overlayLoaded(view)) continue;
|
||
await overlayReady(view);
|
||
await new Promise((r) => setTimeout(r, 150));
|
||
}
|
||
}
|
||
// Resolves once `view` has finished loading its page, loading it first if
|
||
// that hasn't happened yet. Bounded so a wedged renderer can't hang the
|
||
// caller forever.
|
||
function overlayReady(view, timeoutMs = 4000) {
|
||
loadOverlay(view);
|
||
const wc = view.webContents;
|
||
if (overlayLoaded(view)) return Promise.resolve();
|
||
return new Promise((resolve) => {
|
||
const timer = setTimeout(done, timeoutMs);
|
||
function done() { clearTimeout(timer); wc.removeListener("did-finish-load", done); resolve(); }
|
||
wc.on("did-finish-load", done);
|
||
});
|
||
}
|
||
// Post-paint boot. Runs once chrome.html has loaded (createWindow arms a
|
||
// fallback timer in case it never does). Everything here used to start in
|
||
// whenReady, before the toolbar had painted — the BNS index build, three
|
||
// network fetches, every restored tab and seven overlay renderers all piled
|
||
// onto the main thread in the same ~300 ms, and the window sat blank with
|
||
// a white toolbar strip until they drained.
|
||
let chromeReadyDone = false;
|
||
function onChromeReady() {
|
||
if (chromeReadyDone) return;
|
||
chromeReadyDone = true;
|
||
if (addonHost) addonHost.signalUiReady();
|
||
// Tabs don't wait for the BNS index: restored tabs are dormant, and a tab
|
||
// that does navigate to a name waits in resolveHost for the indexer's
|
||
// snapshot (its first, network-free phase — normally already done).
|
||
try { restoreTabs(); }
|
||
catch (e) { console.error("restoreTabs failed:", e?.message); try { createTab(); } catch {} }
|
||
for (const u of pendingTabUrls.splice(0)) { try { createTab(u); } catch {} }
|
||
// The indexer's network phase (electrum sync, Sia refresh, polling) once
|
||
// the first page is up — or after 4 s, whichever comes first.
|
||
{
|
||
let went = false;
|
||
const go = () => { if (!went) { went = true; startBnsPolling(); setTimeout(prewarmOverlays, 1000); } };
|
||
setTimeout(go, 4000);
|
||
try { activeTab()?.view.webContents.once("did-stop-loading", () => setTimeout(go, 500)); } catch {}
|
||
}
|
||
// Re-emit any pending update notice — harmless if nothing is pending.
|
||
emitUpdateAvailable();
|
||
scheduleStartupUpdateCheck();
|
||
refreshRemoteHomeCards().catch(() => {});
|
||
}
|
||
function applyThrottle() {
|
||
for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} }
|
||
}
|
||
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
|
||
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
|
||
// media-device labels/ids from enumerateDevices. Handlers read settings live.
|
||
// Device permissions with no legitimate need here — always denied.
|
||
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
|
||
// Silent clipboard reads (seeds, WIFs, copied vault passwords live there), idle
|
||
// detection and multi-screen layout have no place being auto-granted either.
|
||
const DENIED_PERMISSIONS = new Set(["clipboard-read", "idle-detection", "window-management"]);
|
||
// A page navigating to an unknown scheme (search-ms:, ms-msdt:, …) asks for
|
||
// "openExternal"; hand it to the OS only after the user says so.
|
||
async function confirmOpenExternal(wc, externalURL) {
|
||
let scheme = "";
|
||
try { scheme = new URL(externalURL).protocol; } catch { return false; }
|
||
if (scheme === "mailto:" || scheme === "tel:") return true;
|
||
const parent = BrowserWindow.fromWebContents(wc) || win;
|
||
const r = await dialog.showMessageBox(parent, {
|
||
type: "question", buttons: ["Open", "Cancel"], defaultId: 1, cancelId: 1, noLink: true,
|
||
message: "Open an external application?",
|
||
detail: `This page wants to open:\n${String(externalURL).slice(0, 300)}`,
|
||
}).catch(() => ({ response: 1 }));
|
||
return r.response === 0;
|
||
}
|
||
// 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) || DENIED_PERMISSIONS.has(permission)) return callback(false);
|
||
if (permission === "openExternal") { confirmOpenExternal(_wc, details?.externalURL).then(callback, () => callback(false)); return; }
|
||
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) || DENIED_PERMISSIONS.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 } },
|
||
]);
|
||
|
||
// True only when we can say for sure, without waiting, that `host` has no
|
||
// BCNR registration: the resolver is loaded, an index exists and the name is
|
||
// in neither it nor the resolved-entries cache.
|
||
function knownUnregistered(host) {
|
||
if (!resolver || !sharedIndex) return false;
|
||
const h = String(host || "").toLowerCase();
|
||
if (entries.has(h)) return false;
|
||
let key; try { key = resolver.normalizeName(h); } catch { return false; }
|
||
return sharedIndex.get(key) == null;
|
||
}
|
||
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(); });
|
||
// A missing/quarantined tor.exe emits "error" and never "exit" — without this
|
||
// torProc stays set and startTor() is a no-op until restart.
|
||
torProc.on("error", (e) => { console.warn("tor spawn failed:", e?.message); torProc = null; 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}` });
|
||
indexerTor();
|
||
applyWebRTCPolicy();
|
||
sendTor();
|
||
}
|
||
// The session proxy has two owners: Tor and an add-on (VPN). Tor wins while
|
||
// it is on; the add-on's rules are remembered and come back when Tor goes
|
||
// off, instead of the two silently wiping each other's settings.
|
||
let addonProxyOpts = null;
|
||
function torOff() {
|
||
torState = "off"; torWsAgent = null;
|
||
session.defaultSession.setProxy(addonProxyOpts || { proxyRules: "" });
|
||
indexerTor();
|
||
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"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
|
||
res.on("error", reject);
|
||
});
|
||
// An upstream that accepts and never answers would leave the tab spinning.
|
||
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
|
||
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"), location: r.headers.get("location"), 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"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
|
||
res.on("error", reject);
|
||
});
|
||
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
|
||
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; }
|
||
}
|
||
|
||
// ---- BNS index: mirror of the indexer process --------------------------------
|
||
// The index itself — snapshot warm-start, electrum delta poll, Sia snapshot
|
||
// refresh, electrum server discovery — runs in bns-indexer.js, a separate
|
||
// process (utilityProcess), so none of it competes with the browser's main
|
||
// thread. It boots in two phases: the local snapshot first (no network — what
|
||
// the first tab needs), then the network work once this side says "go", after
|
||
// the first page has loaded. This side keeps a read-only mirror of the name
|
||
// map for the lookups that must answer synchronously (knownUnregistered, the
|
||
// native-TLD set, error-page suggestions, Hermes reverse lookups).
|
||
|
||
// host -> { entry, host, gen }. `gen` is the indexGen of the mirror the entry
|
||
// came from; serveBns re-resolves when the mirror has changed since, so an
|
||
// edited ip/s3/tls record, a transfer or an expiry shows up without a restart.
|
||
const entries = new Map();
|
||
let sharedIndex = null; // Map name -> entry, mirrored from the indexer
|
||
let indexBuiltAt = 0; // when the indexer last confirmed it is current (0 = snapshot only)
|
||
let indexGen = 0; // bumps whenever the mirror's content changes
|
||
const SNAPSHOT_BUNDLED = path.join(RES_DIR, "bns-name-snapshot.json");
|
||
let indexer = null, indexerStarts = 0, indexerSeq = 0, indexerGo = false, indexerStopping = false;
|
||
const indexerPending = new Map();
|
||
const indexWaiters = [];
|
||
function startIndexer() {
|
||
if (indexer || indexerStopping) return;
|
||
indexerStarts++;
|
||
try {
|
||
indexer = utilityProcess.fork(path.join(__dirname, "bns-indexer.js"), [], { serviceName: "Theseus BNS indexer", stdio: "pipe" });
|
||
} catch (e) { console.warn("[bns] indexer failed to start:", e?.message); indexer = null; return; }
|
||
indexer.stdout?.on("data", (d) => { try { process.stdout.write(d); } catch {} });
|
||
indexer.stderr?.on("data", (d) => { try { process.stderr.write(d); } catch {} });
|
||
indexer.on("message", onIndexerMessage);
|
||
indexer.once("exit", (code) => {
|
||
indexer = null;
|
||
for (const settle of indexerPending.values()) settle(false);
|
||
indexerPending.clear();
|
||
if (indexerStopping) return;
|
||
// The mirror stays usable meanwhile; only freshness is lost. Logged when
|
||
// the restart happens, not here: on app.exit() the child goes down with
|
||
// the app and this timer never fires — nothing to report then.
|
||
const wait = Math.min(30_000, 1000 * 2 ** Math.min(indexerStarts, 5));
|
||
setTimeout(() => {
|
||
if (indexerStopping || indexer) return;
|
||
console.warn(`[bns] indexer exited (${code}); restarting after ${wait / 1000}s`);
|
||
startIndexer();
|
||
}, wait);
|
||
});
|
||
indexer.postMessage({ type: "init", resolverPath: RESOLVER, userData: app.getPath("userData"), bundledSnapshot: SNAPSHOT_BUNDLED, torPort: torState === "on" ? TOR_PORT : null });
|
||
if (indexerGo) indexer.postMessage({ type: "go" });
|
||
}
|
||
function onIndexerMessage(m) {
|
||
if (!m) return;
|
||
if (m.type === "index") {
|
||
sharedIndex = new Map(m.names);
|
||
indexBuiltAt = m.builtAt;
|
||
indexGen++;
|
||
refreshBcnrTlds(sharedIndex);
|
||
for (const w of indexWaiters.splice(0)) w(sharedIndex);
|
||
verifyQuickAnswers();
|
||
} else if (m.type === "fresh") {
|
||
indexBuiltAt = m.builtAt;
|
||
} else if (m.type === "reply") {
|
||
const settle = indexerPending.get(m.id);
|
||
if (settle) { indexerPending.delete(m.id); settle(!!m.ok); }
|
||
}
|
||
}
|
||
function indexerRequest(type, timeoutMs) {
|
||
return new Promise((resolve) => {
|
||
if (!indexer) startIndexer();
|
||
if (!indexer) return resolve(false);
|
||
const id = ++indexerSeq;
|
||
const timer = setTimeout(() => { indexerPending.delete(id); resolve(false); }, timeoutMs);
|
||
indexerPending.set(id, (ok) => { clearTimeout(timer); resolve(ok); });
|
||
indexer.postMessage({ id, type });
|
||
});
|
||
}
|
||
// Phase 2 (electrum sync, Sia refresh, 30 s polling) — after the first page.
|
||
function startBnsPolling() { indexerGo = true; try { indexer?.postMessage({ type: "go" }); } catch {} }
|
||
function stopBnsPolling() { indexerStopping = true; try { indexer?.postMessage({ type: "stop-polling" }); } catch {} }
|
||
function indexerTor() { try { indexer?.postMessage({ type: "tor", port: torState === "on" ? TOR_PORT : null }); } catch {} }
|
||
function waitForIndex(timeoutMs) {
|
||
if (sharedIndex) return Promise.resolve(sharedIndex);
|
||
return new Promise((resolve) => {
|
||
const timer = setTimeout(() => resolve(sharedIndex), timeoutMs);
|
||
indexWaiters.push((idx) => { clearTimeout(timer); resolve(idx); });
|
||
});
|
||
}
|
||
// One live delta poll, on demand (callers bound the wait).
|
||
const pollAndMerge = () => indexerRequest("poll", 50_000);
|
||
// Any index at all — normally the snapshot, a few hundred ms after launch;
|
||
// with no snapshot, the first live poll. `force` asks for a full rebuild.
|
||
async function ensureIndex(force = false) {
|
||
if (force) await indexerRequest("rebuild", 120_000);
|
||
else if (!sharedIndex) { indexerRequest("ready", 120_000); await waitForIndex(120_000); }
|
||
if (!sharedIndex) throw new Error("BNS index unavailable");
|
||
return sharedIndex;
|
||
}
|
||
// ---- quick lane: a cold start with no local index yet --------------------
|
||
// Normally the index is there within milliseconds of launch (the local
|
||
// snapshot). When it isn't — first start, the copy deleted or moved — a name
|
||
// does not wait for the full download: one lookup of just that name on the
|
||
// gateway answers it (~0.2 s), the site opens, and the index arrives in the
|
||
// background (published snapshot, then the electrum check in the indexer).
|
||
// When it lands, every quick answer is compared with the verified index; a
|
||
// mismatch reloads the tabs showing that host from the verified record.
|
||
// Only names under a known BCNR TLD take this lane: an ordinary web host is
|
||
// never sent to the gateway — it waits briefly for the index, then goes to
|
||
// the web as before. Anything that must not act on an unverified answer
|
||
// (the extension-publisher check) passes { verified: true }.
|
||
const quickAnswers = new Map(); // host -> provisional entry
|
||
async function quickLookup(key) {
|
||
try {
|
||
const up = await Promise.race([
|
||
contentFetch(`${GATEWAY}/api/name/${encodeURIComponent(key)}`, {}),
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 2500)),
|
||
]);
|
||
if (up.status !== 200) return undefined;
|
||
const j = JSON.parse(up.buffer.toString("utf8"));
|
||
if (!j || j.name !== key) return undefined;
|
||
if (!j.registered || !j.records) return null;
|
||
return { name: key, records: j.records, owner: j.owner || null, category: j.category || null, provisional: true };
|
||
} catch { return undefined; } // undefined = no answer; null = not registered
|
||
}
|
||
// Resolves to an entry / null from the quick lane, or undefined to use the index.
|
||
async function coldLookup(key) {
|
||
const indexP = waitForIndex(3000);
|
||
if (!isBcnrNativeTld(key.split(".").pop())) { await indexP; return undefined; }
|
||
const quickP = quickLookup(key);
|
||
const first = await Promise.race([indexP.then(() => "index"), quickP.then(() => "quick")]);
|
||
if (first === "index" && sharedIndex) return undefined;
|
||
const quick = await quickP;
|
||
if (quick === undefined) { await indexP; return undefined; }
|
||
return quick;
|
||
}
|
||
function verifyQuickAnswers() {
|
||
if (!quickAnswers.size || !resolver || !sharedIndex) return;
|
||
for (const [h, q] of quickAnswers) {
|
||
let v = null; try { v = sharedIndex.get(resolver.normalizeName(h)) ?? null; } catch {}
|
||
const same = v && JSON.stringify(v.records) === JSON.stringify(q.records) && (v.owner || null) === (q.owner || null);
|
||
if (same) continue;
|
||
console.warn(`[bns] quick lookup for ${h} differs from the verified index — reloading its tabs`);
|
||
for (const t of tabs) {
|
||
let th = ""; try { th = new URL(t.view.webContents.getURL()).hostname.toLowerCase(); } catch {}
|
||
if (th === h && t.url) navigateTab(t.id, t.url);
|
||
}
|
||
}
|
||
quickAnswers.clear();
|
||
}
|
||
async function resolveHost(host, { verified = false } = {}) {
|
||
const { normalizeName } = await getResolver();
|
||
let key; try { key = normalizeName(host); } catch { return null; }
|
||
const h = host.toLowerCase();
|
||
if (!sharedIndex) {
|
||
indexerRequest("ready", 120_000); // local copy -> published snapshot -> electrum, in the background
|
||
if (verified) await waitForIndex(120_000);
|
||
else {
|
||
const quick = await coldLookup(key);
|
||
if (quick !== undefined) {
|
||
if (quick) { quickAnswers.set(h, quick); entries.set(h, { entry: quick, host: h, gen: -1 }); attachDnsRecords(quick); }
|
||
else entries.delete(h);
|
||
return quick;
|
||
}
|
||
}
|
||
}
|
||
// No index after all that (offline, CDN and electrum unreachable): BCNR is
|
||
// unreachable, so the host goes to the web, as documented above.
|
||
const idx = sharedIndex; if (!idx) return null;
|
||
let entry = idx.get(key) ?? null;
|
||
// Miss on a possibly-stale index → one delta poll in the indexer (1 history
|
||
// call + only-new-tx bodies), allowed even before its network phase has
|
||
// started: that is the "this tab's name isn't in the snapshot" case. Every
|
||
// dotted host the web uses lands here on a miss, so the wait is bounded —
|
||
// with electrum unreachable an ordinary website must not sit behind a TCP
|
||
// timeout per server; the poll carries on and the next lookup sees it.
|
||
if (!entry && Date.now() - indexBuiltAt > 8_000) {
|
||
await Promise.race([pollAndMerge(), new Promise((r) => setTimeout(r, 2500))]);
|
||
entry = sharedIndex?.get(key) ?? null;
|
||
}
|
||
if (entry) entries.set(h, { entry, host: h, gen: indexGen });
|
||
else entries.delete(h); // no longer registered — stop serving the old record
|
||
// Signed DNS records ride alongside the on-chain answer — started here,
|
||
// never awaited (see attachDnsRecords).
|
||
if (entry) attachDnsRecords(entry);
|
||
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";
|
||
// Response() throws on a body with a null-body status, which turned an
|
||
// upstream 204/304 into the 502 page.
|
||
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
|
||
function upstreamResponse(up, contentType) {
|
||
const headers = { "content-type": contentType };
|
||
if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location;
|
||
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
|
||
}
|
||
|
||
async function serveBns(request) {
|
||
const url = new URL(request.url);
|
||
const host = url.hostname.toLowerCase();
|
||
// Upstream requests carry the path exactly as the URL has it (percent-
|
||
// encoded). Decoding first broke file names with spaces/non-Latin-1 on `ip`
|
||
// records, turned %23/%3F into #/?, and let `..%2F` climb out of the
|
||
// name's own bucket on the gateway (bns://a.bch/..%2Fb.bch%2Fx). The
|
||
// decoded form is only used for MIME guessing.
|
||
const rawPath = url.pathname || "/";
|
||
let reqPath; try { reqPath = decodeURIComponent(rawPath); } catch { reqPath = rawPath; }
|
||
|
||
// (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);
|
||
// A lookup that throws (resolver unavailable) keeps the last good record;
|
||
// one that answers "not registered" has already evicted it.
|
||
if (!rec || (rec.gen !== indexGen && !(rec.entry.provisional && !sharedIndex))) { try { await resolveHost(host); rec = entries.get(host); } catch {} }
|
||
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, rawPath + url.search, host, r.tls);
|
||
return upstreamResponse(up, up.contentType || guessType(reqPath));
|
||
};
|
||
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
|
||
// on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and
|
||
// sends `Host:` of the upstream so vhost-based origins answer correctly.
|
||
// Not applied under subdomain inheritance — see public-gateway.mjs and the
|
||
// matching test in record-picker.test.mjs for why. Preserve any path prefix
|
||
// in r.p (e.g. `{p:"https://api.host/v1"}` + request "/x" → ".../v1/x").
|
||
const serveP = async () => {
|
||
const base = new URL(r.p);
|
||
const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
|
||
const target = base.origin + prefix + rawPath + url.search;
|
||
// Forward method + headers + body: a p-record is a reverse-proxy, so an
|
||
// API behind it (POST /translate, PUT, …) needs the live request as the
|
||
// caller sent it, not a bare GET. Hop-by-hop and host-rewriting headers
|
||
// are stripped — the upstream is a different origin and sees its own
|
||
// Host.
|
||
const method = request.method || "GET";
|
||
const headers = {};
|
||
const SKIP = new Set(["host", "connection", "content-length", "transfer-encoding", "accept-encoding"]);
|
||
try {
|
||
for (const [k, v] of request.headers.entries()) {
|
||
if (!SKIP.has(k.toLowerCase())) headers[k] = v;
|
||
}
|
||
} catch {}
|
||
const init = { method, headers, redirect: "manual" };
|
||
if (method !== "GET" && method !== "HEAD" && request.body) {
|
||
try { init.body = Buffer.from(await request.arrayBuffer()); } catch {}
|
||
}
|
||
// contentFetch so Tor covers this too (a plain fetch leaked the real IP).
|
||
const up = await contentFetch(target, init);
|
||
return upstreamResponse(up, up.contentType || guessType(reqPath));
|
||
};
|
||
try {
|
||
// A subdomain the owner has ruled on: blocked, or sent elsewhere. The
|
||
// gateway applies this for anything it serves, so this only covers the
|
||
// paths Theseus takes on its own — `ip` and inline `h`.
|
||
if (isSubdomain && (r.ip || r.h)) {
|
||
const act = await fetchHostAction(host);
|
||
if (act?.kind === "block") return new Response("not found", { status: 404 });
|
||
if (act?.kind === "redirect" && act.url) return Response.redirect(act.url, 302);
|
||
}
|
||
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}${rawPath}${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 upstreamResponse({ ...up, buffer: body }, ct);
|
||
}
|
||
if (r.ip) return await serveIp();
|
||
if (!isSubdomain && r.p) return await serveP();
|
||
if (r.u) return Response.redirect(r.u, 302);
|
||
// No on-chain content record. If the owner published a signed DNS A
|
||
// record, that server is the only way to reach the name — same Host-header
|
||
// semantics as an `ip` record (and the on-chain `tls` pin still applies).
|
||
const dnsIp = await dnsAddressFor(rec.entry);
|
||
if (dnsIp) {
|
||
const up = await ipRequest(dnsIp, rawPath + url.search, host, r.tls);
|
||
return upstreamResponse(up, up.contentType || guessType(reqPath));
|
||
}
|
||
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
|
||
} catch (e) {
|
||
// The upstream fetch failed — relay unreachable, DNS stalling, site's
|
||
// own server down. A bare "fetch failed" reads as "Theseus is broken";
|
||
// name the upstream and the cause code so it reads as what it is,
|
||
// and give a retry.
|
||
const cause = e?.cause?.code || e?.cause?.message || "";
|
||
const upstream = r.s3 ? new URL(GATEWAY).host : r.p ? (() => { try { return new URL(r.p).host; } catch { return r.p; } })() : r.ip ? String(r.ip) : "";
|
||
return new Response(bnsErrorHtml({ host, message: e?.message || String(e), cause, upstream }),
|
||
{ status: 502, headers: { "content-type": "text/html; charset=utf-8" } });
|
||
}
|
||
}
|
||
// Error surface for a bns:// fetch that failed after the name resolved.
|
||
// Self-contained HTML (this is a protocol handler response, not a tab
|
||
// navigation, so error.html's loadFile path doesn't apply).
|
||
function bnsErrorHtml({ host, message, cause, upstream }) {
|
||
const esc = (s) => String(s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
const hint = /ETIMEDOUT|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/.test(cause)
|
||
? `Theseus resolved <b>${esc(host)}</b> but could not reach <b>${esc(upstream || "its server")}</b> from this network. Check your connection or VPN and try again.`
|
||
: /ENOTFOUND|EAI_AGAIN/.test(cause)
|
||
? `Your system DNS could not look up <b>${esc(upstream || "the server")}</b>. A stale or unreachable DNS server on one of your network adapters is the usual cause.`
|
||
: `Theseus resolved <b>${esc(host)}</b> but the content fetch from <b>${esc(upstream || "its server")}</b> failed.`;
|
||
return `<!doctype html><html><head><meta charset="utf-8"><title>Can’t reach ${esc(host)}</title>
|
||
<style>:root{color-scheme:dark}body{margin:0;background:#0f1420;color:#e8ecf3;font:15px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh}
|
||
.card{max-width:560px;padding:32px 36px;background:#1b2330;border:1px solid #ffffff1f;border-radius:14px}h1{font-size:20px;margin:0 0 10px}p{margin:8px 0;color:#b9c2d0}
|
||
code{font:12.5px ui-monospace,monospace;color:#D6FF3D;background:#0f1420;padding:2px 6px;border-radius:5px}
|
||
a.btn{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:999px;background:#D6FF3D;color:#0f1420;font-weight:600;text-decoration:none}</style></head>
|
||
<body><div class="card"><h1>Can’t reach ${esc(host)}</h1><p>${hint}</p>
|
||
<p><code>${esc(message)}${cause ? " · " + esc(cause) : ""}</code></p>
|
||
<a class="btn" href="javascript:location.reload()">Try again</a></div></body></html>`;
|
||
}
|
||
|
||
// ---- window + tabs ----
|
||
let win, chrome;
|
||
// The window can be gone while tabs and app windows still fire events.
|
||
const winAlive = () => !!win && !win.isDestroyed();
|
||
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;
|
||
let quicklinks; // left-edge quick-links strip (Opera-style)
|
||
const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles
|
||
let quickPanel; // the mini dedicated view for the active quick-link
|
||
const QUICK_PANEL_W = 380; // Opera's sidebar panel sits around this width
|
||
let activeQuickLinkId = null; // id of the quick-link whose panel is open, or null
|
||
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
|
||
// The value below is the default; settings.sidebarWidth overrides it once
|
||
// loadSettings() runs and persists any drag adjustment made by the user.
|
||
const SIDEBAR_W_MIN = 200, SIDEBAR_W_MAX = 800, SIDEBAR_W_DEFAULT = 340;
|
||
let sidebarW = SIDEBAR_W_DEFAULT;
|
||
// When a panel asks for "maximize" (screenshot editor wanting the full canvas
|
||
// area), we widen the sidebar to fill the window and remember the previous
|
||
// width so restore drops us back exactly. Non-persisted: closing/reopening
|
||
// Theseus always starts un-maximized.
|
||
let sidebarMaximized = false;
|
||
let sidebarPreMaxW = SIDEBAR_W_DEFAULT;
|
||
function sidebarMaxWidth() {
|
||
if (!win) return SIDEBAR_W_MAX;
|
||
const { width } = win.getContentBounds();
|
||
return Math.max(SIDEBAR_W_MIN, width);
|
||
}
|
||
// The add-on host is the single point of truth for what's installed and
|
||
// active. Populated by initAddons() at app-ready time.
|
||
let addonHost = null;
|
||
// Proxy credentials supplied by an add-on via setSessionProxy, answered from
|
||
// app#login (Session has no "login" event). Replaced on every setSessionProxy
|
||
// call so the current credentials always match the current proxy.
|
||
let proxyAuth = null;
|
||
const { AddonHost } = require("./addons-host.js");
|
||
const { storeFor, flushAll: flushAddonStores } = require("./lib/addon-store.cjs");
|
||
const addonUpdater = require("./addon-updater.js");
|
||
const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js");
|
||
// Extensions live under <userData>\extensions (installed copies), with their
|
||
// per-extension storage, backups of replaced copies and staged updates in
|
||
// sibling folders. These were addons / addons-data / addons-backups /
|
||
// addons-updates-staged; migrateExtensionDirs() renames a profile's old
|
||
// folders once, before the host first reads them.
|
||
function addonsUserDir() { return path.join(app.getPath("userData"), "extensions"); }
|
||
function addonsDataDir() { return path.join(app.getPath("userData"), "extensions-data"); }
|
||
function addonsBackupDir() { return path.join(app.getPath("userData"), "extensions-backups"); }
|
||
function addonsStagedDir() { return path.join(app.getPath("userData"), "extensions-staged"); }
|
||
function migrateExtensionDirs() {
|
||
const ud = app.getPath("userData");
|
||
for (const [from, to] of [["addons", "extensions"], ["addons-data", "extensions-data"], ["addons-backups", "extensions-backups"], ["addons-updates-staged", "extensions-staged"]]) {
|
||
const src = path.join(ud, from), dst = path.join(ud, to);
|
||
if (!fs.existsSync(src) || fs.existsSync(dst)) continue;
|
||
try { fs.renameSync(src, dst); console.log(`[addons] moved ${from} -> ${to}`); }
|
||
catch (e) { console.warn(`[addons] could not move ${from} -> ${to}:`, e?.message); }
|
||
}
|
||
}
|
||
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
|
||
// Copy bundled reference add-ons (shipped inside resources/) into the user's
|
||
// addons directory. Users can then edit, disable, or delete them — the
|
||
// framework treats bundled and user add-ons identically, no special path
|
||
// handling.
|
||
//
|
||
// Update rule: reseed when the bundled addon.json version differs from the
|
||
// user's on-disk addon.json version. Before reseeding, rename the user copy
|
||
// to <id>-<oldver>-<stamp>/ under <userData>/addons-backups/ so any local
|
||
// edits survive. If the two versions match we leave the folder alone —
|
||
// users who fork by bumping their own version stay pinned; users who edit
|
||
// files without bumping accept upstream updates.
|
||
function readAddonVersion(dir) {
|
||
try { return JSON.parse(fs.readFileSync(path.join(dir, "addon.json"), "utf8"))?.version ?? null; }
|
||
catch { return null; }
|
||
}
|
||
// One-time migration for the bchwallet → aegis rename + the siawallet
|
||
// retirement. Idempotent: after the first run the sources are gone and
|
||
// subsequent runs are no-ops.
|
||
//
|
||
// - addons/bchwallet/ → addons-backups/bchwallet-migrated-<stamp>/
|
||
// (bundle folder is now aegis/; leaving the old dir active would load
|
||
// the pre-rename copy as a second addon under the same id and clash).
|
||
// - addons-data/bchwallet.json → addons-data/aegis.json (COPY, so any
|
||
// downgrade to a 0.3.x build can still read its own storage).
|
||
// - addons/siawallet/ → addons-backups/siawallet-migrated-<stamp>/
|
||
// (folded into Aegis via absorbs: ["siawallet"]; keeping it running
|
||
// would show duplicate Sia UI). Its data file stays under
|
||
// addons-data/ untouched — Aegis derives its own SC walletdUrl
|
||
// per-sub-wallet, so users re-paste their URL in Aegis Settings.
|
||
function migrateAegisRename() {
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
const backups = addonsBackupDir();
|
||
try { fs.mkdirSync(backups, { recursive: true }); } catch {}
|
||
const addonsRoot = addonsUserDir();
|
||
const dataRoot = addonsDataDir();
|
||
|
||
const bchDir = path.join(addonsRoot, "bchwallet");
|
||
const aegisDir = path.join(addonsRoot, "aegis");
|
||
const bchJson = path.join(dataRoot, "bchwallet.json");
|
||
const aegisJson = path.join(dataRoot, "aegis.json");
|
||
// Copy legacy storage into the new file exactly once, only when the new
|
||
// file doesn't exist yet (a downgrade to 0.3.x would otherwise clobber
|
||
// fresh state written by 0.4+).
|
||
if (fs.existsSync(bchJson) && !fs.existsSync(aegisJson)) {
|
||
try { fs.copyFileSync(bchJson, aegisJson); console.log("[addons] migrated bchwallet.json -> aegis.json"); }
|
||
catch (err) { console.warn("[addons] migrate storage failed:", err?.message); }
|
||
}
|
||
// Retire the bchwallet folder EVERY LAUNCH it exists. The signed OTA
|
||
// update endpoint may still advertise `id=bchwallet` updates, and
|
||
// promoteStagedUpdates (which runs before us) could reinstall it. Left
|
||
// active it would load as a second Aegis under a stale id, doubling
|
||
// every wallet in the sidebar.
|
||
if (fs.existsSync(bchDir)) {
|
||
const backup = path.join(backups, `bchwallet-migrated-${stamp}`);
|
||
try { fs.renameSync(bchDir, backup); console.log(`[addons] retired addons/bchwallet -> addons-backups/${path.basename(backup)}${fs.existsSync(aegisDir) ? " (aegis already present)" : " (folder renamed to aegis/)"}`); }
|
||
catch (err) { console.warn("[addons] retire bchwallet failed:", err?.message); }
|
||
}
|
||
|
||
const siaDir = path.join(addonsRoot, "siawallet");
|
||
if (fs.existsSync(siaDir)) {
|
||
const backup = path.join(backups, `siawallet-migrated-${stamp}`);
|
||
try { fs.renameSync(siaDir, backup); console.log(`[addons] retired addons/siawallet -> addons-backups/${path.basename(backup)} (absorbed by aegis)`); }
|
||
catch (err) { console.warn("[addons] retire siawallet failed:", err?.message); }
|
||
}
|
||
}
|
||
function seedBundledAddons() {
|
||
const dst = addonsUserDir();
|
||
try { fs.mkdirSync(dst, { recursive: true }); } catch {}
|
||
const src = bundledAddonsDir();
|
||
if (!fs.existsSync(src)) return;
|
||
let entries = [];
|
||
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { return; }
|
||
for (const e of entries) {
|
||
if (!e.isDirectory()) continue;
|
||
const from = path.join(src, e.name);
|
||
const target = path.join(dst, e.name);
|
||
const bundleVer = readAddonVersion(from);
|
||
if (!bundleVer) continue; // broken bundle — skip rather than corrupt user state
|
||
if (fs.existsSync(target)) {
|
||
const userVer = readAddonVersion(target);
|
||
// Only reseed when the bundle is STRICTLY NEWER than what's in
|
||
// userData. The old check `userVer === bundleVer ? continue` would
|
||
// reseed whenever the versions differed — including the OTA case
|
||
// where promoteStagedUpdates just promoted a newer addon than the
|
||
// one baked into the installer, silently downgrading it on the same
|
||
// boot. Version-compare with the same cmpVer helper the promoter
|
||
// uses so both sides agree on ordering.
|
||
if (userVer && cmpVersions(userVer, bundleVer) >= 0) continue;
|
||
// Backups go in a sibling folder so AddonHost's directory scan doesn't
|
||
// pick them up as duplicate addons with the same manifest id.
|
||
const backupsRoot = addonsBackupDir();
|
||
try { fs.mkdirSync(backupsRoot, { recursive: true }); } catch {}
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
const backup = path.join(backupsRoot, `${e.name}-${userVer ?? "unknown"}-${stamp}`);
|
||
try { fs.renameSync(target, backup); }
|
||
catch (err) { console.warn(`[addons] backup ${e.name} failed, skipping reseed:`, err?.message); continue; }
|
||
console.log(`[addons] reseed ${e.name}: ${userVer ?? "unknown"} -> ${bundleVer} (previous copy at addons-backups/${path.basename(backup)})`);
|
||
}
|
||
try { fs.cpSync(from, target, { recursive: true }); }
|
||
catch (err) { console.warn(`[addons] seed ${e.name} failed:`, err?.message); }
|
||
}
|
||
}
|
||
// ---- request filter (add-on capability) ----
|
||
// Chromium allows one onBeforeRequest listener per session, so main owns it
|
||
// and consults the add-ons' filters. Top-level navigations are never
|
||
// blocked here — a blocker hides trackers, it does not decide where the
|
||
// user can go — and only http(s) subresources are offered to filters.
|
||
const requestFilters = new Map(); // addonId -> (details) => boolean
|
||
const tabChangeListeners = new Set(); // add-on callbacks for api.tabs.onChange
|
||
function notifyTabChange() {
|
||
if (!tabChangeListeners.size) return;
|
||
const t = activeTab(); const info = t ? { id: t.id, url: t.url || "" } : null;
|
||
for (const cb of tabChangeListeners) { try { cb(info); } catch (e) { console.warn("[addons] tab listener failed:", e?.message); } }
|
||
}
|
||
function installRequestFilter() {
|
||
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
|
||
if (!requestFilters.size || !/^https?:/i.test(details.url)) return callback({});
|
||
let frameUrl = ""; try { frameUrl = (details.frame && details.frame.url) || details.referrer || ""; } catch { frameUrl = details.referrer || ""; }
|
||
const view = { url: details.url, resourceType: details.resourceType, method: details.method, frameUrl, initiator: details.initiatorOrigin || "", webContentsId: details.webContentsId ?? null };
|
||
let block = false;
|
||
for (const [id, fn] of requestFilters) {
|
||
try { if (fn(view)) { block = true; } } catch (e) { console.warn(`[addons] [${id}] request filter failed:`, e?.message); }
|
||
}
|
||
callback(block && details.resourceType !== "mainFrame" ? { cancel: true } : {});
|
||
});
|
||
}
|
||
function initAddons() {
|
||
// Promote any signed add-on update staged by a previous run BEFORE we
|
||
// reseed from the bundle — a fresh install of a newer version from
|
||
// updateURL should win over the older bundled copy shipped inside the
|
||
// Theseus installer.
|
||
addonUpdater.promoteStagedUpdates({
|
||
addonsDir: addonsUserDir(),
|
||
backupsDir: addonsBackupDir(),
|
||
stagedDir: addonsStagedDir(),
|
||
logger: (...a) => console.log("[addons]", ...a),
|
||
});
|
||
// Retire the pre-rename bundle layouts before reseeding so the fresh
|
||
// aegis/ + siawallet-less state is what AddonHost enumerates.
|
||
migrateAegisRename();
|
||
seedBundledAddons();
|
||
addonHost = new AddonHost({
|
||
addonsDir: addonsUserDir(),
|
||
// request-filter capability: add-ons register a decision function;
|
||
// main owns the session's one onBeforeRequest listener (see
|
||
// installRequestFilter) and asks every registered filter.
|
||
requestFilter: { set: (id, fn) => requestFilters.set(id, fn), clear: (id) => requestFilters.delete(id) },
|
||
tabs: {
|
||
active: () => { const t = activeTab(); if (!t) return null; let wcId = null; try { wcId = t.view.webContents.id; } catch {} return { id: t.id, webContentsId: wcId, url: t.url || "", host: (() => { try { return new URL(t.url).host; } catch { return ""; } })() }; },
|
||
onChange: (cb) => { tabChangeListeners.add(cb); return () => tabChangeListeners.delete(cb); },
|
||
},
|
||
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.
|
||
proxyAuth = null;
|
||
if (rules == null || rules === "") {
|
||
console.log(`[addons] [${addonId}] clearing session proxy`);
|
||
addonProxyOpts = null;
|
||
if (torState !== "off") return; // Tor owns the session proxy right now
|
||
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)" : "");
|
||
// Chromium fires app#login with authInfo.isProxy when the proxy asks for creds.
|
||
if (auth) proxyAuth = auth;
|
||
addonProxyOpts = opts;
|
||
if (torState !== "off") { console.log(`[addons] [${addonId}] Tor is on — proxy kept for when it goes off`); return; }
|
||
try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); }
|
||
},
|
||
// vault-derive capability. Resolves once the vault is unlocked (the
|
||
// user types the master password at boot or later in Settings) with a
|
||
// 32-byte HKDF child of the vault's root. The vault never persists the
|
||
// BIP-39 seed — only per-purpose roots — so add-on material hangs off
|
||
// the passwords root under an "addons/" info label: recoverable from
|
||
// the same mnemonic on any device, and a derived password can't be
|
||
// walked back to it (HKDF is one-way).
|
||
vaultDerive: async (purposePath, addonId) => {
|
||
if (!fs.existsSync(vaultFile())) throw new Error("password vault is not set up");
|
||
while (!vaultState) await new Promise((r) => setTimeout(r, 500));
|
||
const v = await loadVaultLib();
|
||
const wc = require("node:crypto").webcrypto;
|
||
const key = await wc.subtle.importKey("raw", v.hexToBytes(vaultState.purposeRoot), "HKDF", false, ["deriveBits"]);
|
||
const info = new TextEncoder().encode(`silentmode/addons/${purposePath}`);
|
||
console.log(`[addons] [${addonId}] vault.derive ${purposePath}`);
|
||
return new Uint8Array(await wc.subtle.deriveBits(
|
||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
|
||
},
|
||
vaultLifecycle: {
|
||
status: async () => ({ setup: fs.existsSync(vaultFile()), unlocked: !!vaultState }),
|
||
unlock: async (masterPassword, addonId) => {
|
||
if (!fs.existsSync(vaultFile())) throw new Error("no vault");
|
||
const v = await loadVaultLib();
|
||
vaultState = await v.unlockVault(vaultFile(), masterPassword);
|
||
importsState = null;
|
||
if (fs.existsSync(importsFile())) {
|
||
try { importsState = await v.unlockImports(importsFile(), masterPassword); }
|
||
catch (ie) { console.error("[imports] unlock via addon failed:", ie?.message); }
|
||
}
|
||
importsUnlockPw = masterPassword;
|
||
emitPwAvailability();
|
||
console.log(`[addons] [${addonId}] vault.unlock`);
|
||
return { ok: true };
|
||
},
|
||
setup: async (masterPassword, seedSource, addonId) => {
|
||
if (!masterPassword || String(masterPassword).length < 4) throw new Error("master password too short");
|
||
if (fs.existsSync(vaultFile())) throw new Error("vault already exists");
|
||
const v = await loadVaultLib();
|
||
let purposeRootHex, messengerRootHex;
|
||
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
|
||
const seed = await v.bip39ToSeed(String(seedSource.mnemonic));
|
||
purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0"));
|
||
messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0"));
|
||
} else {
|
||
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
|
||
purposeRootHex = v.bytesToHex(root);
|
||
}
|
||
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex,
|
||
messengerRootHex ? { messengerRootHex } : {});
|
||
importsUnlockPw = masterPassword;
|
||
emitPwAvailability();
|
||
console.log(`[addons] [${addonId}] vault.setup`);
|
||
return { ok: true };
|
||
},
|
||
lock: async (addonId) => {
|
||
vaultState = null; importsState = null; importsUnlockPw = null;
|
||
emitPwAvailability();
|
||
console.log(`[addons] [${addonId}] vault.lock`);
|
||
return { ok: true };
|
||
},
|
||
},
|
||
vaultImports: {
|
||
list: async () => {
|
||
if (!vaultState) throw new Error("password vault is locked");
|
||
const v = await loadVaultLib();
|
||
return importsState ? v.listImportsMetadata(importsState) : [];
|
||
},
|
||
add: async (spec, addonId) => {
|
||
if (!vaultState) throw new Error("password vault is locked");
|
||
if (!importsUnlockPw) throw new Error("imports session credential missing (relock and unlock)");
|
||
if (!spec || typeof spec !== "object") throw new Error("spec required");
|
||
const kind = String(spec.kind || "");
|
||
if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`);
|
||
const cashaddr = String(spec.cashaddr || "").trim();
|
||
if (!cashaddr) throw new Error("cashaddr required (caller derives)");
|
||
const label = String(spec.label || "").trim().slice(0, 120);
|
||
if (!label) throw new Error("label required");
|
||
const category = String(spec.category || "").trim().slice(0, 40) || "operational";
|
||
const source = String(spec.source || "").trim().slice(0, 500);
|
||
const v = await loadVaultLib();
|
||
if (!importsState) importsState = await v.createImports(importsFile(), importsUnlockPw);
|
||
const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet";
|
||
let id = rawId, n = 1;
|
||
while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; }
|
||
const rec = { kind, cashaddr, label, category, source, createdAt: Date.now() };
|
||
if (kind === "seed") {
|
||
if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed");
|
||
rec.seed = String(spec.seed); rec.path = String(spec.path);
|
||
} else {
|
||
if (!spec.wif) throw new Error("wif required for kind=wif");
|
||
rec.wif = String(spec.wif);
|
||
}
|
||
importsState.accounts[id] = rec;
|
||
await v.saveImports(importsFile(), importsState);
|
||
console.log(`[addons] [${addonId}] vault.imports.add ${kind} → ${id}`);
|
||
return { id, entries: v.listImportsMetadata(importsState) };
|
||
},
|
||
remove: async (id, addonId) => {
|
||
if (!vaultState || !importsState) throw new Error("password vault is locked");
|
||
if (!importsState.accounts[id]) throw new Error("no such import");
|
||
delete importsState.accounts[id];
|
||
const v = await loadVaultLib();
|
||
await v.saveImports(importsFile(), importsState);
|
||
console.log(`[addons] [${addonId}] vault.imports.remove ${id}`);
|
||
return { entries: v.listImportsMetadata(importsState) };
|
||
},
|
||
signer: async (id, addonId) => {
|
||
if (!vaultState || !importsState) throw new Error("password vault is locked");
|
||
const v = await loadVaultLib();
|
||
console.log(`[addons] [${addonId}] vault.imports.signer ${id}`);
|
||
return v.getImportSigner(importsState, id);
|
||
},
|
||
},
|
||
approvalModal: (opts, addonId) => showApprovalModal(opts, addonId),
|
||
emitToPanel: (addonId, msg, payload) => {
|
||
// Full-tab pages of the same add-on hear it too. addon-tab-preload has
|
||
// always exposed silentmode.on(), but nothing ever delivered to a tab,
|
||
// so an add-on had no way to tell its own open editor anything — which
|
||
// is what a second document needs in order to land in the editor that
|
||
// is already up instead of a new tab.
|
||
for (const t of tabs) {
|
||
if (t.addonId !== addonId) continue;
|
||
try { t.view?.webContents?.send("addon-event", msg, payload); } catch {}
|
||
}
|
||
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return;
|
||
try { sidebar.webContents.send("addon-event", msg, payload); } catch {}
|
||
},
|
||
hostRequire: (name) => require(name),
|
||
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
|
||
openTab: (url) => { if (win) createTab(url); },
|
||
// openSettings: routes through the existing "open-settings" IPC handler
|
||
// so the same section-hint validation applies. Add-ons hit this when they
|
||
// want to point users at Passwords, Extensions, etc.
|
||
openSettings: (section) => {
|
||
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?$/i.test(section) ? section.toLowerCase() : "";
|
||
const ex = tabs.find((t) => t.settings);
|
||
if (ex) {
|
||
setActive(ex.id);
|
||
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
|
||
return;
|
||
}
|
||
createTab(null, { settings: true, settingsSection: slug });
|
||
},
|
||
// Panel-driven update flow. Runs the same signed-payload verify + stage
|
||
// path used by Settings › Extensions › Check-for-updates and the boot
|
||
// timer, but on demand from an add-on's own UI so a plug-in card can
|
||
// offer "Update now" in one click. checkAndStageUpdates itself iterates
|
||
// every installed add-on; the API wrapper filters the report down to
|
||
// the caller. restartApp mirrors the "app-restart" IPC so the plug-in
|
||
// can apply a freshly-staged build without asking the user to hunt
|
||
// for the OS menu.
|
||
checkAndStageUpdates: async () => {
|
||
const stagedDir = addonsStagedDir();
|
||
try {
|
||
const result = await addonUpdater.checkAndStageUpdates({
|
||
addonsDir: addonsUserDir(),
|
||
stagedDir,
|
||
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
||
verifyPublisher: verifyPublisherEntry,
|
||
logger: (...a) => console.log("[addons]", ...a),
|
||
});
|
||
const staged = listStagedAddons(stagedDir);
|
||
notifyStagedAddons(staged);
|
||
return { report: result?.report || [], skipped: result?.skipped || null, staged };
|
||
} catch (e) {
|
||
console.warn("[addons] panel-driven check-updates failed:", e?.message || e);
|
||
return { report: [], skipped: "unexpected-error", staged: [] };
|
||
}
|
||
},
|
||
// An add-on may ask for a relaunch (Aegis does after staging its own
|
||
// update) but never gets to perform one: the user is asked first, in a
|
||
// native dialog the panel cannot draw over. Declining costs nothing —
|
||
// a staged update applies on the next normal launch anyway.
|
||
restartApp: async (addonName) => {
|
||
const who = String(addonName || "An add-on").slice(0, 60);
|
||
console.log(`[restart] ${who} asked to relaunch Theseus`);
|
||
const { response } = await askSheet({
|
||
type: "question", title: "Restart Theseus?",
|
||
message: `${who} wants to restart Theseus.`,
|
||
detail: "Usually to finish installing its own update. Open tabs are restored after the restart. If you choose Later, the update still applies the next time Theseus starts.",
|
||
buttons: ["Restart now", "Later"], defaultId: 1, cancelId: 1, noLink: true,
|
||
});
|
||
if (response !== 0) { console.log(`[restart] ${who}: user chose Later`); return { restarted: false, deferred: true }; }
|
||
try { app.relaunch(); } catch {}
|
||
app.quit();
|
||
return { restarted: true };
|
||
},
|
||
// open-tab (addon-file variant): open one of the add-on's OWN files in a
|
||
// full tab. The path is joined against the resolved add-on folder and
|
||
// rejected if the result escapes it — belt-and-braces with the sanity
|
||
// check the api wrapper already does. The tab uses addon-tab-preload so
|
||
// window.silentmode.invoke() reaches the same handler surface as a
|
||
// sidebar panel; the sender-URL gate on addon-msg then confines the
|
||
// page to its own add-on's storage/handlers.
|
||
openAddonTab: (addonId, relPath, queryString) => {
|
||
if (!win) return;
|
||
const folder = addonHost && addonHost.folderOf(addonId);
|
||
if (!folder) throw new Error(`openAddonTab: no such active add-on "${addonId}"`);
|
||
const base = path.resolve(folder);
|
||
const abs = path.resolve(base, relPath);
|
||
const norm = abs.replace(/\\/g, "/").toLowerCase();
|
||
const baseNorm = base.replace(/\\/g, "/").toLowerCase();
|
||
if (norm !== baseNorm && !norm.startsWith(baseNorm + "/")) {
|
||
throw new Error(`openAddonTab: path "${relPath}" escapes add-on folder`);
|
||
}
|
||
if (!fs.existsSync(abs)) throw new Error(`openAddonTab: file not found: ${abs}`);
|
||
console.log(`[addons] [${addonId}] openAddonTab -> ${path.basename(abs)}${queryString ? "?"+queryString.slice(0,80)+(queryString.length>80?"…":"") : ""}`);
|
||
createTab(null, { addonFile: { absPath: abs, query: queryString || "", addonId } });
|
||
},
|
||
// capture-tab: three modes.
|
||
// visible — one WebContents.capturePage() of the current viewport.
|
||
// full — temporarily grow the tab's WebContentsView to the page's
|
||
// scrollHeight, capture, restore. Cheap and works for most
|
||
// pages; fixed-position headers/footers will repeat because
|
||
// they anchor to the viewport, which is a known trade-off
|
||
// (documented in the panel). Alternative would be a scroll-
|
||
// and-stitch pass; kept for a later revision.
|
||
// region — run the caller-supplied overlay source in the tab, wait
|
||
// for a rect (or null = cancel), then capturePage(rect).
|
||
// Back scan-page. The extraction runs IN the page and returns only the
|
||
// matched URIs — the add-on never sees the DOM. We look at anchor hrefs,
|
||
// visible text, and the handful of attributes a dapp realistically
|
||
// stashes a pairing code in (data-uri, value, title), then dedupe.
|
||
//
|
||
// WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which
|
||
// is often the ONLY thing in the DOM when a dapp renders a QR, so the
|
||
// matcher accepts the percent-encoded spelling too and decodes it.
|
||
scanTabForUris: async ({ scheme, limit }, addonId) => {
|
||
const t = activeTab();
|
||
if (!t) throw new Error("no active tab");
|
||
if (t.addonId || t.settings) throw new Error("open the dapp's tab first, then scan");
|
||
const wc = t.view.webContents;
|
||
const origin = pageOriginOf(wc.getURL());
|
||
// The regex SOURCES are built here and shipped as JSON. Assembling
|
||
// them inside the injected string instead means hand-escaping
|
||
// backslashes and quotes through two levels of literal, which is both
|
||
// easy to get wrong and unreviewable. JSON.stringify does it exactly.
|
||
// `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so
|
||
// it cannot carry regex metacharacters.
|
||
const plainSrc = `\\b${scheme}://[^\\s"'<>]{4,2048}`;
|
||
// Percent-encoded QR spelling: WIZ://%3FP%3D…
|
||
const qrSrc = `\\b${scheme}://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~$+-])+`;
|
||
const arg = JSON.stringify({ plainSrc, qrSrc, limit });
|
||
const found = await wc.executeJavaScript(`(() => {
|
||
const { plainSrc, qrSrc, limit } = ${arg};
|
||
const out = new Set();
|
||
const plain = new RegExp(plainSrc, "gi");
|
||
const qr = new RegExp(qrSrc, "gi");
|
||
const push = (s) => {
|
||
if (!s || out.size >= limit) return;
|
||
let v = String(s).trim();
|
||
if (v.includes("%3F") || v.includes("%3f")) { try { v = decodeURIComponent(v); } catch {} }
|
||
if (v.length <= 2048) out.add(v);
|
||
};
|
||
const scan = (s) => {
|
||
if (!s) return;
|
||
for (const m of String(s).matchAll(plain)) push(m[0]);
|
||
for (const m of String(s).matchAll(qr)) push(m[0]);
|
||
};
|
||
for (const a of document.querySelectorAll("a[href]")) scan(a.getAttribute("href"));
|
||
for (const el of document.querySelectorAll("[data-uri],[data-wc-uri],[value],[title]")) {
|
||
scan(el.getAttribute("data-uri")); scan(el.getAttribute("data-wc-uri"));
|
||
scan(el.getAttribute("value")); scan(el.getAttribute("title"));
|
||
}
|
||
for (const el of document.querySelectorAll("input,textarea")) scan(el.value);
|
||
scan(document.body ? document.body.innerText : "");
|
||
return [...out].slice(0, limit);
|
||
})()`, true);
|
||
const uris = Array.isArray(found) ? found.filter((s) => typeof s === "string") : [];
|
||
console.log(`[addons] ${addonId} scanned ${origin || "tab"} for ${scheme}:// — ${uris.length} match(es)`);
|
||
return { origin, uris };
|
||
},
|
||
captureTab: async (opts, addonId) => {
|
||
// Prefer the currently-active tab, BUT if that's an add-on-owned page
|
||
// (e.g. the screenshot editor is already up when the user re-picks a
|
||
// mode from the dropdown), fall back to the most-recently-active real
|
||
// tab. Otherwise a second capture snapshots the editor's still-blank
|
||
// canvas and every follow-up produces a white PNG.
|
||
let t = activeTab();
|
||
if (t && (t.addonId || t.settings)) {
|
||
const fallback = tabById(lastCapturableTabId);
|
||
if (fallback && !fallback.addonId && !fallback.settings) t = fallback;
|
||
else {
|
||
// Last resort: any non-addon non-settings tab in the list.
|
||
t = tabs.find((x) => !x.addonId && !x.settings) || t;
|
||
}
|
||
}
|
||
if (!t) throw new Error("no active tab");
|
||
if (t.addonId || t.settings) {
|
||
throw new Error("no capturable tab — open a page you'd like to shoot first");
|
||
}
|
||
const wc = t.view.webContents;
|
||
const host = t?.prov?.host || (() => { try { return new URL(wc.getURL()).host; } catch { return ""; } })();
|
||
const mode = String(opts?.mode || "visible");
|
||
const format = opts?.format === "jpeg" ? "jpeg" : "png";
|
||
const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90));
|
||
// CDP-based capture. Page.captureScreenshot forces a fresh composite
|
||
// regardless of occlusion state, so it doesn't blank out when the tab
|
||
// view is marked hidden (which happened right after a native menu
|
||
// popup closed — the compositor stays throttled for a few frames and
|
||
// WebContents.capturePage() would snapshot a stale/transparent frame
|
||
// at the correct dimensions, which no size-based retry could catch).
|
||
// Reads PNG width/height from the IHDR chunk so we don't need a
|
||
// NativeImage roundtrip.
|
||
function pngDims(b64) {
|
||
const buf = Buffer.from(b64, "base64");
|
||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||
}
|
||
async function cdpCapture({ full = false, rect = null } = {}) {
|
||
const wasAttached = wc.debugger.isAttached();
|
||
if (!wasAttached) {
|
||
try { wc.debugger.attach("1.3"); }
|
||
catch (e) {
|
||
if (!/already attached/i.test(String(e?.message))) throw e;
|
||
}
|
||
}
|
||
try {
|
||
const p = { format: format === "jpeg" ? "jpeg" : "png" };
|
||
if (format === "jpeg") p.quality = quality;
|
||
if (rect) p.clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
|
||
if (full) p.captureBeyondViewport = true;
|
||
const { data } = await wc.debugger.sendCommand("Page.captureScreenshot", p);
|
||
const dataUrl = `data:image/${p.format};base64,${data}`;
|
||
const dims = p.format === "png" ? pngDims(data) : (rect ? { width: rect.width, height: rect.height } : null);
|
||
return { dataUrl, ...(dims || {}) };
|
||
} finally {
|
||
// Only detach if WE attached; leave a pre-existing DevTools/other
|
||
// consumer's attachment alone.
|
||
if (!wasAttached) { try { wc.debugger.detach(); } catch {} }
|
||
}
|
||
}
|
||
if (mode === "visible") {
|
||
const r = await cdpCapture();
|
||
console.log(`[addons] [${addonId}] captureTab visible ${r.width}x${r.height}`);
|
||
return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
|
||
}
|
||
if (mode === "full") {
|
||
// Page.captureScreenshot with captureBeyondViewport does the whole
|
||
// scrollable page. Before we shoot, force the layout viewport to the
|
||
// window's full content width via Emulation.setDeviceMetricsOverride
|
||
// so an open sidebar (or any other on-screen chrome that narrowed
|
||
// the tab view) doesn't clip the capture — the shot always comes
|
||
// back at the page's natural full width, not the visible width.
|
||
const wasAttached = wc.debugger.isAttached();
|
||
if (!wasAttached) {
|
||
try { wc.debugger.attach("1.3"); }
|
||
catch (e) { if (!/already attached/i.test(String(e?.message))) throw e; }
|
||
}
|
||
let overrode = false;
|
||
try {
|
||
const winW = (win?.getContentBounds()?.width) || 0;
|
||
const tabB = t.view.getBounds();
|
||
const need = winW > tabB.width + 24 ? winW : 0;
|
||
if (need > 0) {
|
||
// dsf 0 = "let Chromium keep the real device scale factor".
|
||
// mobile false, deviceScaleFactor 0 keeps typography sane;
|
||
// height 0 tells CDP "use the current viewport height".
|
||
await wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", {
|
||
width: need, height: 0, deviceScaleFactor: 0, mobile: false,
|
||
});
|
||
overrode = true;
|
||
// A frame or two so the reflow settles before we snapshot.
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
}
|
||
const r = await cdpCapture({ full: true });
|
||
console.log(`[addons] [${addonId}] captureTab full ${r.width}x${r.height}${overrode ? ` (viewport widened to ${need}px)` : ""}`);
|
||
return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
|
||
} finally {
|
||
if (overrode) {
|
||
try { await wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride"); } catch {}
|
||
}
|
||
if (!wasAttached) { try { wc.debugger.detach(); } catch {} }
|
||
}
|
||
}
|
||
if (mode === "region") {
|
||
const src = String(opts?.overlaySource || "");
|
||
if (!src) throw new Error("region capture needs opts.overlaySource");
|
||
// Overlay script runs in the target tab's world. It's expected to
|
||
// resolve (as the executeJavaScript result) with {x,y,w,h} in CSS
|
||
// pixels, or null when the user hits Escape / right-clicks.
|
||
const rectRaw = await wc.executeJavaScript(src, true);
|
||
if (!rectRaw || typeof rectRaw !== "object") {
|
||
console.log(`[addons] [${addonId}] captureTab region cancelled`);
|
||
return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true };
|
||
}
|
||
const rect = {
|
||
x: Math.max(0, Math.floor(rectRaw.x)),
|
||
y: Math.max(0, Math.floor(rectRaw.y)),
|
||
width: Math.max(1, Math.floor(rectRaw.w)),
|
||
height: Math.max(1, Math.floor(rectRaw.h)),
|
||
};
|
||
const r = await cdpCapture({ rect });
|
||
const s = { width: r.width || rect.width, height: r.height || rect.height };
|
||
console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${rect.x},${rect.y}`);
|
||
return { dataUrl: r.dataUrl, width: s.width, height: s.height, host, format };
|
||
}
|
||
throw new Error(`unknown capture mode: ${mode}`);
|
||
},
|
||
// saveCapture writes the bytes to Downloads and synthesizes a completed
|
||
// download record so the chip shows the file with a Show-in-folder link,
|
||
// just like an HTTP save. session.downloadURL(dataUrl) would go through
|
||
// will-download, but data URLs come across with a synthetic filename that
|
||
// Electron won't let us override in-flight without gymnastics — writing
|
||
// directly is deterministic and produces the same user-facing artifact.
|
||
saveCapture: async (opts, addonId) => {
|
||
const dataUrl = String(opts?.dataUrl || "");
|
||
const m = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl);
|
||
if (!m) throw new Error("saveCapture: dataUrl must be base64-encoded");
|
||
const mime = m[1];
|
||
const bytes = Buffer.from(m[2], "base64");
|
||
const raw = String(opts?.filename || "screenshot.png");
|
||
// Strip path separators — add-on-provided filename must not escape the
|
||
// downloads folder.
|
||
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png";
|
||
const dlDir = app.getPath("downloads");
|
||
let target = path.join(dlDir, safe);
|
||
// Uniquify: append " (n)" before the extension if the name is taken.
|
||
if (fs.existsSync(target)) {
|
||
const ext = path.extname(safe);
|
||
const stem = safe.slice(0, safe.length - ext.length);
|
||
for (let i = 2; i < 10000; i++) {
|
||
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
|
||
if (!fs.existsSync(cand)) { target = cand; break; }
|
||
}
|
||
}
|
||
try { fs.writeFileSync(target, bytes); }
|
||
catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); }
|
||
const id = nextDlId++;
|
||
const rec = {
|
||
id,
|
||
filename: path.basename(target),
|
||
url: `internal://addons/${addonId}/${path.basename(target)}`,
|
||
mime,
|
||
total: bytes.length,
|
||
received: bytes.length,
|
||
state: "completed",
|
||
savePath: target,
|
||
startedAt: Date.now(),
|
||
};
|
||
downloads.unshift(rec);
|
||
emitDownloads();
|
||
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
|
||
return { savePath: target };
|
||
},
|
||
// revealSidebar hook — used by add-ons declaring "context-menu-item" so
|
||
// a right-click handler can pull the sidebar open to its own panel.
|
||
// AddonHost has already gated by ownership before calling us; here we
|
||
// just route through the existing setSidebar path.
|
||
revealSidebar: (addonId, panelId) => {
|
||
try { setSidebar(true, panelId); }
|
||
catch (e) { console.warn(`[addons] [${addonId}] revealSidebar failed:`, e?.message); }
|
||
},
|
||
});
|
||
addonHost.discoverAndActivate();
|
||
const snap = addonHost.snapshot();
|
||
console.log(`[addons] ${snap.installed.length} installed, ${snap.installed.filter((x) => x.enabled).length} enabled, ${snap.sidebarPanels.length} sidebar panels`);
|
||
}
|
||
// Given a webContents sender URL, work out which add-on folder it lives in.
|
||
// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch
|
||
// its own store.
|
||
function addonIdForSender(sender) {
|
||
try {
|
||
const u = new URL(sender.getURL());
|
||
if (u.protocol !== "file:") return null;
|
||
const filePath = decodeURIComponent(u.pathname).replace(/^\/+/, "");
|
||
const norm = filePath.replace(/\\/g, "/");
|
||
const dirNorm = addonsUserDir().replace(/\\/g, "/").replace(/\/+$/, "");
|
||
if (!norm.toLowerCase().startsWith(dirNorm.toLowerCase() + "/")) return null;
|
||
const rest = norm.slice(dirNorm.length + 1);
|
||
const first = rest.split("/")[0];
|
||
return first || null;
|
||
} catch { return null; }
|
||
}
|
||
// In-memory download list. Not persisted: closing the browser clears history
|
||
// (the files are still on disk; only the list of "recent downloads" is dropped).
|
||
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
|
||
const tabs = []; // { id, view, title, url, prov }
|
||
let activeId = null, tabSeq = 0;
|
||
const tabById = (id) => tabs.find((t) => t.id === id);
|
||
const activeTab = () => tabById(activeId);
|
||
// The most-recently-active non-addon tab. captureTab falls back to this when
|
||
// the currently-active tab is an add-on-owned page (e.g. the screenshot
|
||
// editor itself) — otherwise a re-triggered capture snapshots the editor's
|
||
// still-blank canvas instead of the page the user actually wants to shoot.
|
||
let lastCapturableTabId = null;
|
||
|
||
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
|
||
// the bottom status line, so the resolver detail lives on the bottom bar.
|
||
// Enrich prov with a "collision candidate?" flag so the popover switcher can
|
||
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
|
||
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
|
||
function decorate(prov) {
|
||
if (!prov || !prov.tld) return prov;
|
||
return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) };
|
||
}
|
||
function pushNav(prov) {
|
||
const p = decorate(prov);
|
||
chrome?.webContents.send("nav", p);
|
||
if (popVisible) popover?.webContents.send("site-info", p);
|
||
emitPwAvailability();
|
||
}
|
||
|
||
// ---- Fullscreen ------------------------------------------------------------
|
||
// Two ways in: a page asks for HTML fullscreen (video players: Electron puts
|
||
// the whole window in fullscreen for it) or the user presses F11. Nothing
|
||
// used to own either, so the toolbar stayed on top of a fullscreen video,
|
||
// and a page that left fullscreen while its tab was hidden, or a fullscreen
|
||
// tab that was closed or switched away from, left the window in fullscreen
|
||
// with no title-bar buttons, the taskbar covered and no key to get out.
|
||
let fsTabId = null; // tab whose page holds HTML fullscreen (toolbar + sidebar hidden for it)
|
||
let userFullscreen = false; // F11: window fullscreen with the toolbar kept
|
||
function enterHtmlFullscreen(tab) {
|
||
fsTabId = tab.id;
|
||
try { if (!win.isFullScreen()) win.setFullScreen(true); } catch {}
|
||
layout();
|
||
}
|
||
// forced: Theseus is leaving on the page's behalf (tab switched or closed,
|
||
// F11) and must take the window out of fullscreen itself. When the page
|
||
// leaves on its own, Electron has already taken the window out by the time
|
||
// leave-html-full-screen fires; a second exit during that transition
|
||
// restored the window maximized (2026-09-28), so that path only checks
|
||
// later that the exit really happened.
|
||
function leaveHtmlFullscreen(tab, forced = false) {
|
||
if (!tab || fsTabId !== tab.id) return;
|
||
fsTabId = null;
|
||
if (forced) {
|
||
// The page keeps its own fullscreen state (a player's controls, the
|
||
// fullscreenchange event): tell it.
|
||
try { tab.view.webContents.executeJavaScript("document.fullscreenElement && document.exitFullscreen(); 0", true).catch(() => {}); } catch {}
|
||
try { if (!userFullscreen && win.isFullScreen()) win.setFullScreen(false); } catch {}
|
||
} else scheduleFullscreenCheck();
|
||
layout();
|
||
}
|
||
// A fullscreen the window did not ask for and no page holds — the stuck
|
||
// state described above — is left. Checked on a timer: during a fullscreen
|
||
// exit the window still reports fullscreen for a moment, and exiting again
|
||
// right then is what restored it maximized.
|
||
let fsCheckTimer = null;
|
||
function scheduleFullscreenCheck() {
|
||
clearTimeout(fsCheckTimer);
|
||
fsCheckTimer = setTimeout(() => {
|
||
fsCheckTimer = null;
|
||
if (!winAlive() || fsTabId != null || userFullscreen) return;
|
||
try { if (win.isFullScreen()) win.setFullScreen(false); } catch {}
|
||
}, 600);
|
||
}
|
||
function toggleUserFullscreen() {
|
||
if (!winAlive()) return;
|
||
userFullscreen = !userFullscreen;
|
||
if (fsTabId != null) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true);
|
||
try { win.setFullScreen(userFullscreen); } catch {}
|
||
layout();
|
||
}
|
||
function layout() {
|
||
if (!winAlive()) return;
|
||
if (fsTabId == null && !userFullscreen) { try { if (win.isFullScreen()) scheduleFullscreenCheck(); } catch {} }
|
||
const { width, height } = win.getContentBounds();
|
||
const fsTab = fsTabId != null ? tabs.find((t) => t.id === fsTabId) : null;
|
||
if (!fsTab && fsTabId != null) fsTabId = null;
|
||
const chromeH = fsTab ? 0 : CHROME_H;
|
||
chrome.setBounds({ x: 0, y: 0, width, height: chromeH });
|
||
const bodyH = Math.max(0, height - chromeH);
|
||
// Quick-links strip on the LEFT edge (optional, 44px). Hidden in HTML
|
||
// fullscreen so a video truly fills the window. The strip spans the full
|
||
// body height alongside the tab view.
|
||
const leftW = (quicklinks && settings.quickLinksShow && !fsTab) ? QUICKLINKS_W : 0;
|
||
if (quicklinks) {
|
||
quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH });
|
||
try { quicklinks.setVisible(leftW > 0); } catch {}
|
||
}
|
||
// Quick-link panel: a narrow mini-view just right of the strip, showing the
|
||
// active quick-link's web app. Only takes space when something is open.
|
||
const panelW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0;
|
||
if (quickPanel) {
|
||
quickPanel.setBounds({ x: leftW, y: chromeH, width: panelW, height: bodyH });
|
||
try { quickPanel.setVisible(panelW > 0); } catch {}
|
||
}
|
||
// 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 && !fsTab ? sidebarW : 0;
|
||
const tabX = leftW + panelW;
|
||
const tabW = Math.max(0, width - tabX - sideW);
|
||
for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
|
||
if (sidebar) sidebar.setBounds({ x: tabX + tabW, y: chromeH, 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: tabX, y: chromeH, width: tabW, height: bodyH });
|
||
if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, 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) {
|
||
if (deferUntilOverlayLoaded(popover, () => showPopover(true))) return;
|
||
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;
|
||
if (win.isFocused()) try { popover.webContents.focus(); } catch {} // see closeOnClickAway
|
||
popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" });
|
||
} else { cancelOverlayShow(popover); 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) {
|
||
if (deferUntilOverlayLoaded(enginePicker, () => showEnginePicker(true))) return;
|
||
positionEnginePicker();
|
||
win.contentView.removeChildView(enginePicker);
|
||
win.contentView.addChildView(enginePicker);
|
||
enginePicker.setVisible(true); epVisible = true;
|
||
if (win.isFocused()) try { enginePicker.webContents.focus(); } catch {} // see closeOnClickAway
|
||
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
|
||
} else { cancelOverlayShow(enginePicker); 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) {
|
||
if (deferUntilOverlayLoaded(downloadsPop, () => showDownloads(true))) return;
|
||
positionDownloads();
|
||
win.contentView.removeChildView(downloadsPop);
|
||
win.contentView.addChildView(downloadsPop);
|
||
downloadsPop.setVisible(true); dlVisible = true;
|
||
if (win.isFocused()) try { downloadsPop.webContents.focus(); } catch {} // see closeOnClickAway
|
||
downloadsPop.webContents.send("downloads", downloadsPublic());
|
||
} else { cancelOverlayShow(downloadsPop); downloadsPop.setVisible(false); dlVisible = false; }
|
||
}
|
||
function positionAddressPicker() {
|
||
if (!addressPicker || !winAlive()) 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 || !winAlive()) 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 || !winAlive()) return;
|
||
const { width, height } = win.getContentBounds();
|
||
// The pill may grow to (almost) the full width of the tab area — long
|
||
// URLs stay readable — and ellipsises past that. It never runs under
|
||
// the sidebar.
|
||
const tabW = Math.max(0, width - (sidebarVisible ? sidebarW : 0));
|
||
const w = Math.min(Math.max(120, linkStatusW), Math.max(200, tabW - 16));
|
||
const h = Math.max(20, linkStatusH);
|
||
linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h });
|
||
}
|
||
let linkStatusPendingUrl = "";
|
||
function showLinkStatus(url) {
|
||
if (!linkStatus || !winAlive()) return;
|
||
const s = String(url || "");
|
||
if (!s) {
|
||
cancelOverlayShow(linkStatus); linkStatusPendingUrl = "";
|
||
if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; }
|
||
return;
|
||
}
|
||
// First hover before the prewarm got to it: show the latest URL once loaded.
|
||
linkStatusPendingUrl = s;
|
||
if (deferUntilOverlayLoaded(linkStatus, () => showLinkStatus(linkStatusPendingUrl))) 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); }
|
||
// Everything the chrome needs to draw the extension dock + plug-in row:
|
||
// panels, toolbar menus, which panel is open, and the user's dock prefs.
|
||
function sidebarStatePayload() {
|
||
// Runs on the pull path too (the chrome asks at boot), not only on pushes —
|
||
// a fresh profile otherwise showed the quiet add-ons until something re-emitted.
|
||
try { autoHideQuietDock(); } catch {}
|
||
return {
|
||
visible: sidebarVisible,
|
||
active: sidebarActivePanelId,
|
||
panels: addonHost ? addonHost.getSidebarPanels() : [],
|
||
toolbarMenus: addonHost ? addonHost.getToolbarMenus() : [],
|
||
dock: {
|
||
order: Array.isArray(settings.dockOrder) ? settings.dockOrder : [],
|
||
hidden: Array.isArray(settings.dockHidden) ? settings.dockHidden : [],
|
||
},
|
||
};
|
||
}
|
||
// Add-ons whose manifest says dock:"hidden" — Shield and Cookie Pop-ups,
|
||
// whose settings live under Settings › Performance and whose panel is for
|
||
// the details — start hidden from the toolbar dock. Done once per add-on,
|
||
// so a user who shows the button keeps it.
|
||
function autoHideQuietDock() {
|
||
if (!addonHost) return;
|
||
const seen = new Set(Array.isArray(settings.dockAutoHidden) ? settings.dockAutoHidden : []);
|
||
const hidden = new Set(Array.isArray(settings.dockHidden) ? settings.dockHidden : []);
|
||
let changed = false;
|
||
for (const a of addonHost.getInstalled()) {
|
||
const id = a.manifest && a.manifest.id;
|
||
if (!id || a.manifest.dock !== "hidden" || seen.has(id)) continue;
|
||
for (const k of dockKeys()) if (dockAddonId(k) === id) hidden.add(k);
|
||
seen.add(id); changed = true;
|
||
}
|
||
if (changed) { settings.dockHidden = [...hidden]; settings.dockAutoHidden = [...seen]; saveSettings(); }
|
||
}
|
||
function emitSidebarState() {
|
||
try { chrome?.webContents.send("sidebar-state", sidebarStatePayload()); } catch {}
|
||
}
|
||
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;
|
||
// A CALLER-supplied panelId that isn't registered used to silently fall
|
||
// back to panels[0], which surfaced the wrong add-on (Settings › Privacy
|
||
// › VPN opening Aegis whenever the VPN add-on was disabled). Fall back
|
||
// only when the id came from restore state; a specific request is a no-op.
|
||
let panel = panels.find((p) => p.panelId === wantId);
|
||
if (!panel) {
|
||
if (panelId) { console.warn(`setSidebar: no panel "${panelId}"; ignoring`); return; }
|
||
panel = 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 {}
|
||
emitSidebarState();
|
||
} else {
|
||
sidebarVisible = false;
|
||
sidebar.setVisible(false);
|
||
layout();
|
||
try { sidebar.webContents.send("sidebar-visibility", false); } catch {}
|
||
emitSidebarState();
|
||
}
|
||
}
|
||
function showPwFill(show, matches) {
|
||
if (!pwFillPop) return;
|
||
if (show) {
|
||
if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches))) return;
|
||
positionPwFill();
|
||
win.contentView.removeChildView(pwFillPop);
|
||
win.contentView.addChildView(pwFillPop);
|
||
pwFillPop.setVisible(true); pwfVisible = true;
|
||
pwFillPop.webContents.send("pw-matches", { matches: matches || [] });
|
||
} else { cancelOverlayShow(pwFillPop); 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 || "" }));
|
||
}
|
||
// The host of what the tab is showing right now. t.prov.host is set by our own
|
||
// navigations only, so it goes stale on Back/Forward and server redirects —
|
||
// matching credentials against it offered (and filled) bank.com's login on
|
||
// whatever page was actually loaded.
|
||
function liveHost(t) {
|
||
try {
|
||
const u = new URL(t.view.webContents.getURL());
|
||
return u.protocol === "http:" || u.protocol === "https:" || u.protocol === "bns:" ? u.hostname.toLowerCase() : "";
|
||
} catch { return ""; }
|
||
}
|
||
// 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 ? liveHost(t) : "";
|
||
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;
|
||
// Re-check at fill time: the page may have navigated since the picker opened.
|
||
if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
|
||
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);
|
||
if (deferUntilOverlayLoaded(addressPicker, () => showAddressPicker(true, suggestions))) return;
|
||
positionAddressPicker();
|
||
win.contentView.removeChildView(addressPicker);
|
||
win.contentView.addChildView(addressPicker);
|
||
addressPicker.setVisible(true); apVisible = true;
|
||
addressPicker.webContents.send("address-suggest", { suggestions });
|
||
} else { cancelOverlayShow(addressPicker); addressPicker.setVisible(false); apVisible = false; }
|
||
}
|
||
// Public view of a download — no DownloadItem refs leak to the renderer.
|
||
const downloadsPublic = () => downloads.map((d) => ({ ...d }));
|
||
function emitDownloads() {
|
||
const pub = downloadsPublic();
|
||
try { chrome?.webContents.send("downloads", pub); } catch {}
|
||
if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {}
|
||
}
|
||
// Attach the will-download listener to the SHARED default session. Every tab's
|
||
// WebContents inherits it, so we catch downloads regardless of which tab
|
||
// initiated them (including anchor clicks with `download`, form posts serving
|
||
// attachments, and manual save-as gestures).
|
||
function installDownloadTracker() {
|
||
session.defaultSession.on("will-download", (_e, item, wc) => {
|
||
const url = item.getURL();
|
||
// Downloads initiated from an addon-file tab (the Screenshot editor's
|
||
// "Save" hits this via `<a download>` on a blob: URL) go straight to
|
||
// Downloads with a uniquified filename — Electron's default is to pop
|
||
// a Save As dialog, which the user has no way to answer from inside
|
||
// an add-on tab. This matches the ergonomics of the older, sidebar-
|
||
// driven saveCapture path.
|
||
try {
|
||
const addonTab = wc && tabs.find((t) => t.view && t.view.webContents === wc && t.addonId);
|
||
if (addonTab) {
|
||
const raw = item.getFilename() || "download.bin";
|
||
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "download.bin";
|
||
const dlDir = app.getPath("downloads");
|
||
let target = path.join(dlDir, safe);
|
||
if (fs.existsSync(target)) {
|
||
const ext = path.extname(safe);
|
||
const stem = safe.slice(0, safe.length - ext.length);
|
||
for (let i = 2; i < 10000; i++) {
|
||
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
|
||
if (!fs.existsSync(cand)) { target = cand; break; }
|
||
}
|
||
}
|
||
try { item.setSavePath(target); } catch {}
|
||
}
|
||
} catch {}
|
||
// Update installer? Route it to a fixed temp path, keep it out of the
|
||
// visible downloads list, drive updateDownloadState instead so the chip
|
||
// can show "ready to install" and one-click install-and-restart.
|
||
const isUpdate = updateAvailable && (url === updateAvailable.setupUrl || url === updateAvailable.portableUrl);
|
||
if (isUpdate) {
|
||
const dst = path.join(app.getPath("temp"), item.getFilename());
|
||
try { item.setSavePath(dst); } catch {}
|
||
updateDownloadTotal = item.getTotalBytes() || 0;
|
||
updateDownloadReceived = 0;
|
||
// The hash this download must match, taken now: a manifest refresh while
|
||
// it runs would otherwise compare it against the next release's hash.
|
||
// Only the installer is armable — the portable build can't go through
|
||
// the NSIS helper, so it has no expected hash here.
|
||
const expectedHash = url === updateAvailable.setupUrl ? String(updateAvailable.setupHash || "").toLowerCase() : "";
|
||
item.on("updated", () => {
|
||
updateDownloadReceived = item.getReceivedBytes();
|
||
updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal;
|
||
emitUpdateAvailable();
|
||
});
|
||
item.once("done", (_ev, state) => {
|
||
if (state !== "completed") {
|
||
updateDownloadState = "failed";
|
||
console.warn(`[update] silent fetch ${state}`);
|
||
emitUpdateAvailable();
|
||
return;
|
||
}
|
||
// NEVER mark "ready" without verifying the file hashes to what the
|
||
// manifest promised. Electron's DownloadItem has been observed to
|
||
// fire done/completed on truncated payloads (bad Content-Length,
|
||
// CDN cache truncation, mid-stream TLS reset the runtime swallowed),
|
||
// and 0.3.31's in-app updater then spawned a half-file as setup —
|
||
// NSIS integrity check failed silently and the browser was gone.
|
||
const savedPath = item.getSavePath() || dst;
|
||
const expected = expectedHash;
|
||
if (!expected) {
|
||
updateDownloadState = "failed";
|
||
console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`);
|
||
try { fs.unlinkSync(savedPath); } catch {}
|
||
emitUpdateAvailable();
|
||
return;
|
||
}
|
||
const crypto = require("node:crypto");
|
||
const hash = crypto.createHash("sha256");
|
||
const rs = fs.createReadStream(savedPath);
|
||
rs.on("data", (c) => hash.update(c));
|
||
rs.once("error", (e) => {
|
||
updateDownloadState = "failed";
|
||
console.warn(`[update] hash read failed: ${e.message}`);
|
||
try { fs.unlinkSync(savedPath); } catch {}
|
||
emitUpdateAvailable();
|
||
});
|
||
rs.once("end", () => {
|
||
const got = hash.digest("hex").toLowerCase();
|
||
if (got !== expected) {
|
||
updateDownloadState = "failed";
|
||
console.warn(`[update] SHA-256 mismatch: got ${got}, want ${expected} — refusing to arm install`);
|
||
try { fs.unlinkSync(savedPath); } catch {}
|
||
emitUpdateAvailable();
|
||
return;
|
||
}
|
||
updateDownloadPath = savedPath;
|
||
updateDownloadState = "ready";
|
||
// Drop the mark-of-the-web Chromium stamps on downloads. Our
|
||
// hash check above is the trust decision; the zone marker only
|
||
// makes Windows raise a security prompt if the file is ever
|
||
// started through the shell.
|
||
try { fs.unlinkSync(savedPath + ":Zone.Identifier"); } catch {}
|
||
console.log(`[update] silent fetch complete + verified: ${savedPath}`);
|
||
emitUpdateAvailable();
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
const id = nextDlId++;
|
||
const rec = {
|
||
id,
|
||
filename: item.getFilename(),
|
||
url: item.getURL(),
|
||
mime: item.getMimeType(),
|
||
total: item.getTotalBytes() || 0,
|
||
received: 0,
|
||
state: "progressing", // progressing | paused | completed | cancelled | interrupted
|
||
savePath: "",
|
||
startedAt: Date.now(),
|
||
};
|
||
downloads.unshift(rec);
|
||
dlItems.set(id, item);
|
||
emitDownloads();
|
||
item.on("updated", (_ev, state) => {
|
||
rec.state = state; // "progressing" | "interrupted"
|
||
rec.received = item.getReceivedBytes();
|
||
rec.total = item.getTotalBytes() || rec.total;
|
||
rec.savePath = item.getSavePath() || rec.savePath;
|
||
emitDownloads();
|
||
});
|
||
item.once("done", (_ev, state) => {
|
||
rec.state = state; // "completed" | "cancelled" | "interrupted"
|
||
rec.received = item.getReceivedBytes();
|
||
rec.savePath = item.getSavePath() || rec.savePath;
|
||
dlItems.delete(id);
|
||
emitDownloads();
|
||
});
|
||
});
|
||
}
|
||
// ---- background tab freezing ----
|
||
// A tab the user switches away from is frozen (Chromium's page lifecycle
|
||
// "frozen": no JS, timers, network callbacks or media) after a short grace,
|
||
// and thawed the moment it is shown again. Exempt: tabs marked "Keep running
|
||
// in background" from the tab menu (music, calls, live dashboards), dormant
|
||
// restored tabs (nothing loaded), tabs waiting on a page dialog. Uses the
|
||
// per-tab debugger applyFingerprint already keeps attached. Chromium only
|
||
// freezes hidden pages, which background tabs are.
|
||
const FREEZE_GRACE_MS = 1000;
|
||
async function setTabFrozen(tab, frozen) {
|
||
if (!tab || !!tab.frozen === frozen) return;
|
||
const wc = tab.view?.webContents;
|
||
if (!wc || wc.isDestroyed()) return;
|
||
try {
|
||
if (!wc.debugger.isAttached()) wc.debugger.attach("1.3");
|
||
await wc.debugger.sendCommand("Page.setWebLifecycleState", { state: frozen ? "frozen" : "active" });
|
||
tab.frozen = frozen;
|
||
} catch (e) { console.warn(`[tabs] ${frozen ? "freeze" : "thaw"} failed:`, e?.message); }
|
||
}
|
||
function scheduleFreeze(tab) {
|
||
if (!tab) return;
|
||
clearTimeout(tab.freezeTimer);
|
||
if (!settings.freezeBackgroundTabs || tab.keepRunning || tab.pending || !tab.url || tab.id === activeId) return;
|
||
tab.freezeTimer = setTimeout(() => {
|
||
if (tab.id === activeId || !tabs.includes(tab) || tab.keepRunning || tab.pending || tabHasPendingDialog(tab.id)) return;
|
||
setTabFrozen(tab, true);
|
||
}, FREEZE_GRACE_MS);
|
||
}
|
||
function thawTab(tab) {
|
||
if (!tab) return;
|
||
clearTimeout(tab.freezeTimer);
|
||
if (tab.frozen) setTabFrozen(tab, false);
|
||
}
|
||
// The setting turned off (or on): thaw everything (or freeze the background).
|
||
function applyFreezeSetting() {
|
||
for (const t of tabs) { if (settings.freezeBackgroundTabs && t.id !== activeId) scheduleFreeze(t); else thawTab(t); }
|
||
}
|
||
function setActive(id) {
|
||
const switching = id !== activeId;
|
||
const previous = switching ? tabs.find((x) => x.id === activeId) : null;
|
||
activeId = id;
|
||
// The incoming tab runs again before it is shown; the one left behind stops.
|
||
thawTab(tabs.find((x) => x.id === id));
|
||
if (previous) scheduleFreeze(previous);
|
||
// A fullscreen video does not follow the user to another tab.
|
||
if (switching && fsTabId != null && fsTabId !== id) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true);
|
||
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
|
||
if (epVisible) showEnginePicker(false);
|
||
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
|
||
// A user action that switches to a different tab (New Tab, Settings,
|
||
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
|
||
// the incoming tab hidden behind a maximized sidebar. Auto-restore the
|
||
// sidebar to its pre-max width so the tab is actually visible; the
|
||
// user can re-maximize when they're done.
|
||
if (switching && sidebarMaximized) setSidebarMaximized(false);
|
||
// Show the NEW active tab first, THEN hide the others. Reversing this
|
||
// order eliminates the "no tab is visible" frame on switch that made the
|
||
// tab strip flash — the compositor always has at least one tab view up.
|
||
const target = tabs.find((x) => x.id === id);
|
||
if (target) target.view.setVisible(true);
|
||
for (const t of tabs) if (t.id !== id) t.view.setVisible(false);
|
||
// Dormant restored tabs come to life on first activation.
|
||
if (target && target.pending) materializePending(target);
|
||
const t = activeTab();
|
||
// Track the last active tab that isn't an add-on-owned page so captureTab
|
||
// has a sensible fallback when the user re-triggers the dropdown from
|
||
// inside (say) the screenshot editor.
|
||
if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id;
|
||
if (t?.prov) pushNav(t.prov);
|
||
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
|
||
syncJsDialogVisibility();
|
||
notifyTabChange();
|
||
emitTabs();
|
||
if (t) emitTranslateState(t);
|
||
}
|
||
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 || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning })),
|
||
collapsedGroups: [...tabGroupCollapsed],
|
||
url: t?.url || "",
|
||
loading: !!t?.loading,
|
||
canBack: (() => { try { return !!wc && wc.navigationHistory.canGoBack(); } catch { return false; } })(),
|
||
canForward: (() => { try { return !!wc && wc.navigationHistory.canGoForward(); } catch { return false; } })(),
|
||
zoom: zoomPercent(t),
|
||
webapp: webapps.chipState(t),
|
||
});
|
||
}
|
||
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
|
||
// ---- page zoom ----
|
||
// Chrome's preset ladder. Zoom is applied with setZoomFactor, which
|
||
// Chromium keys per host for the session — so every tab on the same site
|
||
// shares the level, and navigating back to a site restores it, exactly
|
||
// like Chrome. Settings and add-on tabs never zoom.
|
||
const ZOOM_STEPS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500];
|
||
function zoomPercent(t) {
|
||
if (!t || t.settings || t.addonId) return 100;
|
||
try { return Math.round(t.view.webContents.getZoomFactor() * 100); } catch { return 100; }
|
||
}
|
||
function zoomStep(t, dir) {
|
||
if (!t || t.settings || t.addonId) return;
|
||
const cur = zoomPercent(t);
|
||
const next = dir > 0
|
||
? (ZOOM_STEPS.find((z) => z > cur) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1])
|
||
: ([...ZOOM_STEPS].reverse().find((z) => z < cur) ?? ZOOM_STEPS[0]);
|
||
zoomSet(t, next);
|
||
}
|
||
function zoomSet(t, percent) {
|
||
if (!t || t.settings || t.addonId) return;
|
||
try { t.view.webContents.setZoomFactor(Math.max(25, Math.min(500, percent)) / 100); } catch {}
|
||
emitTabs();
|
||
}
|
||
ipcMain.handle("zoom-step", (_e, dir) => zoomStep(activeTab(), Number(dir) > 0 ? 1 : -1));
|
||
ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100));
|
||
|
||
// ---- HTTP authentication (401 / 407 challenges) ----
|
||
// Without a `login` listener Electron cancels every challenge, so a site
|
||
// behind Basic/Digest auth just rendered the server's bare 401 page
|
||
// (silentmode.st/guardian/admin, 2026-09-15). One modal prompt at a time;
|
||
// concurrent challenges for the same host+realm (a page plus its
|
||
// subresources) share the first answer. Successful credentials are cached
|
||
// by Chromium's network service for the session, so a page's later
|
||
// requests don't re-prompt.
|
||
const authPrompts = new Map(); // key -> Promise<{username,password}|null>
|
||
const authPending = new Map(); // reqId -> resolve
|
||
let authSeq = 0, authQueue = Promise.resolve();
|
||
function promptHttpAuth(req) {
|
||
const run = () => new Promise((resolve) => {
|
||
if (!win || win.isDestroyed()) return resolve(null);
|
||
const id = ++authSeq;
|
||
const pw = new BrowserWindow({
|
||
parent: win, modal: true, show: false, width: 440, height: 340,
|
||
resizable: false, minimizable: false, maximizable: false, fullscreenable: false,
|
||
title: req.isProxy ? "Proxy sign-in" : "Sign in",
|
||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#1c222c" : "#ffffff",
|
||
autoHideMenuBar: true,
|
||
webPreferences: { preload: path.join(__dirname, "auth-prompt-preload.js") },
|
||
});
|
||
let settled = false;
|
||
const settle = (v) => { if (settled) return; settled = true; authPending.delete(id); resolve(v); if (!pw.isDestroyed()) pw.close(); };
|
||
authPending.set(id, settle);
|
||
pw.on("closed", () => settle(null));
|
||
pw.webContents.on("did-finish-load", () => { pw.webContents.send("auth-show", { id, ...req }); pw.show(); });
|
||
pw.loadFile(path.join(__dirname, "auth-prompt.html")).catch(() => settle(null));
|
||
});
|
||
const p = authQueue.then(run, run);
|
||
authQueue = p.catch(() => {});
|
||
return p;
|
||
}
|
||
ipcMain.handle("auth-answer", (e, id, creds) => {
|
||
const settle = authPending.get(Number(id));
|
||
if (!settle) return;
|
||
// Only the prompt window itself may answer.
|
||
const isPrompt = [...BrowserWindow.getAllWindows()].some((w) => w.webContents === e.sender && w.getParentWindow() === win);
|
||
if (!isPrompt) return;
|
||
settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null);
|
||
});
|
||
app.on("login", (event, _wc, details, authInfo, callback) => {
|
||
event.preventDefault();
|
||
// Proxy auth configured by an add-on is answered with its credentials.
|
||
if (authInfo?.isProxy && proxyAuth) return callback(proxyAuth.username, proxyAuth.password);
|
||
const host = authInfo?.host || "";
|
||
const port = authInfo?.port;
|
||
const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://"))
|
||
+ host + (port && port !== 80 && port !== 443 ? ":" + port : "");
|
||
const key = `${authInfo?.isProxy ? "proxy" : "site"}|${host}:${port}|${authInfo?.realm || ""}`;
|
||
let p = authPrompts.get(key);
|
||
if (!p) {
|
||
p = promptHttpAuth({ origin, realm: authInfo?.realm || "", scheme: authInfo?.scheme || "", isProxy: !!authInfo?.isProxy,
|
||
insecure: !authInfo?.isProxy && String(details?.url || "").startsWith("http:") });
|
||
authPrompts.set(key, p);
|
||
// Share only while the prompt is open. Once answered, a fresh challenge
|
||
// for the same realm means the server rejected those credentials, and
|
||
// the user must be asked again (Chromium caches accepted ones itself).
|
||
p.finally(() => authPrompts.delete(key));
|
||
}
|
||
p.then((c) => { if (c) callback(c.username, c.password); else callback(); }, () => callback());
|
||
});
|
||
// 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();
|
||
// file: is normally our own surface (home/error pages) and never shown;
|
||
// a tab the user pointed at a local file is the exception.
|
||
if (raw && (!raw.startsWith("file:") || tab.prov?.kind === "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, local: failedUrl.startsWith("file:") };
|
||
t.view.webContents.loadFile(path.join(__dirname, "error.html"), { search: q })
|
||
.catch((e) => console.warn("error page load failed:", e?.message))
|
||
.finally(() => { t.internalNav = false; });
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
}
|
||
// Scrollbar theme injected into every webContents we own — tabs, chrome,
|
||
// sidebar, all the floating popovers, and every add-on panel host. Track
|
||
// picks up a subtle neutral grey (works on both dark and light surfaces
|
||
// without hardcoding either); thumb is the BCH primary #0AC18E so every
|
||
// scroll surface reads as Silent Mode's. `scrollbar-color` is the modern
|
||
// standard (Chromium ≥ 121); the ::-webkit- fallback gives us fine control
|
||
// over width, radius and hover state on older engines. `!important` on the
|
||
// track / thumb wins over per-page overrides so the branding stays visible
|
||
// even on sites that theme their own scrollbars — but we deliberately don't
|
||
// force `scrollbar-width` so a page that has hidden its scrollbars entirely
|
||
// keeps that behaviour.
|
||
const SCROLLBAR_CSS = `
|
||
html { scrollbar-color: #0AC18E rgba(120,130,150,0.18); }
|
||
::-webkit-scrollbar { width: 12px; height: 12px; background: rgba(120,130,150,0.18) !important; }
|
||
::-webkit-scrollbar-track { background: rgba(120,130,150,0.18) !important; }
|
||
::-webkit-scrollbar-thumb { background: #0AC18E !important; border-radius: 6px;
|
||
border: 2px solid transparent; background-clip: padding-box !important; }
|
||
::-webkit-scrollbar-thumb:hover { background: #14e0a5 !important; background-clip: padding-box !important; }
|
||
::-webkit-scrollbar-corner { background: transparent !important; }
|
||
`;
|
||
function styleScrollbars(wc) {
|
||
if (!wc) return;
|
||
const inject = () => { try { wc.insertCSS(SCROLLBAR_CSS); } catch {} };
|
||
wc.on("dom-ready", inject);
|
||
// For a wc that's already past dom-ready when we attach (fixed views load
|
||
// fast during startup), fire once explicitly.
|
||
try { if (!wc.isLoading()) inject(); } catch {}
|
||
}
|
||
function createTab(initial, opts = {}) {
|
||
const id = ++tabSeq;
|
||
// Non-settings tabs get home-preload so the built-in home page can round-
|
||
// trip its editable-cards state via IPC. IPC handlers reject any call
|
||
// whose sender URL isn't our own home.html, so a third-party page sees
|
||
// the API's shape but can't act through it.
|
||
// Preload picker: settings and add-on-file tabs each need their own IPC
|
||
// surface; everything else gets home-preload (superset of a plain web
|
||
// page's needs, plus the home-page card wiring).
|
||
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
|
||
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
|
||
: path.join(__dirname, "home-preload.js");
|
||
const view = new WebContentsView({ webPreferences: { preload: preloadPath } });
|
||
// Explicit solid background: transparent (Electron default) makes the tab
|
||
// view flash to whatever's underneath (which can be the just-hidden tab or
|
||
// black) between setVisible(true) and the first paint on tab switch. A
|
||
// solid ground kills that flash. Colour tracks the system theme so light-
|
||
// mode users don't get a dark stub while a page paints.
|
||
try { view.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
|
||
const wc = view.webContents;
|
||
styleScrollbars(wc);
|
||
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
||
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
||
applyFingerprint(wc);
|
||
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null, openerId: opts.after ?? null };
|
||
// A tab opened from a link goes right after the tab it came from — and
|
||
// after any siblings that tab already opened, so a run of links from one
|
||
// page reads left-to-right — instead of at the far end of the strip.
|
||
// Everything else (+ button, restore, add-on requests) appends as before.
|
||
const openerIdx = opts.after != null ? tabs.findIndex((t) => t.id === opts.after) : -1;
|
||
if (openerIdx < 0) tabs.push(tab);
|
||
else {
|
||
let at = openerIdx + 1;
|
||
while (at < tabs.length && tabs[at].openerId === opts.after) at++;
|
||
tabs.splice(at, 0, 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(); }
|
||
});
|
||
// HTML fullscreen (see enterHtmlFullscreen): the page gets the whole window.
|
||
wc.on("enter-html-full-screen", () => enterHtmlFullscreen(tab));
|
||
wc.on("leave-html-full-screen", () => leaveHtmlFullscreen(tab));
|
||
// Chromium fires found-in-page on every findInPage call + on subsequent
|
||
// match walks. Forward to chrome so the find bar shows "N of M".
|
||
wc.on("found-in-page", (_e, r) => {
|
||
if (tab.id !== activeId) return;
|
||
try { chrome?.webContents.send("find-result", { activeMatchOrdinal: r.activeMatchOrdinal, matches: r.matches, finalUpdate: r.finalUpdate }); } catch {}
|
||
});
|
||
// A new document means a new (or no) web-app manifest; the chip follows.
|
||
wc.on("did-navigate", () => { tab.webapp = null; });
|
||
// A Settings (or add-on) tab the user navigates elsewhere is an ordinary tab
|
||
// from then on; otherwise openSettingsTab keeps focusing a tab that no
|
||
// longer shows Settings.
|
||
wc.on("did-navigate", () => {
|
||
if (!tab.settings && !tab.addonId) return;
|
||
let u = ""; try { u = wc.getURL() || ""; } catch {}
|
||
if (/^file:/i.test(u)) return;
|
||
tab.settings = false; tab.addonId = null; tab.prov = null;
|
||
});
|
||
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); });
|
||
// Navigations Chromium makes on its own (Back/Forward, redirects, links to
|
||
// unregistered hosts) never pass through navigateTab, which is what sets
|
||
// prov — keep the site badge on the host actually loaded.
|
||
wc.on("did-navigate", () => {
|
||
let u; try { u = new URL(wc.getURL()); } catch { return; }
|
||
if (u.protocol !== "http:" && u.protocol !== "https:") return;
|
||
const host = u.hostname.toLowerCase();
|
||
if (tab.prov && tab.prov.host === host) return;
|
||
tab.prov = { host, kind: "web" };
|
||
if (tab.id === activeId) pushNav(tab.prov);
|
||
});
|
||
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
|
||
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
|
||
// Translator state is per-document: a new navigation drops any "translated"
|
||
// flag and clears the cached page language. The chip then re-decides on
|
||
// the next pageLang read whether to light up for this new page.
|
||
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
|
||
if (!isMainFrame) return;
|
||
tab._tr = null; tab.pageLang = "";
|
||
if (tab.id === activeId) emitTranslateState(tab);
|
||
});
|
||
// After the page has committed, read <html lang> once so the chip knows
|
||
// what language the server actually served (which may not match whatever
|
||
// we asked for via Accept-Language).
|
||
wc.on("did-finish-load", async () => {
|
||
try {
|
||
const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`);
|
||
tab.pageLang = String(lang || "").toLowerCase().split("-")[0];
|
||
} catch { tab.pageLang = ""; }
|
||
if (tab.id === activeId) emitTranslateState(tab);
|
||
});
|
||
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
|
||
// Linux, the zoom itself is up to us.
|
||
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
|
||
wc.on("did-start-loading", () => setLoading(tab, true));
|
||
wc.on("did-stop-loading", () => setLoading(tab, false));
|
||
// Installable site? Read its manifest once the document is in; the chip
|
||
// and the page's own beforeinstallprompt follow from tab.webapp.
|
||
wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (tab.id === activeId) emitTabs(); }).catch(() => {}); });
|
||
// 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 installId = installLinkId(u);
|
||
if (installId) {
|
||
e.preventDefault();
|
||
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
|
||
installExtensionWithConsent(installId, requester);
|
||
return;
|
||
}
|
||
// Same rule as navigateTab: privileged tabs hand any non-file target to a new tab.
|
||
if ((tab.settings || tab.addonId) && !/^file:/i.test(u)) {
|
||
e.preventDefault();
|
||
navigateTab(id, u);
|
||
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();
|
||
// Only our interstitial may post a choice — any page could otherwise
|
||
// persist "always ICANN"/"always BCNR" for a name or a whole TLD.
|
||
if (!isAppPage(wc, "collision.html")) return;
|
||
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);
|
||
}
|
||
// A wiz:// click never navigates — it hands the pairing URI to the
|
||
// wallet and leaves the dapp exactly where it is.
|
||
if (parsed.protocol === "wiz:") {
|
||
e.preventDefault();
|
||
routeWizUri(u, pageOriginOf(wc.getURL()), tab.id);
|
||
return;
|
||
}
|
||
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;
|
||
// Cross-host too: when the warm index already says the target isn't a
|
||
// BCNR name, there is nothing for navigateTab to add — and replaying
|
||
// it via loadURL would turn a form POST into a bodyless GET (OAuth
|
||
// form_post, SAML, 3-D Secure, login forms posting to auth.<site>).
|
||
if (knownUnregistered(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 }) => {
|
||
// target="_blank" on a wiz:// link lands here rather than will-navigate.
|
||
// Route it to the wallet instead of opening a tab on an unloadable URL.
|
||
if (url && /^wiz:/i.test(url)) {
|
||
routeWizUri(url, pageOriginOf(wc.getURL()), tab.id);
|
||
return { action: "deny" };
|
||
}
|
||
const installId = installLinkId(url);
|
||
if (installId) {
|
||
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
|
||
installExtensionWithConsent(installId, requester);
|
||
} else if (url && url !== "about:blank") {
|
||
// Chromium won't let a web page navigate to file://, chrome:// or
|
||
// theseus://, but createTab → loadURL runs from main and would. Web
|
||
// pages get web schemes only; our own file:// pages keep the rest.
|
||
let opener = ""; try { opener = new URL(wc.getURL()).protocol; } catch {}
|
||
let target = ""; try { target = new URL(url).protocol; } catch {}
|
||
if (opener === "file:" || ["http:", "https:", "bns:"].includes(target)) {
|
||
createTab(url, { background: disposition === "background-tab", after: tab.id });
|
||
}
|
||
}
|
||
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, { after: tab.id }) },
|
||
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true, after: tab.id }) },
|
||
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
|
||
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
|
||
{ type: "separator" },
|
||
);
|
||
}
|
||
// Image context menu: only when the pointer is actually on an image, and
|
||
// we have a src to act on. Save-image-as triggers will-download with no
|
||
// preset savePath, so Electron shows the native Save As dialog.
|
||
if (p.mediaType === "image" && p.srcURL) {
|
||
items.push(
|
||
{ label: "Open image in new tab", click: () => createTab(p.srcURL, { after: tab.id }) },
|
||
{ label: "Save image as…", click: () => wc.downloadURL(p.srcURL) },
|
||
{ label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } },
|
||
{ label: "Copy image address", click: () => clipboard.writeText(p.srcURL) },
|
||
{ type: "separator" },
|
||
);
|
||
}
|
||
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
|
||
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
|
||
// "Search for …" when text is selected. Label uses a short excerpt so
|
||
// a long selection doesn't stretch the menu. Opens in a new foreground
|
||
// tab so the current page isn't lost — matches Chrome / Firefox UX.
|
||
if (p.selectionText) {
|
||
const raw = p.selectionText.replace(/\s+/g, " ").trim();
|
||
if (raw) {
|
||
const excerpt = raw.length > 40 ? raw.slice(0, 40) + "…" : raw;
|
||
items.push(
|
||
{ label: `Search for "${excerpt.replace(/&/g, "&&")}"`, click: () => createTab(SEARCH(raw), { after: tab.id }) },
|
||
{ type: "separator" },
|
||
);
|
||
}
|
||
}
|
||
// Add-on context-menu items. Add-ons that declare "context-menu-item"
|
||
// filter by `when` (selectionText / linkURL / editable / image /
|
||
// always) matched against Electron's params. Click dispatches the
|
||
// "context-menu" message to the add-on with the full context.
|
||
try {
|
||
const addonItems = addonHost ? addonHost.getContextMenuItems({
|
||
selectionText: p.selectionText, linkURL: p.linkURL,
|
||
mediaType: p.mediaType, srcURL: p.srcURL, isEditable: p.isEditable,
|
||
pageURL: p.pageURL,
|
||
}) : [];
|
||
if (addonItems.length) {
|
||
for (const it of addonItems) {
|
||
const label = (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id);
|
||
items.push({ label, click: () => {
|
||
const payload = {
|
||
itemId: it.id,
|
||
selectionText: p.selectionText || "",
|
||
linkURL: p.linkURL || "",
|
||
mediaType: p.mediaType || "",
|
||
srcURL: p.srcURL || "",
|
||
pageURL: p.pageURL || (wc && wc.getURL()) || "",
|
||
host: (() => { try { return new URL(p.pageURL || wc.getURL()).host; } catch { return ""; } })(),
|
||
};
|
||
addonHost.dispatch(it.addonId, "context-menu", payload, { from: "context-menu" })
|
||
.catch((err) => console.warn(`[addons] context-menu ${it.addonId}.${it.id} failed:`, err?.message || err));
|
||
}});
|
||
}
|
||
items.push({ type: "separator" });
|
||
}
|
||
} catch (err) { console.warn("context-menu addon merge failed:", err?.message || err); }
|
||
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() },
|
||
);
|
||
if (tab.webapp) {
|
||
const inst = webapps.find(tab.webapp.key);
|
||
items.push({ type: "separator" }, inst
|
||
? { label: `Open ${tab.webapp.name} in its window`, click: () => webapps.open(inst) }
|
||
: { label: `Install ${tab.webapp.name}…`, click: () => installWebAppFromTab(tab) });
|
||
}
|
||
Menu.buildFromTemplate(items).popup();
|
||
});
|
||
layout();
|
||
if (opts.background) { view.setVisible(false); emitTabs(); }
|
||
else setActive(id);
|
||
if (opts.pending) {
|
||
// Lazy / dormant: the tab lives in the strip with its saved metadata but
|
||
// nothing loads until setActive consumes `pending`. Only valid on
|
||
// background tabs — createTab's caller never asks for a foreground tab
|
||
// that is also dormant (restoreTabs enforces this).
|
||
tab.pending = { url: opts.pending.url, title: opts.pending.title || "", favicon: opts.pending.favicon || null };
|
||
tab.url = opts.pending.url;
|
||
if (opts.pending.title) tab.title = opts.pending.title;
|
||
if (opts.pending.favicon) tab.favicon = opts.pending.favicon;
|
||
emitTabs();
|
||
} else if (opts.settings) {
|
||
tab.prov = { host: "", kind: "home" };
|
||
const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined;
|
||
wc.loadFile("settings.html", settingsOpts);
|
||
if (id === activeId) pushNav(tab.prov);
|
||
emitTabs();
|
||
} else if (opts.addonFile) {
|
||
// Same treatment as settings: leave the address bar empty (refreshTabUrl
|
||
// skips file:// anyway), title arrives via page-title-updated. loadFile
|
||
// takes the query as `search` (Node's url.format shape) without the ?.
|
||
tab.prov = { host: "", kind: "home" };
|
||
tab.addonId = opts.addonFile.addonId;
|
||
wc.loadFile(opts.addonFile.absPath, opts.addonFile.query ? { search: opts.addonFile.query } : undefined);
|
||
if (id === activeId) pushNav(tab.prov);
|
||
emitTabs();
|
||
} else if (initial) navigateTab(id, initial);
|
||
else loadHome(id);
|
||
return id;
|
||
}
|
||
// First activation of a dormant restored tab: navigate to its saved URL.
|
||
// Called from setActive and reload — both the "I'm looking at this tab" and
|
||
// "I want it to load" entry points consume `pending`.
|
||
function materializePending(tab) {
|
||
if (!tab || !tab.pending) return false;
|
||
const url = tab.pending.url;
|
||
tab.pending = null;
|
||
try { navigateTab(tab.id, url); } catch (e) { console.warn("materializePending failed:", e?.message); }
|
||
return true;
|
||
}
|
||
function closeTab(id) {
|
||
const i = tabs.findIndex((t) => t.id === id);
|
||
if (i < 0) return;
|
||
const [t] = tabs.splice(i, 1);
|
||
if (fsTabId === id) leaveHtmlFullscreen(t, true);
|
||
dismissJsDialogFor(id);
|
||
win.contentView.removeChildView(t.view);
|
||
t.view.webContents.destroy?.();
|
||
if (tabs.length === 0) { createTab(); return; }
|
||
if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id);
|
||
else emitTabs();
|
||
}
|
||
|
||
function createWindow() {
|
||
chromeReadyDone = false;
|
||
overlaysPrewarmed = false;
|
||
overlayLoads.length = 0; // views of a previous window, if any
|
||
overlayWant.clear();
|
||
sessionSavedAtClose = false;
|
||
if (tabs.length) { // leftovers from a window that closed while app windows kept the process alive
|
||
for (const t of tabs.splice(0)) { try { t.view.webContents.destroy?.(); } catch {} }
|
||
activeId = null;
|
||
}
|
||
// Window ground + chrome view ground both match chrome.html's --bg for the
|
||
// active theme. The chrome view used to sit on Chromium's default white
|
||
// until chrome.html painted, which is the "white strip over a dark
|
||
// window" users saw on a slow launch.
|
||
const uiBg = nativeTheme.shouldUseDarkColors ? "#0f1420" : "#e6e8ec";
|
||
// On Windows the tab strip lives in the title bar: the frame is hidden and
|
||
// the native minimise/maximise/close buttons are drawn as an overlay over
|
||
// our chrome, whose tab row is a drag region (chrome.html). That returns
|
||
// the OS title bar's height to the page. Kept native elsewhere.
|
||
const titleBarOverlay = { color: uiBg, symbolColor: nativeTheme.shouldUseDarkColors ? "#e7eaf1" : "#1a1f28", height: 40 };
|
||
const frameOpts = process.platform === "win32" ? { titleBarStyle: "hidden", titleBarOverlay } : {};
|
||
win = new BrowserWindow({
|
||
width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg, ...frameOpts,
|
||
// 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"),
|
||
});
|
||
// Keep the overlay buttons on the theme when it flips at runtime.
|
||
if (process.platform === "win32") {
|
||
nativeTheme.on("updated", () => {
|
||
if (!win || win.isDestroyed()) return;
|
||
const dark = nativeTheme.shouldUseDarkColors;
|
||
try { win.setTitleBarOverlay({ color: dark ? "#0f1420" : "#e6e8ec", symbolColor: dark ? "#e7eaf1" : "#1a1f28", height: 40 }); } catch {}
|
||
});
|
||
}
|
||
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
|
||
try { chrome.setBackgroundColor(uiBg); } catch {}
|
||
win.contentView.addChildView(chrome);
|
||
styleScrollbars(chrome.webContents);
|
||
chrome.webContents.loadFile("chrome.html");
|
||
// The overlays below are created now (cheap) but their pages load via
|
||
// deferOverlayLoad: each page loads on first use, or in the idle prewarm.
|
||
// Floating site-info overlay (hidden until the address-bar badge is clicked).
|
||
popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } });
|
||
try { popover.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(popover);
|
||
styleScrollbars(popover.webContents);
|
||
deferOverlayLoad(popover, "popover.html");
|
||
popover.setVisible(false);
|
||
// Floating engine-picker overlay (custom dropdown with real favicons).
|
||
enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } });
|
||
try { enginePicker.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(enginePicker);
|
||
styleScrollbars(enginePicker.webContents);
|
||
deferOverlayLoad(enginePicker, "engine-picker.html");
|
||
enginePicker.setVisible(false);
|
||
// Floating downloads panel — shows active + recent downloads.
|
||
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
|
||
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(downloadsPop);
|
||
styleScrollbars(downloadsPop.webContents);
|
||
deferOverlayLoad(downloadsPop, "downloads.html");
|
||
downloadsPop.setVisible(false);
|
||
clickAwayPopups.length = 0;
|
||
closeOnClickAway(popover, () => popVisible, () => showPopover(false));
|
||
closeOnClickAway(enginePicker, () => epVisible, () => showEnginePicker(false));
|
||
closeOnClickAway(downloadsPop, () => dlVisible, () => showDownloads(false));
|
||
win.on("blur", closePopupsOnWindowBlur);
|
||
// Floating address-bar suggestions dropdown.
|
||
addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } });
|
||
try { addressPicker.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(addressPicker);
|
||
styleScrollbars(addressPicker.webContents);
|
||
deferOverlayLoad(addressPicker, "address-picker.html");
|
||
addressPicker.setVisible(false);
|
||
// Floating password-fill picker.
|
||
pwFillPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "pw-fill-preload.js") } });
|
||
try { pwFillPop.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(pwFillPop);
|
||
styleScrollbars(pwFillPop.webContents);
|
||
deferOverlayLoad(pwFillPop, "pw-fill.html");
|
||
pwFillPop.setVisible(false);
|
||
// Link-hover status pill (bottom-left of window).
|
||
linkStatus = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "link-status-preload.js") } });
|
||
try { linkStatus.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(linkStatus);
|
||
styleScrollbars(linkStatus.webContents);
|
||
deferOverlayLoad(linkStatus, "link-status.html");
|
||
linkStatus.setVisible(false);
|
||
// Add-on sidebar host. Doesn't loadFile until an add-on panel is opened —
|
||
// styleScrollbars hooks dom-ready, which fires per navigation, so every
|
||
// panel loaded into this view (Aegis, Screenshot, etc.) picks up the
|
||
// brand scrollbar the moment its DOM is ready.
|
||
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
|
||
win.contentView.addChildView(sidebar);
|
||
styleScrollbars(sidebar.webContents);
|
||
sidebar.setVisible(false);
|
||
// Opera-style quick-links strip on the left edge: a thin vertical column
|
||
// with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking
|
||
// one opens that service in a dedicated mini-tab (the quickPanel next door)
|
||
// rather than a full-sized tab.
|
||
quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } });
|
||
try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {}
|
||
win.contentView.addChildView(quicklinks);
|
||
styleScrollbars(quicklinks.webContents);
|
||
quicklinks.webContents.loadFile("quicklinks.html");
|
||
// Dedicated mini-view that renders the currently-active quick-link in its
|
||
// own narrow column, Opera-style. Lives permanently in the window; shown /
|
||
// hidden via activeQuickLinkId. It shares the default session with the
|
||
// tabs (and so their cookies) on purpose: Tor/proxy, the permission
|
||
// handlers, the request filter and the bns:// protocol are all installed
|
||
// on session.defaultSession, and a separate partition would silently go
|
||
// without every one of them.
|
||
// Third-party sites only — no preload (home-preload's navigate/cards API
|
||
// has no business here), and its popups become ordinary tabs instead of
|
||
// bare Electron windows outside every tab protection.
|
||
quickPanel = new WebContentsView();
|
||
quickPanel.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (url && /^(?:https?|bns):/i.test(url)) createTab(url);
|
||
return { action: "deny" };
|
||
});
|
||
try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
|
||
win.contentView.addChildView(quickPanel);
|
||
styleScrollbars(quickPanel.webContents);
|
||
quickPanel.setVisible(false);
|
||
// Add-on approval overlay (approval-modal capability). Transparent view
|
||
// over the tab area, loaded once, shown per request.
|
||
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
|
||
try { approvalPop.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(approvalPop);
|
||
styleScrollbars(approvalPop.webContents);
|
||
deferOverlayLoad(approvalPop, "approval.html");
|
||
approvalPop.setVisible(false);
|
||
// Page dialogs (alert / confirm / prompt) — see the js-dialog block.
|
||
jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } });
|
||
try { jsDialogPop.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(jsDialogPop);
|
||
styleScrollbars(jsDialogPop.webContents);
|
||
deferOverlayLoad(jsDialogPop, "js-dialog.html");
|
||
jsDialogPop.setVisible(false);
|
||
// Everything that isn't needed to paint the toolbar waits for chrome.html.
|
||
// dom-ready, NOT did-finish-load: the load event also waits for every
|
||
// subresource, and the bookmarks bar pulls its favicons over bns:// —
|
||
// a BNS lookup plus a network fetch each. On a slow link that held the
|
||
// load event (and with it every restored tab) for seconds while the
|
||
// toolbar sat blank. By dom-ready the toolbar's scripts have run and its
|
||
// IPC listeners exist, which is all the rest of boot needs. The fallback
|
||
// timer covers a chrome.html that fails to load at all.
|
||
chrome.webContents.once("dom-ready", onChromeReady);
|
||
setTimeout(onChromeReady, 8000);
|
||
win.on("resize", layout);
|
||
win.on("enter-full-screen", layout);
|
||
// Fullscreen left from outside (the OS, or Electron on the page's behalf)
|
||
// while a page still held it: put the toolbar back and tell the page.
|
||
win.on("leave-full-screen", () => {
|
||
userFullscreen = false;
|
||
if (fsTabId != null) { // the window is already on its way out: only the page needs telling
|
||
const t = tabs.find((x) => x.id === fsTabId); fsTabId = null;
|
||
try { t?.view.webContents.executeJavaScript("document.fullscreenElement && document.exitFullscreen(); 0", true).catch(() => {}); } catch {}
|
||
}
|
||
layout();
|
||
});
|
||
// The browser window can close while app windows (webapps.js) keep the
|
||
// process alive. Capture the session while the tabs are still here, then
|
||
// let go of the window's views: tab events (hover → update-target-url,
|
||
// title updates) keep arriving during teardown and used to reach the
|
||
// destroyed window through positionLinkStatus (2026-09-27).
|
||
win.on("close", () => { saveSession(); sessionSavedAtClose = true; });
|
||
win.on("closed", () => {
|
||
win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null;
|
||
dismissJsDialogFor(null);
|
||
// Same for wallet approvals: a request left current would block
|
||
// pumpApproval (and every later dapp request) until a full restart.
|
||
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
|
||
for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} }
|
||
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null;
|
||
linkStatusVisible = false;
|
||
});
|
||
layout();
|
||
}
|
||
|
||
async function navigateTab(id, input) {
|
||
const t = tabById(id); if (!t) return;
|
||
thawTab(t);
|
||
// A pending tab the user navigates explicitly (URL bar, link follow,
|
||
// search) has outgrown its saved URL — otherwise a later reload or chip
|
||
// click would snap it back to that stale URL via materializePending.
|
||
if (t.pending) t.pending = null;
|
||
let q = String(input).trim();
|
||
if (!q) return;
|
||
// theseus://extensions/install/<id> typed or pasted into the address bar.
|
||
const installId = installLinkId(q);
|
||
if (installId) { installExtensionWithConsent(installId, null); return; }
|
||
// theseus://settings, theseus://settings/<section>: a linkable address for
|
||
// every Settings page.
|
||
const settingsLink = /^theseus:\/\/settings(?:\/([a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?))?\/?$/i.exec(q);
|
||
if (settingsLink) { openSettingsTab(settingsLink[1] || ""); return; }
|
||
// A Settings or add-on tab keeps its privileged preload for the life of its
|
||
// WebContents, so it never loads anything else: the target opens in a fresh
|
||
// tab in its place.
|
||
if (t.settings || t.addonId) { createTab(q, { after: id }); closeTab(id); return; }
|
||
// Local paths open as files — never BCNR, never a search.
|
||
const fileUrl = localFileUrl(q);
|
||
if (fileUrl) return loadLocalFile(t, id, fileUrl);
|
||
// data:/blob: (e.g. "Open image in new tab" on an inline image) aren't
|
||
// scheme:// URLs, so they used to fall through to the search below — which
|
||
// sent the whole image to the search engine. Load them as they are.
|
||
if (/^(?:data|blob):/i.test(q)) {
|
||
t.url = q; t.prov = { host: "", kind: "web" };
|
||
await t.view.webContents.loadURL(q).catch(() => {});
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
return;
|
||
}
|
||
if (/^javascript:/i.test(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();
|
||
}
|
||
|
||
// Open a local file (file:// URL) in a tab. A missing file surfaces through
|
||
// did-fail-load → loadErrorPage like any other failed navigation.
|
||
async function loadLocalFile(t, id, fileUrl) {
|
||
t.url = fileUrl;
|
||
t.prov = { host: "", kind: "file" };
|
||
t.bcnrOffer = null;
|
||
if (id === activeId) chrome.webContents.send("bcnr-offer", null);
|
||
setLoading(t, true);
|
||
t.nav = (t.nav || 0) + 1;
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
try { await t.view.webContents.loadURL(fileUrl); }
|
||
catch (e) { console.warn("local file load failed:", e?.message); }
|
||
}
|
||
|
||
// Load a name from BCNR into a tab. Called for every dotted host — BCNR is
|
||
// tried first; on NXDOMAIN or resolver failure we always fall through to the
|
||
// clearnet (https://<host><rest>) so the user isn't stranded when the chain
|
||
// is down or the name isn't registered.
|
||
async function loadBns(t, id, host, rest, tld) {
|
||
const registry = registryOf(tld);
|
||
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
|
||
const fallbackToWeb = async (reason) => {
|
||
t.url = "https://" + host + (rest === "/" ? "" : rest);
|
||
t.internalNav = true;
|
||
try { await t.view.webContents.loadURL(t.url); }
|
||
catch (e) { console.warn("fallback loadURL failed:", e?.message); }
|
||
finally { t.internalNav = false; }
|
||
t.prov = { host, kind: "web", note: `fallback: ${reason}`, tld, registry };
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
};
|
||
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
|
||
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
|
||
// (harmless) if absent.
|
||
let urlChoice = null;
|
||
try {
|
||
const u = new URL(rest, `bns://${host}/`);
|
||
if (u.searchParams.has("_collision")) {
|
||
urlChoice = u.searchParams.get("_collision");
|
||
u.searchParams.delete("_collision");
|
||
rest = u.pathname + (u.search ? u.search : "");
|
||
}
|
||
} catch {}
|
||
let entry;
|
||
try { entry = await resolveHost(host); }
|
||
catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
|
||
// Address-bar display: show https:// even for BCDN sites. Rationale — BCNR
|
||
// replaces DNS (name resolution), NOT HTTP (transport). For s3/ip/p records
|
||
// the actual delivery IS HTTPS under the hood; for h records the content is
|
||
// on-chain (no HTTP at all, but https:// is the least surprising display).
|
||
// The BCDN/ICANN badge is the source-of-truth for which registry served us;
|
||
// the URL scheme is a convention, kept consistent so users' muscle memory
|
||
// holds. (bns:// is an internal Electron protocol implementation detail.)
|
||
t.url = "https://" + host + (rest === "/" ? "" : rest);
|
||
if (!entry) { setLoading(t, false); return fallbackToWeb("no BCNR record"); }
|
||
|
||
// ---- Collision policy (BCNR ↔ ICANN) --------------------------------------
|
||
// BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's,
|
||
// no collision possible). Otherwise it's a collision candidate — the same name
|
||
// *might* also exist on ICANN; the policy decides which to load.
|
||
// Full model: SilentMode/Argus/DESIGN-collision-modes.md.
|
||
if (!isBcnrNativeTld(tld)) {
|
||
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
|
||
const transient = t.collisionOverride; t.collisionOverride = null;
|
||
const policy = settings.collisionPolicy || "bcnr-first";
|
||
// Precedence: urlChoice (one-shot from collision-choose) > transient (chip
|
||
// switcher) > persistent per-name/per-TLD > global policy.
|
||
let choice = urlChoice || transient || overrideFor(host, tld);
|
||
if (!choice) {
|
||
if (policy === "icann-first") choice = "icann";
|
||
else if (policy === "soft") {
|
||
// In-tab full-page "Open with…" prompt (loaded from disk; buttons post
|
||
// back through bns://collision-choose/ which serveBns handles above).
|
||
setLoading(t, false);
|
||
const q = new URLSearchParams({ host, tld, resturl: rest }).toString();
|
||
await t.view.webContents.loadFile(path.join(__dirname, "collision.html"), { search: q });
|
||
t.prov = { host, kind: "resolving", tld, registry };
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
return;
|
||
} else choice = "bcnr"; // bcnr-first — the default
|
||
}
|
||
if (choice === "icann") { setLoading(t, false); return fallbackToWeb("collision → ICANN"); }
|
||
}
|
||
|
||
await t.view.webContents.loadURL(`bns://${host}${rest}`);
|
||
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
|
||
// routes via the parent's server, not via Sia. See serveBns for the rule.
|
||
const _isSub = host !== entry.name;
|
||
const src = (_isSub && entry.records.ip) ? "direct server"
|
||
: entry.records.h ? "on-chain (chain)"
|
||
: entry.records.s3 ? "Sia network"
|
||
: entry.records.ip ? "direct server"
|
||
: (!_isSub && entry.records.p) ? "mirror"
|
||
: entry.records.u ? "redirect"
|
||
: "record";
|
||
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), dns: dnsRecordKinds(entry), tld, registry };
|
||
if (id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
}
|
||
|
||
// The toolbar and our own home page only — home-preload is in every tab, and a
|
||
// web page must not steer the active tab (or a Settings tab) from the background.
|
||
ipcMain.handle("navigate", (e, input) => {
|
||
if (chrome && e.sender === chrome.webContents) return navigateTab(activeId, input);
|
||
if (!isHomePageSender(e.sender)) return;
|
||
const t = tabs.find((x) => x.view.webContents === e.sender);
|
||
return navigateTab(t ? t.id : 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-keep-running", (_e, id, on) => {
|
||
const t = tabById(id); if (!t) return false;
|
||
t.keepRunning = typeof on === "boolean" ? on : !t.keepRunning;
|
||
if (t.keepRunning) thawTab(t); else scheduleFreeze(t);
|
||
emitTabs();
|
||
return t.keepRunning;
|
||
});
|
||
ipcMain.handle("tab-mute", (_e, id, on) => {
|
||
const t = tabById(id); if (!t) return false;
|
||
const want = typeof on === "boolean" ? on : !t.muted;
|
||
try { t.view.webContents.setAudioMuted(want); t.muted = want; emitTabs(); return want; }
|
||
catch { return t.muted; }
|
||
});
|
||
ipcMain.handle("tab-group", (_e, id, color) => {
|
||
const t = tabById(id); if (!t) return null;
|
||
// color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
|
||
const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]);
|
||
const next = allowed.has(color) ? color : null;
|
||
t.group = next;
|
||
// Cluster: move this tab so all same-group tabs sit contiguously. Place
|
||
// it right after the LAST existing tab of that group; if there are no
|
||
// other members yet, leave it in place. When a tab is removed from a
|
||
// group (color === null) we don't reorder — the visual break is enough.
|
||
if (next) {
|
||
const idx = tabs.indexOf(t);
|
||
let insertAfter = -1;
|
||
for (let i = 0; i < tabs.length; i++) {
|
||
if (i !== idx && tabs[i].group === next) insertAfter = i;
|
||
}
|
||
if (insertAfter !== -1) {
|
||
tabs.splice(idx, 1);
|
||
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
|
||
tabs.splice(dst, 0, t);
|
||
}
|
||
}
|
||
emitTabs();
|
||
return t.group;
|
||
});
|
||
// Groups get a collapsed/expanded state, per-color, in-memory only (resets
|
||
// on relaunch). Flipping this doesn't touch tabs — the renderer just hides
|
||
// tabs whose group is collapsed and shows the group chip in their place.
|
||
const tabGroupCollapsed = new Set(); // colors currently collapsed
|
||
ipcMain.handle("tab-group-toggle", (_e, color) => {
|
||
if (!color) return false;
|
||
if (tabGroupCollapsed.has(color)) tabGroupCollapsed.delete(color); else tabGroupCollapsed.add(color);
|
||
// Piggy-back on emitTabs so the chrome renderer receives the change.
|
||
emitTabs();
|
||
return tabGroupCollapsed.has(color);
|
||
});
|
||
ipcMain.handle("tab-bookmark", (_e, id) => {
|
||
const t = tabById(id); if (!t) return false;
|
||
const url = t.url; const title = t.title || url;
|
||
if (!url) return false;
|
||
if (bookmarks.some((b) => b.url === url)) return true; // already saved
|
||
bookmarks.unshift({ url, title, addedAt: Date.now() });
|
||
saveBookmarks(); emitBookmarks();
|
||
return true;
|
||
});
|
||
// Native tab context-menu popup. Anchored at the (chrome-view-relative)
|
||
// point the renderer sends. Prevents the visible "chrome view expands to
|
||
// hold a DOM menu" gap between the tabs and the tab content — the OS-owned
|
||
// popup floats above every WebContentsView and doesn't affect layout at
|
||
// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) /
|
||
// Add to Bookmarks / Mute / Close.
|
||
ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender");
|
||
const t = tabById(tabId);
|
||
if (!t) return false;
|
||
const colorLabels = [
|
||
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
|
||
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
|
||
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
|
||
{ id: "purple", label: "Purple" },
|
||
];
|
||
const groupSubmenu = [
|
||
{ label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } },
|
||
{ type: "separator" },
|
||
...colorLabels.map((c) => ({
|
||
label: c.label + (t.group === c.id ? " ✓" : ""),
|
||
click: () => {
|
||
t.group = c.id;
|
||
// Cluster tabs of the same colour, matching the "tab-group" IPC.
|
||
const idx = tabs.indexOf(t);
|
||
let insertAfter = -1;
|
||
for (let i = 0; i < tabs.length; i++) {
|
||
if (i !== idx && tabs[i].group === c.id) insertAfter = i;
|
||
}
|
||
if (insertAfter !== -1) {
|
||
tabs.splice(idx, 1);
|
||
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
|
||
tabs.splice(dst, 0, t);
|
||
}
|
||
emitTabs();
|
||
},
|
||
})),
|
||
];
|
||
const bmDisabled = !t.url;
|
||
const template = [
|
||
{ label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } },
|
||
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url, { after: t.id }); else createTab(null, { after: t.id }); } },
|
||
{ label: "Group", submenu: groupSubmenu },
|
||
{ label: "Add to Bookmarks", enabled: !bmDisabled, click: () => {
|
||
if (bmDisabled) return;
|
||
const url = t.url, title = t.title || url;
|
||
if (!bookmarks.some((b) => b.url === url)) {
|
||
bookmarks.unshift({ url, title, addedAt: Date.now() });
|
||
saveBookmarks(); emitBookmarks();
|
||
}
|
||
} },
|
||
{ label: t.muted ? "Unmute" : "Mute", click: () => {
|
||
try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {}
|
||
} },
|
||
{ type: "separator" },
|
||
{ label: "Close", click: () => closeTab(t.id) },
|
||
];
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||
popup.popup({ window: win, x, y });
|
||
return true;
|
||
});
|
||
// Website-language quick switch — a native menu anchored under the toolbar
|
||
// pill. Same setting the Anti-fingerprinting Language row edits: "auto"
|
||
// (languageMode="show", follow OS) or a specific BCP-47 tag
|
||
// (languageMode="manual", languageValue=<tag>). One truth source, two entry
|
||
// points. Applied via applyAcceptLanguage() + applyFingerprintAll() below.
|
||
// Labels are the language's own name in its own script — no BCP-47 tag in the
|
||
// label. The tag only surfaces as the 2-letter chip in the URL bar once picked.
|
||
// Only the original of each language: English is UK (en-GB), Spanish is Spain
|
||
// (es-ES), Portuguese is Portugal (pt-PT). Regional variants are the same 2-
|
||
// letter chip and ~same text, so they aren't separate rows. Ordered by global
|
||
// speakers, European languages first.
|
||
const WEBSITE_LANGUAGE_QUICK = [
|
||
// European (ranked by global speakers)
|
||
{ tag: "en-GB", label: "English" },
|
||
{ tag: "es-ES", label: "Español" },
|
||
{ tag: "fr-FR", label: "Français" },
|
||
{ tag: "pt-PT", label: "Português" },
|
||
{ tag: "ru-RU", label: "Русский" },
|
||
{ tag: "de-DE", label: "Deutsch" },
|
||
{ tag: "it-IT", label: "Italiano" },
|
||
{ tag: "tr-TR", label: "Türkçe" },
|
||
{ tag: "pl-PL", label: "Polski" },
|
||
{ tag: "nl-NL", label: "Nederlands" },
|
||
{ tag: "el-GR", label: "Ελληνικά" },
|
||
{ tag: "cs-CZ", label: "Čeština" },
|
||
{ tag: "sv-SE", label: "Svenska" },
|
||
{ tag: "fi-FI", label: "Suomi" },
|
||
// Non-European (ranked by global speakers)
|
||
{ tag: "zh-CN", label: "中文(简体)" },
|
||
{ tag: "hi-IN", label: "हिन्दी" },
|
||
{ tag: "ar", label: "العربية" },
|
||
{ tag: "id-ID", label: "Bahasa Indonesia" },
|
||
{ tag: "ja-JP", label: "日本語" },
|
||
{ tag: "vi-VN", label: "Tiếng Việt" },
|
||
{ tag: "ko-KR", label: "한국어" },
|
||
{ tag: "th-TH", label: "ไทย" },
|
||
{ tag: "he-IL", label: "עברית" },
|
||
{ tag: "zh-TW", label: "中文(繁體)" },
|
||
];
|
||
function setWebsiteLanguage(mode, value) {
|
||
const before = { mode: settings.languageMode, value: settings.languageValue };
|
||
if (mode === "show") {
|
||
settings.languageMode = "show";
|
||
} else if (mode === "manual" && value) {
|
||
settings.languageMode = "manual";
|
||
settings.languageValue = String(value);
|
||
} else return;
|
||
const changed = before.mode !== settings.languageMode || before.value !== settings.languageValue;
|
||
saveSettings();
|
||
applyFingerprintAll();
|
||
applyAcceptLanguage();
|
||
broadcastSettings();
|
||
// Nothing short of a reload makes a loaded page re-render in a new language —
|
||
// the server picked the body from the Accept-Language on the ORIGINAL request.
|
||
// The chip is a one-click language switch, so the user expects to see it take
|
||
// effect on whatever they're looking at: reload the active tab (only).
|
||
if (changed) {
|
||
const t = activeTab();
|
||
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
|
||
try { wc && wc.reload(); } catch {}
|
||
}
|
||
}
|
||
// Human-readable name for a BCP-47 tag — the plain language name, no regional
|
||
// qualifier ("en-US" → "English", not "American English"). The picker only
|
||
// shows one entry per language, so the regional half of a tag is noise in
|
||
// labels; pass the base ("en") to Intl.DisplayNames to get the clean name.
|
||
function languageNameFor(tag) {
|
||
const base = String(tag || "").split("-")[0];
|
||
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(base) || tag; }
|
||
catch { return tag; }
|
||
}
|
||
// ---- page translator -------------------------------------------------------
|
||
// The user's own language, as a BCP-47 base tag ("en", "lt", "pt"): the
|
||
// preferred-language setting if they picked one; otherwise their OS locale.
|
||
// Base-only, because the translator cares about language, not region — a
|
||
// user in "en-GB" wants English, not British-English-but-not-American.
|
||
function translationTargetBase() {
|
||
const tag = settings.languageMode === "manual" && settings.languageValue
|
||
? settings.languageValue
|
||
: app.getLocale() || "en-US";
|
||
return String(tag).split("-")[0].toLowerCase() || "en";
|
||
}
|
||
// LibreTranslate POST /translate. `q` may be a single string or an array; the
|
||
// response's `translatedText` is a string or array to match. One POST per
|
||
// call — the caller chunks when the batch would exceed the request budget.
|
||
async function translatorPostOnce(url, texts, from, to) {
|
||
const body = {
|
||
q: texts,
|
||
source: from || "auto",
|
||
target: to,
|
||
format: "text",
|
||
};
|
||
if (settings.translateApiKey) body.api_key = settings.translateApiKey;
|
||
// A peer whose host is a BNS name (libre.x, lingua.x, …) can't be reached
|
||
// by Chromium's net stack directly — those names aren't in ICANN DNS. Rewrite
|
||
// the request URL to bns:// so the in-process serveBns handler resolves the
|
||
// name (p-record = reverse-proxy to the upstream + path concatenation).
|
||
const target = (await targetUrlFor(url)) || url;
|
||
// session.defaultSession.fetch goes through Electron's own network stack,
|
||
// so Tor (session proxy) and any add-on proxy settings apply the same way
|
||
// they do for a tab's fetch; Node's global fetch would bypass both.
|
||
const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession);
|
||
const r = await sfetch(target, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(45000),
|
||
});
|
||
if (!r.ok) {
|
||
let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {}
|
||
throw new Error(`HTTP ${r.status}${detail ? ": " + detail : ""}`);
|
||
}
|
||
const data = await r.json();
|
||
if (Array.isArray(data.translatedText)) return data.translatedText;
|
||
if (typeof data.translatedText === "string") return [data.translatedText];
|
||
// Some LibreTranslate deployments return a bare array of {translatedText}.
|
||
if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? "");
|
||
throw new Error("unexpected response shape");
|
||
}
|
||
// The configured peer list, sanitised: a legacy single `translateEndpoint`
|
||
// migrates to list[0], an empty list falls back to the shipped defaults.
|
||
function translatorPeers() {
|
||
const list = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : [];
|
||
const peers = list.map((s) => String(s || "").trim()).filter(Boolean);
|
||
if (peers.length) return peers;
|
||
const legacy = String(settings.translateEndpoint || "").trim();
|
||
if (legacy) return [legacy];
|
||
return SETTINGS_DEFAULTS.translateEndpoints.slice();
|
||
}
|
||
// Try each peer in order; return the first good answer, or throw the last
|
||
// error. A peer that answers with a non-2xx (needs-API-key, 502, rate limit)
|
||
// is skipped. The ordering is preserved — a user who put their own instance
|
||
// first keeps it as the primary; the list is a fallback chain, not a pool.
|
||
async function translatorLibreTranslate(texts, from, to) {
|
||
const peers = translatorPeers();
|
||
const errors = [];
|
||
for (const url of peers) {
|
||
try { return await translatorPostOnce(url, texts, from, to); }
|
||
catch (e) { errors.push(`${url}: ${e?.message || e}`); console.warn(`[translate] peer failed ${url}:`, e?.message || e); }
|
||
}
|
||
throw new Error(`all ${peers.length} translator peer(s) failed — ${errors[errors.length - 1] || "no detail"}`);
|
||
}
|
||
// Chunk a texts array so each POST stays under a reasonable size — LibreTranslate
|
||
// instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST
|
||
// is also slower to recover from an upstream drop than four 12-page POSTs.
|
||
const TRANSLATE_CHUNK_CHARS = 3800;
|
||
function chunkTexts(texts) {
|
||
const chunks = [[]]; let charsInLast = 0;
|
||
for (const t of texts) {
|
||
const len = t.length + 2;
|
||
if (charsInLast + len > TRANSLATE_CHUNK_CHARS && chunks[chunks.length - 1].length > 0) {
|
||
chunks.push([]); charsInLast = 0;
|
||
}
|
||
chunks[chunks.length - 1].push(t);
|
||
charsInLast += len;
|
||
}
|
||
return chunks.filter((c) => c.length > 0);
|
||
}
|
||
async function translateAll(texts, from, to) {
|
||
if (!Array.isArray(texts) || texts.length === 0) return [];
|
||
const chunks = chunkTexts(texts);
|
||
const out = [];
|
||
for (const chunk of chunks) {
|
||
const got = await translatorLibreTranslate(chunk, from, to);
|
||
for (const s of got) out.push(s);
|
||
}
|
||
return out;
|
||
}
|
||
// Injected into the target page's renderer. Walks the body for visible text
|
||
// nodes, filters out script/style/code/pre and blank runs, saves originals
|
||
// on a window-scoped slot so revert can put them back without reloading, and
|
||
// returns the texts + the page's declared language. The slots never leak
|
||
// across pages (did-navigate drops the renderer context).
|
||
const PAGE_TRANSLATE_EXTRACT = `(() => {
|
||
const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","CODE","PRE","TEXTAREA"]);
|
||
const nodes = [], texts = [];
|
||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||
let n; while ((n = walker.nextNode())) {
|
||
const s = n.nodeValue; if (!s || !s.trim()) continue;
|
||
let p = n.parentElement, skip = false;
|
||
while (p && p !== document.body) {
|
||
if (SKIP.has(p.tagName) || p.isContentEditable) { skip = true; break; }
|
||
p = p.parentElement;
|
||
}
|
||
if (skip) continue;
|
||
nodes.push(n); texts.push(s);
|
||
}
|
||
window.__theseusTranslate = { nodes, originals: texts.slice(), translated: null };
|
||
return { sourceLang: (document.documentElement.lang || "").toLowerCase(), texts };
|
||
})()`;
|
||
// Second-phase apply. Takes a translated-strings array (same order as the
|
||
// extract output), substitutes each node's nodeValue, and remembers the
|
||
// translated set so a second call to this script with "revert" can restore.
|
||
function pageTranslateApplyScript(translated) {
|
||
const payload = JSON.stringify(translated);
|
||
return `(() => {
|
||
const state = window.__theseusTranslate; if (!state) return 0;
|
||
const arr = ${payload};
|
||
for (let i = 0; i < state.nodes.length && i < arr.length; i++) {
|
||
if (arr[i] != null) state.nodes[i].nodeValue = arr[i];
|
||
}
|
||
state.translated = arr.slice();
|
||
return state.nodes.length;
|
||
})()`;
|
||
}
|
||
const PAGE_TRANSLATE_REVERT = `(() => {
|
||
const state = window.__theseusTranslate; if (!state) return 0;
|
||
for (let i = 0; i < state.nodes.length && i < state.originals.length; i++) {
|
||
state.nodes[i].nodeValue = state.originals[i];
|
||
}
|
||
state.translated = null;
|
||
return state.nodes.length;
|
||
})()`;
|
||
// A tab tracks its own translator state so chip + context menu + revert know
|
||
// what to show. { translated: false, source: "", target: "", error?: "" } —
|
||
// mutated in-place and broadcast via emitTranslateState so chrome can react.
|
||
function tabTranslateState(t) {
|
||
if (!t._tr) t._tr = { translated: false, source: "", target: "", error: "" };
|
||
return t._tr;
|
||
}
|
||
function emitTranslateState(t) {
|
||
if (!t || t.id !== activeId) return;
|
||
try { chrome?.webContents.send("page-translate-state", { ...tabTranslateState(t), pageLang: t.pageLang || "" }); } catch {}
|
||
}
|
||
async function translateActiveTab(target) {
|
||
const t = activeTab(); if (!t) return { ok: false, error: "no tab" };
|
||
if (t.settings || t.addonId || t.pending) return { ok: false, error: "not a web page" };
|
||
const st = tabTranslateState(t);
|
||
const to = (String(target || translationTargetBase()).split("-")[0] || "en").toLowerCase();
|
||
const wc = t.view.webContents;
|
||
try {
|
||
const extracted = await wc.executeJavaScript(PAGE_TRANSLATE_EXTRACT);
|
||
const texts = extracted?.texts || [];
|
||
if (!texts.length) { st.error = "nothing to translate"; emitTranslateState(t); return { ok: false, error: st.error }; }
|
||
const from = extracted.sourceLang || "auto";
|
||
const translated = await translateAll(texts, from, to);
|
||
await wc.executeJavaScript(pageTranslateApplyScript(translated));
|
||
st.translated = true; st.source = from; st.target = to; st.error = "";
|
||
emitTranslateState(t);
|
||
return { ok: true, source: from, target: to, count: translated.length };
|
||
} catch (e) {
|
||
st.translated = false; st.error = e?.message || String(e);
|
||
emitTranslateState(t);
|
||
console.warn("[translate] failed:", st.error);
|
||
return { ok: false, error: st.error };
|
||
}
|
||
}
|
||
async function revertActiveTab() {
|
||
const t = activeTab(); if (!t) return { ok: false };
|
||
if (t.settings || t.addonId || t.pending) return { ok: false };
|
||
const st = tabTranslateState(t);
|
||
try { await t.view.webContents.executeJavaScript(PAGE_TRANSLATE_REVERT); } catch (e) { console.warn("[translate] revert failed:", e?.message); }
|
||
st.translated = false; st.error = "";
|
||
emitTranslateState(t);
|
||
return { ok: true };
|
||
}
|
||
ipcMain.handle("page-translate", (e, target) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate: untrusted sender");
|
||
return translateActiveTab(target);
|
||
});
|
||
ipcMain.handle("page-translate-revert", (e) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-revert: untrusted sender");
|
||
return revertActiveTab();
|
||
});
|
||
ipcMain.handle("page-translate-state", (e) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-state: untrusted sender");
|
||
const t = activeTab(); if (!t) return null;
|
||
return { ...tabTranslateState(t), pageLang: t.pageLang || "" };
|
||
});
|
||
// Dropdown off the URL-bar translate chip. "Translate to <target>" is the
|
||
// primary action; "Revert" shows while the page is translated; "More
|
||
// languages…" opens Settings › General so the user can pick a different
|
||
// target or edit the backend.
|
||
ipcMain.handle("page-translate-menu-popup", (e, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-menu-popup: untrusted sender");
|
||
const t = activeTab(); const st = t ? tabTranslateState(t) : null;
|
||
const target = translationTargetBase();
|
||
const template = [
|
||
st?.translated
|
||
? { label: `Revert to ${languageNameFor(st.source || "auto")}`, click: () => revertActiveTab() }
|
||
: { label: `Translate this page to ${languageNameFor(target)}`, click: () => translateActiveTab() },
|
||
{ type: "separator" },
|
||
{ label: "Change target language…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||
{ label: "Translator settings…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||
];
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||
popup.popup({ window: win, x, y });
|
||
return true;
|
||
});
|
||
ipcMain.handle("system-locale", () => app.getLocale() || "en-US");
|
||
ipcMain.handle("website-language-menu-popup", (e, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");
|
||
const osLoc = app.getLocale() || "en-US";
|
||
const isAuto = settings.languageMode === "show";
|
||
const current = isAuto ? null : (settings.languageValue || "").toLowerCase();
|
||
const template = [
|
||
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
|
||
click: () => setWebsiteLanguage("show") },
|
||
{ type: "separator" },
|
||
...WEBSITE_LANGUAGE_QUICK.map((L) => ({
|
||
label: `${L.label}${current === L.tag.toLowerCase() ? " ✓" : ""}`,
|
||
click: () => setWebsiteLanguage("manual", L.tag),
|
||
})),
|
||
{ type: "separator" },
|
||
{ label: "More languages…", click: () => { try { openSettingsTab("general"); } catch {} } },
|
||
];
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||
popup.popup({ window: win, x, y });
|
||
return true;
|
||
});
|
||
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
|
||
const src = tabs.findIndex((t) => t.id === id);
|
||
const dst = tabs.findIndex((t) => t.id === targetId);
|
||
if (src < 0 || dst < 0 || src === dst) return;
|
||
const [t] = tabs.splice(src, 1);
|
||
const insertAt = tabs.findIndex((x) => x.id === targetId);
|
||
tabs.splice(place === "after" ? insertAt + 1 : insertAt, 0, t);
|
||
emitTabs();
|
||
});
|
||
ipcMain.handle("go-home", () => loadHome(activeId));
|
||
// --- Add-on framework -------------------------------------------------------
|
||
// Sidebar toggle + panel switching, driven from the chrome toolbar. `panelId`
|
||
// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no
|
||
// coercion, main matches it verbatim.
|
||
ipcMain.handle("sidebar-toggle", () => { toggleSidebar(); return sidebarVisible; });
|
||
// Drag events stream in from sidebar-preload while the user is holding the
|
||
// grip. Delta is px per mousemove; we clamp, layout, and debounce the save.
|
||
let _sidebarSaveTimer = null;
|
||
ipcMain.handle("sidebar-drag", (_e, deltaPx) => {
|
||
const d = Number(deltaPx) || 0;
|
||
// Drag-to-resize exits maximize mode — the user is asking for a specific
|
||
// width. We snap out of maximize first so the delta lands on the pre-max
|
||
// width rather than on the (huge) maximized value.
|
||
if (sidebarMaximized) {
|
||
sidebarMaximized = false;
|
||
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
|
||
try { sidebar?.webContents.send("sidebar-max-change", false); } catch {}
|
||
}
|
||
const next = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarW + d));
|
||
if (next === sidebarW) return sidebarW;
|
||
sidebarW = next;
|
||
layout();
|
||
settings.sidebarWidth = sidebarW;
|
||
clearTimeout(_sidebarSaveTimer);
|
||
_sidebarSaveTimer = setTimeout(saveSettings, 400);
|
||
return sidebarW;
|
||
});
|
||
ipcMain.handle("sidebar-open", (_e, panelId) => { setSidebar(true, panelId); return sidebarVisible; });
|
||
ipcMain.handle("sidebar-close", () => { setSidebar(false); return false; });
|
||
// Panel-driven sidebar maximize: fills the window with the sidebar (tab
|
||
// area shrinks to zero-width), remembering the pre-max width so restore
|
||
// returns cleanly. Doesn't persist across sessions — a fresh launch
|
||
// always starts at the saved settings.sidebarWidth. Layout runs so tab
|
||
// views and other floating popovers reposition against the new bounds.
|
||
function setSidebarMaximized(next) {
|
||
const wanted = !!next;
|
||
if (wanted === sidebarMaximized) return sidebarMaximized;
|
||
if (wanted) {
|
||
sidebarPreMaxW = sidebarW;
|
||
sidebarW = sidebarMaxWidth();
|
||
} else {
|
||
sidebarW = Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, sidebarPreMaxW || SIDEBAR_W_DEFAULT));
|
||
}
|
||
sidebarMaximized = wanted;
|
||
layout();
|
||
try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {}
|
||
return sidebarMaximized;
|
||
}
|
||
ipcMain.handle("sidebar-maximize", () => setSidebarMaximized(true));
|
||
ipcMain.handle("sidebar-restore", () => setSidebarMaximized(false));
|
||
ipcMain.handle("sidebar-toggle-max", () => setSidebarMaximized(!sidebarMaximized));
|
||
ipcMain.handle("sidebar-is-max", () => sidebarMaximized);
|
||
ipcMain.handle("sidebar-state", () => sidebarStatePayload());
|
||
// ---- extension dock: reorder by drag, right-click menu --------------------
|
||
// Keys match the chrome's: "p:<panelId>" for a sidebar panel button,
|
||
// "m:<addonId>" for a toolbar-menu button. The full key list in the order
|
||
// the chrome would draw it (prefs applied) is computed here so a move is
|
||
// resolved against what the user actually sees.
|
||
function dockKeys() {
|
||
const keys = [];
|
||
if (addonHost) {
|
||
for (const p of addonHost.getSidebarPanels()) if (!p.plugin) keys.push("p:" + p.panelId);
|
||
for (const m of addonHost.getToolbarMenus()) if (!m.plugin) keys.push("m:" + m.addonId);
|
||
}
|
||
const order = Array.isArray(settings.dockOrder) ? settings.dockOrder : [];
|
||
const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; };
|
||
return keys.map((k, i) => ({ k, i })).sort((a, b) => rank(a.k) - rank(b.k) || a.i - b.i).map((x) => x.k);
|
||
}
|
||
function dockLabel(key) {
|
||
if (!addonHost) return key;
|
||
if (key.startsWith("p:")) { const p = addonHost.getSidebarPanels().find((x) => "p:" + x.panelId === key); return p ? (p.addonName || p.title || key) : key.slice(2); }
|
||
const m = addonHost.getToolbarMenus().find((x) => "m:" + x.addonId === key);
|
||
return m ? (m.title || m.addonId) : key.slice(2);
|
||
}
|
||
const dockAddonId = (key) => key.startsWith("p:") ? key.slice(2).split(":")[0] : key.slice(2);
|
||
function dockMove(key, targetKey, place) {
|
||
const keys = dockKeys();
|
||
if (!keys.includes(key) || !keys.includes(targetKey) || key === targetKey) return false;
|
||
const rest = keys.filter((k) => k !== key);
|
||
const at = rest.indexOf(targetKey) + (place === "after" ? 1 : 0);
|
||
rest.splice(at, 0, key);
|
||
settings.dockOrder = rest;
|
||
saveSettings();
|
||
emitSidebarState();
|
||
return true;
|
||
}
|
||
function dockSetHidden(key, hide) {
|
||
const hidden = new Set(Array.isArray(settings.dockHidden) ? settings.dockHidden : []);
|
||
if (hide) hidden.add(key); else hidden.delete(key);
|
||
settings.dockHidden = [...hidden];
|
||
saveSettings();
|
||
emitSidebarState();
|
||
}
|
||
ipcMain.handle("dock-move", (e, key, targetKey, place) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("dock-move: untrusted sender");
|
||
if (typeof key !== "string" || typeof targetKey !== "string") return false;
|
||
return dockMove(key, targetKey, place === "after" ? "after" : "before");
|
||
});
|
||
ipcMain.handle("dock-menu", (e, key, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("dock-menu: untrusted sender");
|
||
const keys = dockKeys();
|
||
const visible = keys.filter((k) => !(settings.dockHidden || []).includes(k));
|
||
const hiddenKeys = (settings.dockHidden || []).filter((k) => keys.includes(k));
|
||
const template = [];
|
||
if (typeof key === "string" && keys.includes(key)) {
|
||
const name = dockLabel(key);
|
||
const pos = visible.indexOf(key);
|
||
if (key.startsWith("p:")) {
|
||
const pid = key.slice(2);
|
||
const open = sidebarVisible && sidebarActivePanelId === pid;
|
||
template.push({ label: open ? `Close ${name}` : `Open ${name}`, click: () => setSidebar(!open, pid) });
|
||
}
|
||
template.push({ type: "separator" });
|
||
template.push({ label: "Move left", enabled: pos > 0, click: () => dockMove(key, visible[pos - 1], "before") });
|
||
template.push({ label: "Move right", enabled: pos >= 0 && pos < visible.length - 1, click: () => dockMove(key, visible[pos + 1], "after") });
|
||
template.push({ type: "separator" });
|
||
template.push({ label: "Hide from toolbar", click: () => dockSetHidden(key, true) });
|
||
template.push({ label: `Turn off ${name}`, click: () => setAddonEnabled(dockAddonId(key), false) });
|
||
template.push({ type: "separator" });
|
||
}
|
||
if (hiddenKeys.length) {
|
||
template.push({ label: "Show hidden", submenu: hiddenKeys.map((k) => ({ label: dockLabel(k), click: () => dockSetHidden(k, false) })) });
|
||
}
|
||
template.push({ label: "Manage extensions…", click: () => openSettingsTab("addons") });
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
popup.popup({ window: win, x: Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))), y: Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))) });
|
||
return true;
|
||
});
|
||
// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the
|
||
// user picked. Route to the add-on's registered "menu-select" handler. We
|
||
// trust chrome as the sender (same convention as sidebar-toggle et al) —
|
||
// it's the only WebContents we ever load chrome.html into.
|
||
ipcMain.handle("addon-menu-select", async (e, addonId, itemId) => {
|
||
if (!addonHost) throw new Error("addon host not ready");
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("addon-menu-select: untrusted sender");
|
||
const id = String(addonId || "");
|
||
const iid = String(itemId || "");
|
||
if (!id || !iid) throw new Error("addon-menu-select: addonId and itemId required");
|
||
// Confirm the menu item was actually declared by this add-on — a rogue
|
||
// renderer message can't invoke a handler with an item id the manifest
|
||
// never listed.
|
||
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
|
||
if (!menu) throw new Error(`addon-menu-select: no toolbar menu for "${id}"`);
|
||
if (!menu.items.find((it) => it.id === iid)) throw new Error(`addon-menu-select: item "${iid}" not declared by "${id}"`);
|
||
return addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" });
|
||
});
|
||
// Toolbar-menu popup — render as a NATIVE OS menu anchored at the button.
|
||
// A renderer-DOM popover in chrome.html gets clipped by the chrome view's
|
||
// own bounds (height = CHROME_H) and then hidden behind the tab view below
|
||
// it. Menu.popup uses an OS window, so it can extend anywhere.
|
||
ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => {
|
||
if (!addonHost) throw new Error("addon host not ready");
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("toolbar-menu-popup: untrusted sender");
|
||
const id = String(addonId || "");
|
||
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
|
||
if (!menu) throw new Error(`toolbar-menu-popup: no menu for "${id}"`);
|
||
// Capture the clicked item id here; dispatch runs from the popup's
|
||
// `callback` below, AFTER the menu is torn down. Firing synchronously
|
||
// in the click handler catches the parent window still non-foreground
|
||
// (the OS menu popup is on top), which leaves Chromium's occlusion
|
||
// tracker marking the tab view as hidden — WebContents.capturePage()
|
||
// then snapshots a blank frame at the correct dimensions (not a 0x0
|
||
// that our retry could catch). Deferring until after callback lets
|
||
// focus return to the parent so the compositor is live at capture time.
|
||
let picked = null;
|
||
const template = menu.items.map((it) => ({
|
||
label: (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id),
|
||
click: () => { picked = it.id; },
|
||
}));
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||
popup.popup({ window: win, x, y, callback: () => {
|
||
try { chrome?.webContents.send("toolbar-menu-closed"); } catch {}
|
||
if (!picked) return; // user hit Escape or clicked outside
|
||
// Explicitly return foreground to the parent window; on some Windows
|
||
// configurations Electron's popup teardown alone leaves the app in a
|
||
// "not-quite-foreground" state until the next OS message pump tick.
|
||
try { win?.focus(); } catch {}
|
||
// Settle before the handler runs. Windows takes several frames to
|
||
// restore foreground and un-throttle the compositor; the previous
|
||
// 120 ms was too short on slower / higher-latency setups and led to
|
||
// blank frames. 250 ms is what the "full page" mode already uses.
|
||
// captureTab itself no longer relies on capturePage anyway (it goes
|
||
// through CDP Page.captureScreenshot, which forces a fresh composite),
|
||
// but the delay still helps addons that do their own DOM work in the
|
||
// click handler before capture.
|
||
const iid = picked;
|
||
setTimeout(() => {
|
||
addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" })
|
||
.catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err));
|
||
}, 250);
|
||
}});
|
||
return true;
|
||
});
|
||
// Close the tab a full-tab add-on page lives in. Sender identifies the
|
||
// webContents; we match it against our tab list and close that tab only.
|
||
// A page hosted elsewhere (or a spoofed sender not in the tab set) gets
|
||
// nothing.
|
||
ipcMain.handle("addon-tab-close", (e) => {
|
||
const senderId = e.sender.id;
|
||
const tab = tabs.find((t) => t.view?.webContents?.id === senderId);
|
||
if (!tab) return false;
|
||
closeTab(tab.id);
|
||
return true;
|
||
});
|
||
// The counterpart: an add-on page asking for its own tab to be brought to
|
||
// the front. Needed when the add-on hands an already-open page something new
|
||
// to show and the click that caused it happened somewhere else — the sidebar,
|
||
// say — so the result would otherwise land in a tab nobody is looking at.
|
||
// Same confinement as the close handler: derived from the sender, so a page
|
||
// can only front the tab it is itself in.
|
||
ipcMain.handle("addon-tab-focus", (e) => {
|
||
const senderId = e.sender.id;
|
||
const tab = tabs.find((t) => t.view?.webContents?.id === senderId);
|
||
if (!tab) return false;
|
||
setActive(tab.id);
|
||
return true;
|
||
});
|
||
// Read-side of Settings' Add-ons tab.
|
||
ipcMain.handle("addons-list", () => {
|
||
if (!addonHost) return { installed: [], sidebarPanels: [] };
|
||
const snap = addonHost.snapshot();
|
||
// Mark bundled add-ons so the extensions UI can render them differently
|
||
// (Aegis + friends look built-in rather than removable extensions). A
|
||
// sibling folder in bundled-addons/ with the same manifest id is proof
|
||
// the addon ships with Theseus; seedBundledAddons keeps them in sync.
|
||
let bundledIds = new Set();
|
||
try {
|
||
for (const e of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) {
|
||
if (!e.isDirectory()) continue;
|
||
try {
|
||
const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), e.name, "addon.json"), "utf8"));
|
||
if (m && m.id) bundledIds.add(String(m.id));
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
snap.installed = snap.installed.map((a) => ({ ...a, bundled: a.id ? bundledIds.has(a.id) : false }));
|
||
return snap;
|
||
});
|
||
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
|
||
// add-ons activate immediately and newly-disabled ones drop out — no
|
||
// restart required.
|
||
function setAddonEnabled(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);
|
||
}
|
||
emitSidebarState();
|
||
return true;
|
||
}
|
||
ipcMain.handle("addons-set-enabled", (_e, id, enabled) => setAddonEnabled(id, enabled));
|
||
// 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; }
|
||
});
|
||
// Remove an installed (non-bundled) extension: delete its folder under the
|
||
// extensions directory and drop its prefs. Bundled add-ons are refused — a
|
||
// deleted folder would just be reseeded on the next launch, so "turn off"
|
||
// is the honest control for those. The per-extension data store is kept.
|
||
ipcMain.handle("addons-remove", (_e, id) => {
|
||
if (!addonHost || typeof id !== "string" || !id) return { ok: false, error: "bad id" };
|
||
const a = addonHost.snapshot().installed.find((x) => x.id === id);
|
||
if (!a || !a.folder) return { ok: false, error: "not installed" };
|
||
const norm = path.normalize(a.folder), base = addonsUserDir();
|
||
if (!norm.toLowerCase().startsWith(base.toLowerCase() + path.sep)) return { ok: false, error: "not in the extensions directory" };
|
||
try {
|
||
const bundledManifest = path.join(bundledAddonsDir(), path.basename(norm), "addon.json");
|
||
if (fs.existsSync(bundledManifest)) return { ok: false, error: "built into Theseus — turn it off instead" };
|
||
} catch {}
|
||
try { fs.rmSync(norm, { recursive: true, force: true }); }
|
||
catch (e) { return { ok: false, error: e?.message || String(e) }; }
|
||
settings.disabledAddons = (settings.disabledAddons || []).filter((x) => x !== id);
|
||
settings.dockOrder = (settings.dockOrder || []).filter((k) => dockAddonId(k) !== id);
|
||
settings.dockHidden = (settings.dockHidden || []).filter((k) => dockAddonId(k) !== id);
|
||
saveSettings();
|
||
addonHost.discoverAndActivate();
|
||
if (sidebarVisible && sidebarActivePanelId && sidebarActivePanelId.startsWith(id + ":")) { sidebarActivePanelId = null; setSidebar(false); }
|
||
emitSidebarState();
|
||
console.log(`[addons] removed ${id} (${norm})`);
|
||
return { ok: true };
|
||
});
|
||
// Manual "Check for updates" from Settings > Extensions. Runs the same
|
||
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
|
||
// staged dir so the UI can render "Update to <ver> — restart to apply".
|
||
// ---- community extensions (theseus.x/extensions) ----------------------------
|
||
// Anyone who owns a BNS name can publish an extension through the gateway
|
||
// (PUT /api/ext/<name>/<id>/<version>); the catalog and every package live
|
||
// on Sia and are served through the public relay. Trust: each channel entry
|
||
// carries the publisher's BCH signature over id|version|sha256|publisher.
|
||
// Before installing or updating, Theseus recovers the signer and compares
|
||
// it with the name's current NFT owner from ITS OWN chain index — so neither
|
||
// the relay nor a tampered catalog can slip in code under a trusted name.
|
||
const COMMUNITY_CATALOG_URL = "https://navigate.st/api/ext/catalog";
|
||
let publisherSigLib = null;
|
||
async function getPublisherSig() {
|
||
if (!publisherSigLib) publisherSigLib = await import(`file://${path.join(__dirname, "lib", "publisher-sig.mjs").replace(/\\/g, "/")}`);
|
||
return publisherSigLib;
|
||
}
|
||
async function verifyPublisherEntry(entry) {
|
||
try {
|
||
const name = String(entry?.publisher || "").toLowerCase();
|
||
if (!name) return false;
|
||
const owner = (await resolveHost(name, { verified: true }))?.owner;
|
||
if (!owner) { console.warn(`[addons] publisher ${name}: owner unknown to the local index`); return false; }
|
||
const lib = await getPublisherSig();
|
||
const ok = lib.verifyPublisherEntry(entry, owner);
|
||
if (!ok) console.warn(`[addons] publisher signature for ${entry.id}@${entry.version} does not match ${name}'s owner`);
|
||
return ok;
|
||
} catch (e) { console.warn("[addons] publisher verify failed:", e?.message); return false; }
|
||
}
|
||
async function fetchCommunityCatalog() {
|
||
const r = await fetch(COMMUNITY_CATALOG_URL, { signal: AbortSignal.timeout(15000), cache: "no-store" });
|
||
if (!r.ok) throw new Error(`catalog HTTP ${r.status}`);
|
||
const j = await r.json();
|
||
return Array.isArray(j?.extensions) ? j.extensions : [];
|
||
}
|
||
ipcMain.handle("addons-community-catalog", async () => {
|
||
try {
|
||
const list = await fetchCommunityCatalog();
|
||
const installed = addonHost ? addonHost.snapshot().installed : [];
|
||
return { ok: true, extensions: list.map((e) => {
|
||
const cur = installed.find((a) => a.id === e.id);
|
||
return { ...e, installedVersion: cur ? cur.version : null, canUpdate: !!(cur && addonUpdater.cmpVer(e.latest, cur.version) > 0) };
|
||
}) };
|
||
} catch (e) { return { ok: false, error: e?.message || String(e), extensions: [] }; }
|
||
});
|
||
const COMMUNITY_ID_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/;
|
||
// Install a catalog extension by id: resolve its channel from the catalog,
|
||
// verify the publisher signature against the name's owner, place it under
|
||
// extensions/<id> and activate it. Shared by Settings and the page bridge.
|
||
async function installCommunityById(id) {
|
||
if (typeof id !== "string" || !COMMUNITY_ID_RE.test(id)) return { ok: false, error: "bad id" };
|
||
try {
|
||
const card = (await fetchCommunityCatalog()).find((e) => e.id === id);
|
||
if (!card) return { ok: false, error: "not in the catalog" };
|
||
if (fs.existsSync(path.join(bundledAddonsDir(), id, "addon.json"))) return { ok: false, error: "id belongs to a built-in add-on" };
|
||
const r = await addonUpdater.installCommunity({
|
||
id, updatesUrl: card.updatesUrl, publisher: card.publisher,
|
||
addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(),
|
||
verifyPublisher: verifyPublisherEntry,
|
||
log: (...a) => console.log("[addons]", ...a),
|
||
});
|
||
if (r.ok && addonHost) { addonHost.discoverAndActivate(); emitSidebarState(); }
|
||
return r;
|
||
} catch (e) { return { ok: false, error: e?.message || String(e) }; }
|
||
}
|
||
ipcMain.handle("addons-install-community", (_e, id) => installCommunityById(id));
|
||
|
||
// One-click install from a web page (theseus.x/extensions, or any page):
|
||
// either `window.bcnr.installExtension(id)` or a link to
|
||
// theseus://extensions/install/<id>. The page only names a catalog id — the
|
||
// package, its hash and the publisher signature still come from the catalog
|
||
// and are verified exactly as a Settings install is. The consent lives in a
|
||
// native dialog the page cannot draw over or click, one at a time.
|
||
const INSTALL_LINK_RE = /^theseus:\/\/extensions\/install\/([a-z0-9][a-z0-9._-]{1,63})\/?$/i;
|
||
function installLinkId(url) {
|
||
const m = INSTALL_LINK_RE.exec(String(url || "").trim());
|
||
return m ? m[1].toLowerCase() : null;
|
||
}
|
||
let installPromptOpen = false;
|
||
async function installExtensionWithConsent(id, requester) {
|
||
if (typeof id !== "string" || !COMMUNITY_ID_RE.test(id)) return { ok: false, error: "bad id" };
|
||
if (installPromptOpen) return { ok: false, error: "another install prompt is open" };
|
||
installPromptOpen = true;
|
||
try {
|
||
let card = null;
|
||
try { card = (await fetchCommunityCatalog()).find((e) => e.id === id) || null; } catch {}
|
||
const parent = win && !win.isDestroyed() ? win : undefined;
|
||
if (!card) {
|
||
await askSheet({ type: "warning", title: "Extension not found", message: `"${id}" is not in the community catalog.`, buttons: ["OK"] });
|
||
return { ok: false, error: "not in the catalog" };
|
||
}
|
||
const installed = addonHost ? addonHost.snapshot().installed.find((a) => a.id === id) : null;
|
||
if (installed && addonUpdater.cmpVer(card.latest, installed.version) <= 0) {
|
||
const { response: r0 } = await askSheet({
|
||
type: "info", title: "Already installed",
|
||
message: `${card.name || id} ${installed.version} is already installed.`,
|
||
detail: "Newer signed versions arrive through the regular update check. Manage it under Settings › Extensions.",
|
||
buttons: ["OK", "Open Settings"], defaultId: 0, cancelId: 0, noLink: true,
|
||
});
|
||
if (r0 === 1) openSettingsTab("addons");
|
||
return { ok: true, version: installed.version, publisher: installed.publisher || card.publisher, alreadyInstalled: true };
|
||
}
|
||
const from = requester ? `Requested by ${requester}.\n\n` : "";
|
||
const { response } = await askSheet({
|
||
type: "question", title: "Install extension",
|
||
message: installed
|
||
? `Update ${card.name || id} ${installed.version} → ${card.latest}?`
|
||
: `Install ${card.name || id} ${card.latest}?`,
|
||
detail: `${from}Published by ${card.publisher}. Theseus verifies the package signature against ${card.publisher}'s current owner in its own chain index before anything is written.\n\n`
|
||
+ `A community extension runs with the same access as any add-on — treat it like a program from that publisher.`,
|
||
buttons: ["Install", "Cancel"], defaultId: 1, cancelId: 1, noLink: true,
|
||
});
|
||
if (response !== 0) return { ok: false, error: "cancelled" };
|
||
const r = await installCommunityById(id);
|
||
if (r.ok) {
|
||
const { response: after } = await askSheet({
|
||
type: "info", title: "Extension installed",
|
||
message: `${card.name || id} ${r.version} is installed and active.`,
|
||
detail: `Signed by ${r.publisher || card.publisher}. Manage it under Settings › Extensions.`,
|
||
buttons: ["OK", "Open Settings"], defaultId: 0, cancelId: 0, noLink: true,
|
||
});
|
||
if (after === 1) openSettingsTab("addons");
|
||
} else {
|
||
await askSheet({ type: "error", title: "Install failed", message: `${card.name || id} was not installed.`, detail: r.error || "unknown error", buttons: ["OK"] });
|
||
}
|
||
return r;
|
||
} finally { installPromptOpen = false; }
|
||
}
|
||
// ---- web apps (PWA install) ----
|
||
function installWebAppFromTab(tab) {
|
||
if (!tab || !tab.webapp) return Promise.resolve({ ok: false, error: "not installable" });
|
||
return webapps.install(tab.webapp, { wc: tab.view.webContents, favicon: tab.favicon || null }).then((r) => { emitTabs(); return r; });
|
||
}
|
||
// Address-bar chip: not installed → the install dialog; installed → a menu
|
||
// to open the app window or remove the app.
|
||
ipcMain.handle("webapp-chip", async (e, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("webapp-chip: untrusted sender");
|
||
const t = activeTab(); if (!t || !t.webapp) return false;
|
||
const inst = webapps.find(t.webapp.key);
|
||
if (!inst) { await installWebAppFromTab(t); return true; }
|
||
const menu = Menu.buildFromTemplate([
|
||
{ label: `Open ${inst.name} in its window`, click: () => webapps.open(inst) },
|
||
{ type: "separator" },
|
||
{ label: `Remove ${inst.name} from Theseus…`, click: () => webapps.uninstall(inst.key, true).then(() => emitTabs()) },
|
||
]);
|
||
const pos = rect && Number.isFinite(rect.x) ? { x: Math.round(rect.x), y: Math.round((rect.y || 0) + (rect.h || 0)) } : {};
|
||
menu.popup({ window: win, ...pos, callback: () => { try { chrome?.webContents.send("toolbar-menu-closed"); } catch {} } });
|
||
return true;
|
||
});
|
||
// A page's own install chip calls beforeinstallprompt.prompt(); the session
|
||
// preload relays it here. Only the tab that was probed installable, and only
|
||
// while it still shows that origin, gets the dialog. Resolves "accepted" /
|
||
// "dismissed" like Chrome's userChoice.
|
||
ipcMain.handle("webapp-prompt", async (e) => {
|
||
const tab = tabs.find((t) => t.view?.webContents === e.sender);
|
||
if (!tab || !tab.webapp) return "dismissed";
|
||
let origin = ""; try { origin = new URL(e.sender.getURL().replace(/^bns:\/\//, "https://")).origin; } catch {}
|
||
if (!origin || origin !== tab.webapp.origin) return "dismissed";
|
||
const r = await installWebAppFromTab(tab);
|
||
return r && r.ok ? "accepted" : "dismissed";
|
||
});
|
||
ipcMain.handle("webapps-list", () => webapps.list().map((a) => ({ key: a.key, name: a.name, host: a.host, startUrl: a.startUrl, installedAt: a.installedAt })));
|
||
ipcMain.handle("webapps-open", (_e, key) => { const a = webapps.find(String(key || "")); if (a) webapps.open(a); return !!a; });
|
||
ipcMain.handle("webapps-remove", (_e, key) => webapps.uninstall(String(key || ""), true).then((ok) => { emitTabs(); return ok; }));
|
||
ipcMain.handle("bcnr:installExtension", (e, id) => {
|
||
let requester = null;
|
||
try { requester = new URL(e.sender.getURL()).host || null; } catch {}
|
||
return installExtensionWithConsent(id, requester);
|
||
});
|
||
// Background / manual add-on update check. Whatever gets staged is pushed to
|
||
// the chrome ("addon-updates") so the toolbar can offer "Restart to apply".
|
||
async function pollAddonUpdates(reason) {
|
||
const stagedDir = addonsStagedDir();
|
||
let report = [], skipped = null;
|
||
try {
|
||
const result = await addonUpdater.checkAndStageUpdates({
|
||
addonsDir: addonsUserDir(),
|
||
stagedDir,
|
||
pubkeysHex: ADDON_UPDATE_PUBKEYS,
|
||
verifyPublisher: verifyPublisherEntry,
|
||
logger: (...a) => console.log("[addons]", ...a),
|
||
});
|
||
report = result?.report || [];
|
||
skipped = result?.skipped || null;
|
||
} catch (e) { console.warn(`[addons] check-updates (${reason}) failed:`, e?.message || e); }
|
||
const staged = listStagedAddons(stagedDir);
|
||
notifyStagedAddons(staged);
|
||
return { report, skipped, staged };
|
||
}
|
||
// Plug-ins (manifest category "plugin", e.g. Aegis) carry their own update
|
||
// UI inside their panel, so the toolbar chip only announces extensions.
|
||
function isPluginAddon(id) {
|
||
try { return (addonHost?.snapshot().installed || []).some((a) => a.id === id && a.category === "plugin"); } catch { return false; }
|
||
}
|
||
function notifyStagedAddons(staged) {
|
||
const list = (staged || listStagedAddons(addonsStagedDir())).filter((s) => !isPluginAddon(s.id));
|
||
try { chrome?.webContents.send("addon-updates", { staged: list.map((s) => ({ id: s.id, name: s.name, version: s.version })) }); } catch {}
|
||
}
|
||
ipcMain.handle("addons-check-updates", () => pollAddonUpdates("manual"));
|
||
// Apply staged extension updates now (no restart): promote the staged
|
||
// folder(s) over the installed copy and rebuild the add-on host, which
|
||
// re-requires each add-on's main from disk. Plug-ins are left for the next
|
||
// launch — a wallet mid-session is not something to hot-swap. `id` limits
|
||
// the apply to one extension (Settings row button); omitted = every
|
||
// staged extension (toolbar chip).
|
||
ipcMain.handle("addons-apply-staged", (_e, id) => {
|
||
if (!addonHost) return { ok: false, error: "add-ons not ready", applied: [] };
|
||
const want = typeof id === "string" && id ? id : null;
|
||
const applied = addonUpdater.promoteStagedUpdates({
|
||
addonsDir: addonsUserDir(),
|
||
backupsDir: addonsBackupDir(),
|
||
stagedDir: addonsStagedDir(),
|
||
logger: (...a) => console.log("[addons]", ...a),
|
||
only: (m) => (want ? m.id === want : true) && !isPluginAddon(m.id),
|
||
});
|
||
if (applied.length) { addonHost.discoverAndActivate(); emitSidebarState(); }
|
||
notifyStagedAddons();
|
||
return { ok: true, applied, pending: listStagedAddons(addonsStagedDir()).map((s) => ({ id: s.id, version: s.version })) };
|
||
});
|
||
ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir()));
|
||
function listStagedAddons(stagedDir) {
|
||
const out = [];
|
||
let entries = [];
|
||
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } catch { return out; }
|
||
for (const de of entries) {
|
||
if (!de.isDirectory()) continue;
|
||
try {
|
||
const m = JSON.parse(fs.readFileSync(path.join(stagedDir, de.name, "addon.json"), "utf8"));
|
||
if (m?.id && m?.version) out.push({ id: m.id, name: m.name || m.id, version: m.version, folder: path.join(stagedDir, de.name) });
|
||
} catch {}
|
||
}
|
||
return out;
|
||
}
|
||
ipcMain.handle("addons-reload", () => {
|
||
if (!addonHost) return false;
|
||
addonHost.discoverAndActivate();
|
||
emitSidebarState();
|
||
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; }
|
||
}
|
||
// wiz:// — WizardConnect pairing links.
|
||
//
|
||
// WizardConnect is a cross-device protocol: a dapp renders its pairing URI
|
||
// as a QR for a phone wallet to scan. On the same device that means copying
|
||
// a wiz:// string out of one tab and pasting it into the wallet by hand.
|
||
// When a dapp renders the URI as a link instead, we can route the click
|
||
// straight to the wallet — no copying, and no change required on the dapp
|
||
// side beyond making it an anchor, so this works for third-party dapps that
|
||
// will never adopt a Silent Mode API.
|
||
//
|
||
// The wallet still shows its own approval before anything is paired; all
|
||
// this does is carry the URI across, tagged with the origin that offered it
|
||
// so the approval can name the real site.
|
||
function routeWizUri(uri, origin, tabId) {
|
||
if (!addonHost) return false;
|
||
const u = String(uri || "");
|
||
if (!/^wiz:/i.test(u) || u.length > 4096) return false;
|
||
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false;
|
||
addonHost
|
||
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
|
||
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
|
||
return true;
|
||
}
|
||
|
||
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
|
||
const tab = tabForSender(e.sender);
|
||
if (!tab || !addonHost) throw new Error("not a page");
|
||
const url = e.sender.getURL();
|
||
const id = String(addonId || "");
|
||
if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`);
|
||
const origin = pageOriginOf(url);
|
||
if (!origin) throw new Error("opaque origin");
|
||
return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id });
|
||
});
|
||
// Synchronous — the inject preload has to know what to run before the page's
|
||
// own scripts start. Decided against the sender's committed URL; the href the
|
||
// preload reports is only logged when it disagrees.
|
||
// Assigning event.returnValue sends the reply at once, so it is set exactly
|
||
// once at the end.
|
||
function injectionsForSender(e, href) {
|
||
const tab = tabForSender(e.sender);
|
||
if (!tab || !addonHost) return [];
|
||
const url = e.sender.getURL();
|
||
if (!url || url.startsWith("file:")) return [];
|
||
if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`);
|
||
const origin = pageOriginOf(url);
|
||
const list = addonHost.injectionsFor(url).map((x) => ({ ...x, origin }));
|
||
if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}`);
|
||
return list;
|
||
}
|
||
ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); });
|
||
// Approval overlay. One request at a time; later callers queue behind the
|
||
// visible one so two dapps can't race each other for the same click.
|
||
// ---- page dialogs: alert / confirm / prompt ----
|
||
// Chromium's stock JavaScript dialogs are bare OS message boxes titled with
|
||
// the package name ("theseus-navigator"). The session preload reroutes the
|
||
// page's calls here, synchronously (sendSync), and the answer comes from a
|
||
// Theseus-drawn sheet under the toolbar that names who is asking — the
|
||
// site's host, or the add-on's name for add-on pages — like Chrome's
|
||
// "example.com says". Windows without the chrome (app windows, plain
|
||
// windows) fall back to a native box with a proper title.
|
||
let jsDialogPop = null;
|
||
const jsDialogQueue = [];
|
||
let jsDialogCurrent = null; // { e, req, tabId }
|
||
let jsDialogSeq = 0;
|
||
function jsDialogWho(sender, tab) {
|
||
let u = ""; try { u = sender.getURL() || ""; } catch {}
|
||
// Add-on pages — a full-tab page, a sidebar panel, a background page — all
|
||
// load from <profile>/extensions/<id>/…; the id is the first segment after
|
||
// that directory. (A tab's addonId flag would also stay set after the tab
|
||
// navigated elsewhere, so the URL is the reliable source.)
|
||
if (/^file:/i.test(u)) {
|
||
try {
|
||
const base = url.pathToFileURL(addonsUserDir()).href.replace(/\/?$/, "/");
|
||
if (u.startsWith(base)) {
|
||
const id = decodeURIComponent(u.slice(base.length).split(/[/?#]/)[0]);
|
||
const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === id);
|
||
return { label: a && a.manifest.name ? String(a.manifest.name) : id, kind: "addon" };
|
||
}
|
||
} catch {}
|
||
}
|
||
// Theseus's own pages: every non-tab view, and the tabs that show our own
|
||
// files (home, settings). Decided from the URL — a tab's prov lags a
|
||
// navigation, so a tab that just left the home page would still read as
|
||
// "home". Any other file: in a tab is a local file the user opened.
|
||
if (/^file:/i.test(u)) {
|
||
if (!tab) return { label: "Theseus", kind: "app" };
|
||
const own = (() => { try { return u.startsWith(url.pathToFileURL(__dirname).href); } catch { return false; } })();
|
||
return own || tab.settings ? { label: "Theseus", kind: "app" } : { label: "This page", kind: "site" };
|
||
}
|
||
try { const p = new URL(u.replace(/^bns:\/\//i, "https://")); if (p.host) return { label: p.host, kind: "site" }; } catch {}
|
||
return { label: "This page", kind: "site" };
|
||
}
|
||
function jsDialogReply(item, ok, value) {
|
||
const { kind } = item.req;
|
||
if (item.resolve) { // one of Theseus's own prompts (askSheet)
|
||
const v = value && typeof value === "object" ? value : {};
|
||
const buttons = item.req.buttons || [];
|
||
let response = ok ? Number(v.response) : item.req.cancelId;
|
||
if (!Number.isInteger(response) || response < 0 || response >= buttons.length) response = item.req.cancelId;
|
||
item.resolve({ response, checkboxChecked: !!v.checkboxChecked });
|
||
return;
|
||
}
|
||
const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : "";
|
||
try { item.e.returnValue = { handled: true, value: out }; } catch {}
|
||
}
|
||
// Does this tab have a page dialog waiting for the user to come back to it?
|
||
function tabHasPendingDialog(tabId) {
|
||
return jsDialogQueue.some((q) => q.tabId === tabId) || (!!jsDialogCurrent && jsDialogCurrent.tabId === tabId && tabId !== activeId);
|
||
}
|
||
function pumpJsDialog() {
|
||
if (jsDialogCurrent || !jsDialogQueue.length) return;
|
||
if (!jsDialogPop || !winAlive()) { for (const it of jsDialogQueue.splice(0)) jsDialogReply(it, false, null); return; }
|
||
for (let i = jsDialogQueue.length - 1; i >= 0; i--) {
|
||
const q = jsDialogQueue[i];
|
||
if (q.tabId != null && !tabById(q.tabId)) jsDialogReply(jsDialogQueue.splice(i, 1)[0], false, null);
|
||
}
|
||
// A dialog shows only over the tab that asked (or, for a panel's dialog,
|
||
// over whatever is current). A background tab's dialog waits — marked in
|
||
// the tab strip — until the user switches to it; it never pulls its tab to
|
||
// the front, which let any page in the background jump over what the user
|
||
// was doing with alert().
|
||
const i = jsDialogQueue.findIndex((q) => q.tabId == null || q.tabId === activeId);
|
||
emitTabs();
|
||
if (i < 0) return;
|
||
const next = jsDialogQueue.splice(i, 1)[0];
|
||
jsDialogCurrent = next;
|
||
overlayReady(jsDialogPop).then(() => {
|
||
if (jsDialogCurrent !== next) return;
|
||
try {
|
||
jsDialogPop.webContents.send("jsdialog-show", next.req);
|
||
jsDialogPop.setVisible(true);
|
||
try { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); } catch {}
|
||
layout();
|
||
jsDialogPop.webContents.focus();
|
||
} catch (err) {
|
||
jsDialogCurrent = null;
|
||
jsDialogReply(next, false, null);
|
||
console.warn("page dialog show failed:", err?.message);
|
||
pumpJsDialog();
|
||
}
|
||
});
|
||
}
|
||
// The sheet belongs to one tab: hidden while another tab is in front, back
|
||
// (and above any tab added since) when its tab returns. A panel's sheet has
|
||
// no tab and stays.
|
||
function syncJsDialogVisibility() {
|
||
if (!jsDialogPop || !winAlive()) return;
|
||
if (!jsDialogCurrent) { pumpJsDialog(); return; }
|
||
const show = jsDialogCurrent.tabId == null || jsDialogCurrent.tabId === activeId;
|
||
if (!show) {
|
||
// Its tab went to the background: put it back in line (still answering
|
||
// nothing — the page stays blocked in its sendSync) so the tab now in
|
||
// front can show its own dialog, if any.
|
||
try { jsDialogPop.setVisible(false); } catch {}
|
||
jsDialogQueue.unshift(jsDialogCurrent);
|
||
jsDialogCurrent = null;
|
||
pumpJsDialog();
|
||
return;
|
||
}
|
||
try {
|
||
jsDialogPop.setVisible(true);
|
||
win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus();
|
||
} catch {}
|
||
}
|
||
function finishJsDialog(ok, value) {
|
||
const cur = jsDialogCurrent; if (!cur) return;
|
||
jsDialogCurrent = null;
|
||
try { if (jsDialogPop) jsDialogPop.setVisible(false); } catch {}
|
||
jsDialogReply(cur, ok, value);
|
||
try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && t.id === activeId) t.view.webContents.focus(); } catch {}
|
||
pumpJsDialog();
|
||
}
|
||
// Theseus's own prompts — install this app / extension, remove, restart —
|
||
// in the same sheet as page dialogs. Same option shape as
|
||
// dialog.showMessageBox (title, message, detail, buttons, defaultId,
|
||
// cancelId, checkboxLabel, checkboxChecked, icon, type), same result; the
|
||
// native box remains the fallback when the browser window is not there.
|
||
function askSheet(opts) {
|
||
const o = opts || {};
|
||
const buttons = Array.isArray(o.buttons) && o.buttons.length ? o.buttons.map(String) : ["OK"];
|
||
const defaultId = Number.isInteger(o.defaultId) && o.defaultId >= 0 && o.defaultId < buttons.length ? o.defaultId : 0;
|
||
const cancelId = Number.isInteger(o.cancelId) && o.cancelId >= 0 && o.cancelId < buttons.length ? o.cancelId : (buttons.length > 1 ? buttons.length - 1 : 0);
|
||
if (!jsDialogPop || !winAlive()) {
|
||
const parent = win && !win.isDestroyed() ? win : undefined;
|
||
return dialog.showMessageBox(parent, { ...o, noLink: true }).catch(() => ({ response: cancelId, checkboxChecked: false }));
|
||
}
|
||
let iconUrl = null;
|
||
try { if (o.icon && typeof o.icon.toDataURL === "function" && !o.icon.isEmpty()) iconUrl = o.icon.toDataURL(); } catch {}
|
||
const req = {
|
||
reqId: ++jsDialogSeq, kind: "app", who: { label: "Theseus", kind: "app" },
|
||
title: String(o.title || ""), message: String(o.message || ""), detail: String(o.detail || ""),
|
||
buttons, defaultId, cancelId, type: String(o.type || "none"), iconUrl,
|
||
checkbox: o.checkboxLabel ? { label: String(o.checkboxLabel), checked: !!o.checkboxChecked } : null,
|
||
};
|
||
return new Promise((resolve) => {
|
||
jsDialogQueue.push({ resolve, tabId: null, req });
|
||
pumpJsDialog();
|
||
});
|
||
}
|
||
// A tab closing (or the window going away) must not leave its renderer
|
||
// blocked inside a sendSync that nobody will answer.
|
||
function dismissJsDialogFor(tabId) {
|
||
for (let i = jsDialogQueue.length - 1; i >= 0; i--) if (tabId == null || jsDialogQueue[i].tabId === tabId) jsDialogReply(jsDialogQueue.splice(i, 1)[0], false, null);
|
||
if (jsDialogCurrent && (tabId == null || jsDialogCurrent.tabId === tabId)) finishJsDialog(false, null);
|
||
}
|
||
ipcMain.on("js-dialog", (e, raw) => {
|
||
const kind = ["alert", "confirm", "prompt"].includes(raw && raw.kind) ? raw.kind : "alert";
|
||
const message = String((raw && raw.message) ?? "").slice(0, 4000);
|
||
const def = raw && raw.def != null ? String(raw.def).slice(0, 2000) : null;
|
||
const tab = tabs.find((t) => t.view?.webContents === e.sender);
|
||
const who = jsDialogWho(e.sender, tab);
|
||
// Anything living in the browser window — a tab, a sidebar panel, an
|
||
// add-on's page — gets the sheet. Only another window (an installed app's
|
||
// window, a plain window) gets a native box: the sheet is drawn in win.
|
||
let owner = null; try { owner = BrowserWindow.fromWebContents(e.sender); } catch {}
|
||
const inMain = winAlive() && (!!tab || !owner || owner === win);
|
||
if (!inMain || !jsDialogPop) {
|
||
if (kind === "prompt") { e.returnValue = { handled: false }; return; }
|
||
let parent; try { parent = BrowserWindow.fromWebContents(e.sender) || undefined; } catch {}
|
||
// Async box: the page stays blocked in its sendSync until returnValue is
|
||
// set, but the main process (every tab, bns://, downloads) keeps running —
|
||
// the sync variant froze the whole browser while the box was up.
|
||
const answer = (r) => { try { e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" }; } catch {} };
|
||
dialog.showMessageBox(parent, {
|
||
type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator",
|
||
message: `${who.label} says`, detail: message,
|
||
buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true,
|
||
}).then(({ response }) => answer(response), () => answer(1));
|
||
return;
|
||
}
|
||
jsDialogQueue.push({ e, tabId: tab ? tab.id : null, req: { reqId: ++jsDialogSeq, kind, message, def, who } });
|
||
pumpJsDialog();
|
||
});
|
||
ipcMain.handle("jsdialog-answer", (e, reqId, ok, value) => {
|
||
if (!jsDialogPop || e.sender !== jsDialogPop.webContents) return false;
|
||
if (!jsDialogCurrent || jsDialogCurrent.req.reqId !== reqId) return false;
|
||
finishJsDialog(!!ok, value);
|
||
return true;
|
||
});
|
||
let approvalPop = null;
|
||
const approvalQueue = [];
|
||
let approvalCurrent = null; // { reqId, resolve }
|
||
let approvalSeq = 0;
|
||
function pumpApproval() {
|
||
if (approvalCurrent || !approvalQueue.length || !approvalPop) return;
|
||
const next = approvalQueue.shift();
|
||
approvalCurrent = next;
|
||
// The overlay's page loads lazily after first paint; a dapp on a restored
|
||
// tab can ask for approval before that, so wait for the page rather than
|
||
// sending into an empty renderer (the request would silently hang).
|
||
overlayReady(approvalPop).then(() => {
|
||
if (approvalCurrent !== next) return;
|
||
try {
|
||
approvalPop.webContents.send("approval-show", next.req);
|
||
approvalPop.setVisible(true);
|
||
try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {}
|
||
layout();
|
||
approvalPop.webContents.focus();
|
||
} catch (err) {
|
||
approvalCurrent = null;
|
||
next.resolve("cancel");
|
||
console.warn("[addons] approval show failed:", err?.message);
|
||
}
|
||
});
|
||
}
|
||
function showApprovalModal(opts, addonId) {
|
||
const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
|
||
const req = {
|
||
reqId: ++approvalSeq,
|
||
addonId,
|
||
addonName: a ? a.manifest.name : addonId,
|
||
title: String(opts.title || "Approve?"),
|
||
body: opts.body == null ? "" : String(opts.body),
|
||
origin: opts.origin == null ? "" : String(opts.origin),
|
||
rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [],
|
||
actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [],
|
||
checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null,
|
||
// Optional dropdown; a non-empty chosen value comes back as "+<id>=<value>".
|
||
select: opts.select && Array.isArray(opts.select.options) ? {
|
||
id: String(opts.select.id || "choice"), label: String(opts.select.label || ""),
|
||
options: opts.select.options.map((o) => ({ value: String(o.value ?? ""), label: String(o.label ?? o.value ?? "") })),
|
||
} : null,
|
||
};
|
||
return new Promise((resolve) => {
|
||
approvalQueue.push({ req, resolve });
|
||
pumpApproval();
|
||
});
|
||
}
|
||
ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => {
|
||
if (!approvalPop || e.sender !== approvalPop.webContents) return false;
|
||
if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;
|
||
const cur = approvalCurrent;
|
||
approvalCurrent = null;
|
||
approvalPop.setVisible(false);
|
||
let result = String(action || "cancel");
|
||
if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id;
|
||
if (result !== "cancel" && cur.req.select && extra && cur.req.select.options.some((o) => o.value === extra)) {
|
||
result += "+" + cur.req.select.id + "=" + extra;
|
||
}
|
||
cur.resolve(result);
|
||
pumpApproval();
|
||
return true;
|
||
});
|
||
// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------
|
||
// Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main
|
||
// derives the add-on id from the sender's file:// URL so a page can only
|
||
// touch its own store; any file:// outside addons/ returns nothing.
|
||
// Same in-memory store as the add-on's own api.storage (lib/addon-store.cjs).
|
||
const addonStore = (id) => storeFor(path.join(addonsDataDir(), id + ".json"), (...a) => console.warn(`[addons] [${id}]`, ...a));
|
||
ipcMain.handle("addon-storage-get", (e, key, fallback) => {
|
||
const id = addonIdForSender(e.sender);
|
||
if (!id) return fallback ?? null;
|
||
return addonStore(id).get(key, 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;
|
||
addonStore(id).set(key, value);
|
||
return true;
|
||
});
|
||
ipcMain.handle("addon-storage-all", (e) => {
|
||
const id = addonIdForSender(e.sender);
|
||
if (!id) return {};
|
||
return addonStore(id).all();
|
||
});
|
||
// Error-page actions. All origin-gated to error.html so a third-party page
|
||
// that happens to see the API shape (home-preload exposes it on every tab)
|
||
// can't drive them.
|
||
ipcMain.handle("error-retry", (e, url) => {
|
||
if (!isErrorPageSender(e.sender)) return false;
|
||
if (typeof url !== "string" || !url) return false;
|
||
navigateTab(activeId, url);
|
||
return true;
|
||
});
|
||
ipcMain.handle("error-home", (e) => {
|
||
if (!isErrorPageSender(e.sender)) return false;
|
||
loadHome(activeId);
|
||
return true;
|
||
});
|
||
ipcMain.handle("error-search", (e, text) => {
|
||
if (!isErrorPageSender(e.sender)) return false;
|
||
const q = String(text || "").trim();
|
||
if (!q) return false;
|
||
navigateTab(activeId, SEARCH(q));
|
||
return true;
|
||
});
|
||
ipcMain.handle("error-register", (e, host) => {
|
||
if (!isErrorPageSender(e.sender)) return false;
|
||
const h = String(host || "").trim().toLowerCase();
|
||
if (!h) return false;
|
||
// Sirius's registrar UI takes ?prefill=<name>; if it ignores an unknown
|
||
// param the user just lands on the form and types it themselves.
|
||
const url = "https://sirius.x/register.html?prefill=" + encodeURIComponent(h);
|
||
navigateTab(activeId, url);
|
||
return true;
|
||
});
|
||
// "Did you mean" for the error page. Returns up to `limit` BCNR-registered
|
||
// names within a small edit distance of `host`, same TLD only. Uses the
|
||
// warm sharedIndex — no network call, no wait — so an offline user still
|
||
// gets suggestions if the index warmed at least once. Ranks by ascending
|
||
// distance, then alphabetical for a stable list.
|
||
function levenshtein(a, b) {
|
||
const m = a.length, n = b.length;
|
||
if (Math.abs(m - n) > 3) return 4; // early-out, we only care about ≤2
|
||
const prev = new Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j;
|
||
const cur = new Array(n + 1);
|
||
for (let i = 1; i <= m; i++) {
|
||
cur[0] = i;
|
||
for (let j = 1; j <= n; j++) {
|
||
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
||
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
||
}
|
||
for (let j = 0; j <= n; j++) prev[j] = cur[j];
|
||
}
|
||
return prev[n];
|
||
}
|
||
ipcMain.handle("error-bns-similar", (e, host) => {
|
||
if (!isErrorPageSender(e.sender)) return [];
|
||
const h = String(host || "").trim().toLowerCase();
|
||
if (!h || !sharedIndex || typeof sharedIndex.keys !== "function") return [];
|
||
const tld = tldOf(h);
|
||
if (!tld) return [];
|
||
const cands = [];
|
||
const maxDist = 2;
|
||
for (const key of sharedIndex.keys()) {
|
||
if (typeof key !== "string") continue;
|
||
// Same TLD only — a typo like "games.x" → "game.x" or "gaeme.x" → "game.x".
|
||
if (!key.endsWith("." + tld)) continue;
|
||
if (key === h) continue;
|
||
const d = levenshtein(h, key);
|
||
if (d <= maxDist) cands.push({ name: key, dist: d });
|
||
if (cands.length > 200) break; // hard cap so a huge index doesn't stall the render
|
||
}
|
||
cands.sort((a, b) => (a.dist - b.dist) || a.name.localeCompare(b.name));
|
||
return cands.slice(0, 3).map((c) => c.name);
|
||
});
|
||
ipcMain.handle("error-open-external", (e, url) => {
|
||
if (!isErrorPageSender(e.sender)) return false;
|
||
// Allowlist Silent Mode domains only — no arbitrary external opens from
|
||
// a page that visits when things are already going wrong.
|
||
const ok = typeof url === "string" && /^https:\/\/(silentmode\.st|silentmode\.bch|sirius\.x|theseus\.x|navigate\.st)(\/|$)/i.test(url);
|
||
if (!ok) return false;
|
||
try { shell.openExternal(url); return true; } catch { return false; }
|
||
});
|
||
// Update chip: user clicked the download button → download the installer
|
||
// through Theseus itself. session.downloadURL triggers the same
|
||
// will-download handler our own downloads panel listens on, so the file
|
||
// lands in the user's Downloads folder AND appears in the in-app
|
||
// downloads chip with progress + Show-in-folder. No system browser
|
||
// jump, no "why did another browser open?" confusion.
|
||
ipcMain.handle("open-update-download", (_e, url) => {
|
||
const ok = typeof url === "string" && (url.startsWith("https://dl.silentmode.st/") || url.startsWith("https://silentmode.st/"));
|
||
if (!ok) return false;
|
||
try {
|
||
// The downloads toolbar button spins + shows a progress badge as
|
||
// will-download / did-update updates fire, so the user sees the
|
||
// transfer without us having to force-open the downloads panel.
|
||
session.defaultSession.downloadURL(url);
|
||
return true;
|
||
} catch (e) { console.warn("update download failed:", e?.message); return false; }
|
||
});
|
||
ipcMain.handle("dismiss-update", () => { updateDismissedThisSession = true; emitUpdateAvailable(); return true; });
|
||
// Find-in-page. Chrome renderer's find bar drives this: the bar sends
|
||
// `find-in-page` with the query + direction; main proxies to the active
|
||
// tab's webContents.findInPage. Chromium raises `found-in-page` on each
|
||
// keystroke with the running match count / active match ordinal; we
|
||
// forward that back to chrome so the bar can render "3 of 12".
|
||
ipcMain.handle("find-in-page", (_e, query, opts = {}) => {
|
||
const t = activeTab(); if (!t) return false;
|
||
if (typeof query !== "string" || !query) { try { t.view.webContents.stopFindInPage("clearSelection"); } catch {} return false; }
|
||
try {
|
||
t.view.webContents.findInPage(query, {
|
||
forward: opts.forward !== false,
|
||
findNext: !!opts.findNext, // false = new query; true = jump next/prev
|
||
matchCase: !!opts.matchCase,
|
||
});
|
||
return true;
|
||
} catch { return false; }
|
||
});
|
||
ipcMain.handle("find-stop", () => {
|
||
const t = activeTab(); if (!t) return false;
|
||
try { t.view.webContents.stopFindInPage("clearSelection"); return true; } catch { return false; }
|
||
});
|
||
// Just the app version — no network. Used by Settings > General so the
|
||
// user can see which Theseus they're on without clicking "Check for updates".
|
||
ipcMain.handle("app-version", () => app.getVersion());
|
||
// Clean relaunch — used by the Aegis card to apply a staged add-on update
|
||
// (promotion happens on next boot; this is just how the user gets there).
|
||
ipcMain.handle("app-restart", (e) => { let from = "?"; try { from = e.sender.getURL(); } catch {} console.log("[restart] requested via app-restart IPC from", from); try { app.relaunch(); } catch {} app.quit(); });
|
||
ipcMain.handle("recheck-update", async () => {
|
||
// Manual "Check for updates" also un-dismisses any chip the user closed
|
||
// in this session — they're actively asking to see the status, so honour
|
||
// that. Report current app version too so the UI can render "you're on
|
||
// vN.M.O" when no newer build is out.
|
||
updateDismissedThisSession = false;
|
||
await checkForUpdate();
|
||
return { updateAvailable, currentVersion: app.getVersion() };
|
||
});
|
||
// One-click "Install & restart". Requires the silent pre-fetch to have
|
||
// finished (updateDownloadState === "ready"). Launches the setup with
|
||
// /S (silent, skips the wizard) and --force-run (electron-builder's NSIS
|
||
// convention for "start the app when the install finishes"), then quits
|
||
// Theseus so the installer can overwrite it. The user sees the browser
|
||
// disappear for a couple of seconds and come back on the new version —
|
||
// no manual relaunch needed. Verified E2E on 0.3.33 → 0.3.34 in the
|
||
// 2026-09-08 session; the 0.3.37 → 0.3.39 update ran WITHOUT --force-run
|
||
// (the flag was dropped in the 0.3.31 rewrite once it was clear /S alone
|
||
// installs correctly) and the user reported the missing relaunch — this
|
||
// commit puts --force-run back on so the auto-restart is part of the
|
||
// standard flow again. --updated stays out: the earlier E2E showed it
|
||
// wasn't load-bearing correctness for our NSIS config.
|
||
//
|
||
// 2026-09-11: an update ran while the app was still shutting down, both NSIS
|
||
// processes died ~8 s in, and the install was left without app.asar. The
|
||
// installer is no longer spawned from here: install-update-now only records
|
||
// the setup path and quits; will-quit then starts a detached batch helper
|
||
// that waits for this PID to be gone, runs the installer, and re-runs it
|
||
// once if app.asar is missing afterwards. See lib/update-helper.cjs for
|
||
// the script, the console-less cmd.exe traps, and the reasoning.
|
||
let pendingInstallerPath = null;
|
||
ipcMain.handle("install-update-now", () => {
|
||
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
|
||
pendingInstallerPath = updateDownloadPath;
|
||
app.quit();
|
||
return true;
|
||
});
|
||
app.on("will-quit", () => {
|
||
if (!pendingInstallerPath) return;
|
||
const setupPath = pendingInstallerPath;
|
||
pendingInstallerPath = null;
|
||
try {
|
||
const { buildUpdateHelperCmd, updateHelperEnv } = require("./lib/update-helper.cjs");
|
||
const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd");
|
||
const installDir = path.dirname(process.execPath);
|
||
fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir }));
|
||
// A detached cmd.exe outlives this process (verified) and is in no job
|
||
// of ours; the batch file itself waits for our PID to disappear.
|
||
// /s + doubled quotes: a plain /c "<path>" loses its quotes when the path
|
||
// holds & ( ) and the like.
|
||
const helper = spawn("cmd.exe", [`/d /s /c ""${cmdPath}""`],
|
||
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true,
|
||
env: { ...process.env, ...updateHelperEnv({ setupPath, installDir }) } });
|
||
helper.unref();
|
||
console.log(`[update] helper armed for ${setupPath}`);
|
||
} catch (e) { console.warn("[update] helper spawn failed:", e?.message); }
|
||
});
|
||
// Home page editable cards. Origin-gated to home.html — random pages that
|
||
// snoop the preload can't act on the local file.
|
||
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
|
||
ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; });
|
||
ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; });
|
||
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
|
||
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
|
||
ipcMain.handle("reload", (_e, hard) => {
|
||
const t = activeTab();
|
||
if (!t) return;
|
||
// A dormant restored tab has nothing to reload — treat Reload as "load it
|
||
// for the first time" rather than reloading an empty renderer.
|
||
if (t.pending) { materializePending(t); return; }
|
||
const wc = t.view.webContents;
|
||
if (!wc) return;
|
||
try { hard ? wc.reloadIgnoringCache() : wc.reload(); } catch {}
|
||
});
|
||
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
|
||
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
|
||
// Open (or focus) the Settings tab. Optional section slug ("passwords",
|
||
// "addons", …) lands directly on that sidebar entry when the page loads.
|
||
// settings.html watches for a fragment on load and an IPC message when
|
||
// already loaded.
|
||
function openSettingsTab(section) {
|
||
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?$/i.test(section) ? section.toLowerCase() : "";
|
||
const ex = tabs.find((t) => t.settings);
|
||
if (ex) {
|
||
setActive(ex.id);
|
||
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
|
||
return;
|
||
}
|
||
createTab(null, { settings: true, settingsSection: slug });
|
||
}
|
||
ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section));
|
||
// Settings › Performance drives Shield and Cookie Pop-ups through their
|
||
// add-ons' own message handlers; only the Settings tab may call this.
|
||
const isSettingsSender = (e) => tabs.some((t) => t.settings && t.view?.webContents === e.sender);
|
||
ipcMain.handle("addon-invoke", (e, id, msg, payload) => {
|
||
if (!isSettingsSender(e)) throw new Error("addon-invoke: settings only");
|
||
if (!addonHost) throw new Error("no add-on host");
|
||
return addonHost.dispatch(String(id || ""), String(msg || ""), payload, { from: "settings" });
|
||
});
|
||
// The Settings page reports which page it shows; the address bar follows
|
||
// (theseus://settings/<slug>), so every Settings page has a link.
|
||
ipcMain.handle("settings-section", (e, slug) => {
|
||
const t = tabs.find((x) => x.settings && x.view?.webContents === e.sender);
|
||
if (!t) return false;
|
||
const s = typeof slug === "string" && /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?$/i.test(slug) ? slug.toLowerCase() : "";
|
||
t.url = s ? `theseus://settings/${s}` : "theseus://settings";
|
||
emitTabs();
|
||
return true;
|
||
});
|
||
ipcMain.handle("tor-state", (e) => { if (!isSettingsSender(e)) throw new Error("tor-state: settings only"); return torState; });
|
||
ipcMain.handle("settings-open-panel", (e, panelId) => {
|
||
if (!isSettingsSender(e)) throw new Error("settings-open-panel: settings only");
|
||
setSidebar(true, String(panelId || ""));
|
||
return true;
|
||
});
|
||
ipcMain.handle("toggle-site-info", (_e, rect) => {
|
||
if (popVisible) return showPopover(false);
|
||
if (justClosedByClickAway(popover)) return;
|
||
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
||
showPopover(true);
|
||
});
|
||
ipcMain.handle("close-site-info", () => showPopover(false));
|
||
ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); });
|
||
|
||
// ---- Collision-mode: per-tab live switcher + policy control -----------------
|
||
// Flip the active tab between BCNR and ICANN for its current host, optionally
|
||
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
|
||
ipcMain.handle("collision-switch", (_e, arg) => switchRegistry(arg));
|
||
async function switchRegistry(arg) {
|
||
const t = activeTab(); if (!t?.prov?.host) return null;
|
||
const host = t.prov.host, tld = tldOf(host);
|
||
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
|
||
const raw = arg?.choice;
|
||
const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr"
|
||
: (raw === "icann") ? "icann"
|
||
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
|
||
// Optional persistent memory ("Always use…" from the popover switcher).
|
||
rememberCollision(host, tld, choice, arg?.remember || "no");
|
||
const registry = registryOf(tld);
|
||
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
|
||
// collision-decision path can trigger a re-prompt on an explicit user switch.
|
||
// The user clicked the switcher; they've made their choice. Just load it.
|
||
setLoading(t, true);
|
||
t.internalNav = true;
|
||
try {
|
||
if (choice === "icann") {
|
||
t.url = "https://" + host + "/";
|
||
await t.view.webContents.loadURL(t.url);
|
||
t.prov = { host, kind: "web", note: "switched to ICANN", tld, registry };
|
||
} else {
|
||
t.url = "https://" + host + "/"; // display: https://; internal fetch: bns://
|
||
await t.view.webContents.loadURL(`bns://${host}/`);
|
||
const rec = entries.get(host);
|
||
const r = rec?.entry?.records || {};
|
||
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
|
||
const _isSub = rec?.entry?.name && host !== rec.entry.name;
|
||
const src = (_isSub && r.ip) ? "direct server"
|
||
: r.h ? "on-chain (chain)"
|
||
: r.s3 ? "Sia network"
|
||
: r.ip ? "direct server"
|
||
: (!_isSub && r.p) ? "mirror"
|
||
: r.u ? "redirect"
|
||
: "record";
|
||
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), dns: dnsRecordKinds(rec?.entry), tld, registry };
|
||
}
|
||
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
|
||
finally { t.internalNav = false; setLoading(t, false); }
|
||
if (t.id === activeId) pushNav(t.prov);
|
||
emitTabs();
|
||
}
|
||
// Ariadne's Thread menu — the registry button at the end of the address
|
||
// bar. Replaces the BCDN/ICANN segmented chips: one icon that never
|
||
// overflows the bar, a native popup with the switch, the per-name / per-TLD
|
||
// memory, the collision policy, and a jump to the resolver settings.
|
||
ipcMain.handle("registry-menu-popup", (e, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("registry-menu-popup: untrusted sender");
|
||
const t = activeTab();
|
||
const prov = t?.prov || null;
|
||
const host = String(prov?.host || "");
|
||
const tld = String(prov?.tld || (host ? tldOf(host) : "") || "");
|
||
const onBcdn = prov?.kind === "ok";
|
||
const onIcann = prov?.kind === "web";
|
||
const isCand = !!tld && (onBcdn || onIcann) && !isBcnrNativeTld(tld);
|
||
const current = onBcdn ? "bcnr" : "icann";
|
||
const policyItem = (value, label) => ({
|
||
label, type: "radio", checked: settings.collisionPolicy === value,
|
||
click: () => { settings.collisionPolicy = value; saveSettings(); },
|
||
});
|
||
const template = [
|
||
{ label: host ? `Ariadne's Thread · ${host}` : "Ariadne's Thread", enabled: false },
|
||
{ type: "separator" },
|
||
{ label: "Serve from BCDN", type: "radio", checked: onBcdn, enabled: isCand || onBcdn,
|
||
click: () => { if (!onBcdn) switchRegistry({ choice: "bcnr", remember: "no" }).catch(() => {}); } },
|
||
{ label: "Serve from ICANN", type: "radio", checked: onIcann, enabled: isCand || onIcann,
|
||
click: () => { if (!onIcann) switchRegistry({ choice: "icann", remember: "no" }).catch(() => {}); } },
|
||
{ label: "Remember", enabled: !!host, submenu: [
|
||
{ label: host ? `Always open ${host} this way` : "Always open this site this way", enabled: isCand,
|
||
click: () => rememberCollision(host, tld, current, "name") },
|
||
{ label: tld ? `Always open .${tld} names this way` : "Always open this TLD this way", enabled: isCand,
|
||
click: () => rememberCollision(host, tld, current, "tld") },
|
||
{ type: "separator" },
|
||
{ label: "Forget remembered choices", click: () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); } },
|
||
] },
|
||
{ type: "separator" },
|
||
{ label: "When a name exists on both registries", submenu: [
|
||
policyItem("bcnr-first", "BCDN first"),
|
||
policyItem("icann-first", "ICANN first"),
|
||
policyItem("soft", "Ask each time"),
|
||
] },
|
||
{ type: "separator" },
|
||
{ label: "Ariadne's Thread settings…", click: () => openSettingsTab("plugins") },
|
||
];
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
|
||
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
|
||
popup.popup({ window: win, x, y });
|
||
});
|
||
// Back/Forward history menu (press-and-hold or right-click on the buttons).
|
||
// Lists up to 15 entries in the requested direction, nearest first, from the
|
||
// active tab's navigation history; picking one jumps straight to it.
|
||
ipcMain.handle("nav-history-menu", (e, dir, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("nav-history-menu: untrusted sender");
|
||
const t = activeTab(); const wc = t?.view?.webContents;
|
||
if (!wc) return false;
|
||
const nh = wc.navigationHistory;
|
||
const entries = nh.getAllEntries();
|
||
const cur = nh.getActiveIndex();
|
||
const picks = [];
|
||
if (dir === "forward") for (let i = cur + 1; i < entries.length && picks.length < 15; i++) picks.push(i);
|
||
else for (let i = cur - 1; i >= 0 && picks.length < 15; i--) picks.push(i);
|
||
if (!picks.length) return false;
|
||
const label = (en) => {
|
||
const title = String(en.title || "").trim();
|
||
let host = ""; try { host = new URL(en.url).host; } catch {}
|
||
const text = title || en.url || "";
|
||
return (text.length > 60 ? text.slice(0, 57) + "…" : text) + (host && title ? ` — ${host}` : "");
|
||
};
|
||
const template = picks.map((i) => ({ label: label(entries[i]), click: () => { try { nh.goToIndex(i); } catch {} } }));
|
||
const popup = Menu.buildFromTemplate(template);
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
popup.popup({ window: win, x: Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0))), y: Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0))) });
|
||
return true;
|
||
});
|
||
ipcMain.handle("collision-state", () => ({
|
||
policy: settings.collisionPolicy,
|
||
byName: collisions.byName,
|
||
byTld: collisions.byTld,
|
||
bcnrTlds,
|
||
}));
|
||
ipcMain.handle("collision-set-policy", (_e, p) => {
|
||
if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); }
|
||
return settings.collisionPolicy;
|
||
});
|
||
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
|
||
// Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe
|
||
// as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge").
|
||
// Turning them off means non-Theseus browsers stop resolving BCDN names on
|
||
// this machine; Theseus itself uses its own in-process resolver so it's
|
||
// unaffected. Toggling requires admin (tasks run as SYSTEM) — start/stop go
|
||
// through an elevated powershell that UAC-prompts once per action.
|
||
//
|
||
// As of 0.3.23 Ariadne is NOT bundled inside Theseus. Install / Update stream
|
||
// AriadneResolver-Setup-<v>.exe directly from silentmode.st and verify its
|
||
// SHA-256 against the on-site releases manifest before spawning it, so
|
||
// Ariadne's release cadence is decoupled from ours.
|
||
const ARIADNE_TASKS = ["BNS Resolver Daemon", "BNS Sia Bridge"];
|
||
// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both
|
||
// native and WOW6432 in case Inno installed either way.
|
||
// PowerShell fragment that leaves Ariadne's Inno uninstall entry in $r (or
|
||
// $null). Found by DisplayName rather than by key path: the installer's
|
||
// AppId is written as `{{…}}`, which Inno stores as `{…}}_is1` (doubled
|
||
// closing brace), so the literal key path Theseus used to look for never
|
||
// matched — the panel showed no version, no Update, no Uninstall. Matching
|
||
// on the name also survives a future AppId fix.
|
||
// HKLM only: the installer is PrivilegesRequired=admin, and HKCU is writable
|
||
// by any process the user runs — a planted "Ariadne Resolver" entry there
|
||
// would have its uninstall string run elevated by ariadneUninstall.
|
||
const ARIADNE_REG_LOOKUP =
|
||
"$r=$null;foreach($root in 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'){" +
|
||
"if($r){break};foreach($k in (Get-ChildItem $root -ErrorAction SilentlyContinue)){$p=Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue;" +
|
||
"if($p.DisplayName -like 'Ariadne Resolver*' -and $p.QuietUninstallString){$r=$p;break}}};";
|
||
// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on
|
||
// silentmode.st is a mirror that has lagged behind dl.silentmode.st (2026-09-09:
|
||
// it still listed Theseus 0.3.31 while dl had 0.3.44), so the Ariadne "Update"
|
||
// button was checking against a stale version list.
|
||
const ARIADNE_MANIFEST_URL = "https://dl.silentmode.st/releases-manifest.json";
|
||
const ARIADNE_DL_ORIGIN = "https://dl.silentmode.st";
|
||
const ARIADNE_MANIFEST_TTL_MS = 30 * 60 * 1000;
|
||
let ariadneManifestCache = null; // { at:number, entry:{version,filename,sha256,url}|null }
|
||
|
||
// GET the releases manifest, find the win-x64 ariadne-resolver entry, return
|
||
// {version, filename, sha256, url}. Cached 30 min in-process; opening the
|
||
// Settings panel every few seconds does not spam silentmode.st. On any
|
||
// failure (offline, 5xx, malformed JSON) returns null and the caller shows
|
||
// bundledVersion:null / canUpdate:false gracefully.
|
||
function ariadneManifestFetch() {
|
||
const now = Date.now();
|
||
if (ariadneManifestCache && now - ariadneManifestCache.at < ARIADNE_MANIFEST_TTL_MS) {
|
||
return Promise.resolve(ariadneManifestCache.entry);
|
||
}
|
||
return new Promise((resolve) => {
|
||
const https = require("node:https");
|
||
const req = https.request(ARIADNE_MANIFEST_URL, {
|
||
method: "GET", timeout: 8000,
|
||
headers: { "user-agent": "TheseusNavigator/ariadne-updater" },
|
||
}, (r) => {
|
||
if (r.statusCode !== 200) { r.resume(); ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
|
||
const chunks = [];
|
||
r.on("data", (c) => chunks.push(c));
|
||
r.on("end", () => {
|
||
try {
|
||
const j = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||
const rel = (j.releases || []).find((x) => x.id === "ariadne-resolver" && x.platform === "win-x64");
|
||
if (!rel) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
|
||
const filename = Object.keys(rel.files || {}).find((f) => /^AriadneResolver-Setup-.*\.exe$/i.test(f));
|
||
if (!filename) { ariadneManifestCache = { at: now, entry: null }; return resolve(null); }
|
||
const entry = { version: rel.version, filename, sha256: String(rel.files[filename] || "").toLowerCase(), url: ARIADNE_DL_ORIGIN + "/" + filename };
|
||
ariadneManifestCache = { at: now, entry };
|
||
resolve(entry);
|
||
} catch { ariadneManifestCache = { at: now, entry: null }; resolve(null); }
|
||
});
|
||
});
|
||
req.on("timeout", () => req.destroy(new Error("manifest timeout")));
|
||
req.on("error", () => { ariadneManifestCache = { at: now, entry: null }; resolve(null); });
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// Stream the .exe to a per-session temp file, hashing as we go. Reject on
|
||
// hash mismatch (and delete the file) so a wrong-hash binary is never spawned.
|
||
// The SHA-256 is authoritative because the manifest itself is served over
|
||
// HTTPS -- silentmode.st TLS -> manifest.json -> hash -> verified .exe.
|
||
function ariadneDownloadInstaller(entry) {
|
||
return new Promise((resolve, reject) => {
|
||
const https = require("node:https");
|
||
const crypto = require("node:crypto");
|
||
const dst = path.join(app.getPath("temp"), `ariadne-${entry.version}-${Date.now()}.exe`);
|
||
const req = https.request(entry.url, {
|
||
method: "GET", timeout: 60000,
|
||
headers: { "user-agent": "TheseusNavigator/ariadne-updater" },
|
||
}, (r) => {
|
||
if (r.statusCode !== 200) { r.resume(); return reject(new Error(`download ${entry.url} -> HTTP ${r.statusCode}`)); }
|
||
const hash = crypto.createHash("sha256");
|
||
const out = fs.createWriteStream(dst);
|
||
r.on("data", (c) => hash.update(c));
|
||
r.pipe(out);
|
||
out.on("finish", () => {
|
||
const got = hash.digest("hex").toLowerCase();
|
||
if (got !== entry.sha256) {
|
||
try { fs.unlinkSync(dst); } catch {}
|
||
return reject(new Error(`SHA-256 mismatch: got ${got}, want ${entry.sha256}`));
|
||
}
|
||
resolve(dst);
|
||
});
|
||
out.on("error", (e) => { try { fs.unlinkSync(dst); } catch {}; reject(e); });
|
||
});
|
||
req.on("timeout", () => req.destroy(new Error("download timeout")));
|
||
req.on("error", reject);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function ariadneQueryState() {
|
||
const { spawn } = require("child_process");
|
||
// One shell round-trip for both bits of info:
|
||
// - task states for BNS Resolver Daemon + BNS Sia Bridge
|
||
// - installed version + quiet-uninstall string from Inno's registry entry
|
||
// Task state comes from the Task Scheduler COM object, not Get-ScheduledTask:
|
||
// that cmdlet imports the ScheduledTasks module, which took 8–10 s cold on the
|
||
// 0.3.48 install and left the panel on "checking…" with every button hidden
|
||
// for that long (user report 2026-09-16). The COM state is a number, so it
|
||
// is also locale-independent (schtasks.exe prints localized status words).
|
||
// The manifest fetch used to run after this shell finished; it now runs in
|
||
// parallel.
|
||
const shell = new Promise((resolve) => {
|
||
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
|
||
"$svc=New-Object -ComObject Schedule.Service;$svc.Connect();$f=$svc.GetFolder('\\');" +
|
||
"foreach($n in 'BNS Resolver Daemon','BNS Sia Bridge'){try{$t=$f.GetTask($n);\"$n=$($t.State)\"}catch{\"$n=MISSING\"}};" +
|
||
ARIADNE_REG_LOOKUP +
|
||
"if ($r) { \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\" }"
|
||
], { windowsHide: true });
|
||
let out = "";
|
||
ps.stdout.on("data", (d) => { out += d; });
|
||
ps.on("close", () => resolve(out));
|
||
ps.on("error", () => resolve(""));
|
||
});
|
||
return Promise.all([shell, ariadneManifestFetch()]).then(([out, latest]) => {
|
||
const lines = out.trim().split(/\r?\n/).filter(Boolean);
|
||
const map = Object.fromEntries(lines.map((l) => { const i = l.lastIndexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
|
||
const primary = map["BNS Resolver Daemon"]; // TASK_STATE: 1 disabled, 2 queued, 3 ready, 4 running
|
||
const installedVersion = map.__VER__ || null;
|
||
const quietUninstall = map.__UNINSTALL__ || null;
|
||
const latestVersion = latest ? latest.version : null;
|
||
const canUpdate = !!(installedVersion && latestVersion && cmpVersions(latestVersion, installedVersion) > 0);
|
||
let state;
|
||
if (!primary || primary === "MISSING") state = installedVersion ? "stopped" : "not-installed";
|
||
else if (primary === "4" || primary === "Running") state = "running";
|
||
else state = "stopped";
|
||
// The "bundledVersion" field name is kept for renderer compatibility --
|
||
// it now carries the latest version advertised by silentmode.st's
|
||
// releases manifest, not a version physically bundled with Theseus.
|
||
return { state, installedVersion, bundledVersion: latestVersion, canUpdate, hasUninstaller: !!quietUninstall };
|
||
});
|
||
}
|
||
function cmpVersions(a, b) {
|
||
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
||
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
||
const n = Math.max(pa.length, pb.length);
|
||
for (let i = 0; i < n; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
|
||
return 0;
|
||
}
|
||
function ariadneSetState(on) {
|
||
return new Promise((resolve, reject) => {
|
||
const { spawn } = require("child_process");
|
||
// Off = stop AND disable, on = enable AND start. The daemon task has an
|
||
// at-startup trigger, so a plain Stop-ScheduledTask came back on the
|
||
// next reboot and "Turn off" silently didn't stick.
|
||
//
|
||
// The inner script goes across as -EncodedCommand (base64 UTF-16LE). The
|
||
// previous version embedded it in a double-quoted string on the OUTER
|
||
// powershell's command line, which interpolated `$t` to nothing before
|
||
// the elevated shell ever saw it — the elevated shell got
|
||
// `foreach ( in ...)`, failed to parse, and the outer shell still exited
|
||
// 0, so the toggle reported success while doing nothing.
|
||
const inner = on
|
||
? `$ok = $true
|
||
foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') {
|
||
try { Enable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null; Start-ScheduledTask -TaskName $t -ErrorAction Stop }
|
||
catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } }
|
||
}
|
||
if ($ok) { exit 0 } else { exit 2 }`
|
||
: `$ok = $true
|
||
foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') {
|
||
try { Stop-ScheduledTask -TaskName $t -ErrorAction Stop } catch {}
|
||
try { Disable-ScheduledTask -TaskName $t -ErrorAction Stop | Out-Null }
|
||
catch { if ($t -eq 'BNS Resolver Daemon') { $ok = $false } }
|
||
}
|
||
if ($ok) { exit 0 } else { exit 2 }`;
|
||
const encoded = Buffer.from(inner, "utf16le").toString("base64");
|
||
// -PassThru + exit $p.ExitCode: the elevated shell's exit code (0 ok,
|
||
// 2 = daemon task missing) reaches us; a declined UAC prompt makes
|
||
// Start-Process throw, which exits the outer shell non-zero.
|
||
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
|
||
`$p = Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}'; exit $p.ExitCode`], { windowsHide: true });
|
||
ps.on("close", (code) => {
|
||
if (code === 0) resolve(true);
|
||
else if (code === 2) reject(new Error("the 'BNS Resolver Daemon' scheduled task is missing — reinstall Ariadne's Thread"));
|
||
else reject(new Error("UAC declined or task failed (exit " + code + ")"));
|
||
});
|
||
ps.on("error", (e) => reject(e));
|
||
});
|
||
}
|
||
// Fetch manifest -> download+verify AriadneResolver-Setup-<v>.exe -> spawn
|
||
// Inno silently+elevated (/VERYSILENT /SUPPRESSMSGBOXES /NORESTART). The
|
||
// downloaded .exe is deleted whether the install succeeds or fails, so a
|
||
// wrong-hash abort never leaves a suspect binary behind.
|
||
async function ariadneInstall() {
|
||
const entry = await ariadneManifestFetch();
|
||
if (!entry) throw new Error("could not reach silentmode.st releases manifest");
|
||
const exePath = await ariadneDownloadInstaller(entry);
|
||
const { spawn } = require("child_process");
|
||
return new Promise((resolve, reject) => {
|
||
// -PassThru + exit $p.ExitCode so Inno's own exit code reaches us; a bare
|
||
// -Wait always returned 0 and a failed silent install looked like success.
|
||
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
|
||
`$p = Start-Process -FilePath '${exePath.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`
|
||
], { windowsHide: true });
|
||
const cleanup = () => { try { fs.unlinkSync(exePath); } catch {} };
|
||
ps.on("close", (code) => { cleanup(); code === 0 ? resolve(true) : reject(new Error("installer exited " + code)); });
|
||
ps.on("error", (e) => { cleanup(); reject(e); });
|
||
});
|
||
}
|
||
// Run Inno's own quiet uninstaller. Reads the QuietUninstallString from the
|
||
// registry (already ends in / VERYSILENT) and spawns it elevated.
|
||
function ariadneUninstall() {
|
||
const { spawn } = require("child_process");
|
||
return new Promise((resolve, reject) => {
|
||
// Read the uninstall string fresh — a stale cache could point at a moved
|
||
// file. Extract the path (may be quoted) + any trailing args.
|
||
const cmd = ARIADNE_REG_LOOKUP + "if($r){Write-Host $r.QuietUninstallString}";
|
||
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true });
|
||
let out = "";
|
||
ps.stdout.on("data", (d) => { out += d; });
|
||
ps.on("close", () => {
|
||
const uninstallCmd = out.trim();
|
||
if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered"));
|
||
// uninstallCmd typically: "C:\Program Files\Ariadne Resolver\unins000.exe" /SILENT
|
||
// Only the Inno uninstaller itself is run elevated — never the registry
|
||
// string through cmd /c (the UAC prompt would only name cmd.exe).
|
||
const m = /^\s*"([^"]+)"|^\s*(\S+)/.exec(uninstallCmd);
|
||
const exe = path.resolve((m && (m[1] || m[2])) || "");
|
||
const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean).map((r) => path.resolve(r).toLowerCase() + path.sep);
|
||
if (!/^unins\d{3}\.exe$/i.test(path.basename(exe)) || !roots.some((r) => exe.toLowerCase().startsWith(r))) {
|
||
return reject(new Error("unexpected Ariadne uninstaller path: " + exe));
|
||
}
|
||
// Inno's silent uninstall respects /VERYSILENT so no UI.
|
||
const runCmd = `$p = Start-Process -FilePath '${exe.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
|
||
const ps2 = spawn("powershell.exe", ["-NoProfile", "-Command", runCmd], { windowsHide: true });
|
||
ps2.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code)));
|
||
ps2.on("error", reject);
|
||
});
|
||
ps.on("error", reject);
|
||
});
|
||
}
|
||
// Update = run the bundled installer over the top. Inno Setup detects the
|
||
// same AppId and upgrades in place. Same UAC dance as install.
|
||
const ariadneUpdate = ariadneInstall;
|
||
ipcMain.handle("ariadne-state", () => ariadneQueryState().catch(() => ({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false })));
|
||
ipcMain.handle("ariadne-toggle", (_e, on) => ariadneSetState(!!on).catch((e) => ({ ok: false, error: e?.message || String(e) })));
|
||
ipcMain.handle("ariadne-install", () => ariadneInstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
|
||
ipcMain.handle("ariadne-update", () => ariadneUpdate().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
|
||
ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
|
||
|
||
// ---- Ariadne 0.1.13+ settings + status wiring --------------------------
|
||
// The daemon's own read/write surface lives at http://127.0.0.1/api/status
|
||
// and C:\ProgramData\Ariadne\policy.json. The policy file is ACL'd user-
|
||
// writable by install.ps1, so all four of these run WITHOUT UAC.
|
||
const ARIADNE_POLICY_FILE = (() => {
|
||
const dir = process.env.PROGRAMDATA || "C:\\ProgramData";
|
||
return path.join(dir, "Ariadne", "policy.json");
|
||
})();
|
||
function ariadneReadPolicyFile() {
|
||
try {
|
||
if (!fs.existsSync(ARIADNE_POLICY_FILE)) return {};
|
||
const raw = fs.readFileSync(ARIADNE_POLICY_FILE, "utf8").replace(/^\uFEFF/, "");
|
||
return JSON.parse(raw) || {};
|
||
} catch { return {}; }
|
||
}
|
||
function ariadneWritePolicyFile(obj) {
|
||
try {
|
||
fs.mkdirSync(path.dirname(ARIADNE_POLICY_FILE), { recursive: true });
|
||
fs.writeFileSync(ARIADNE_POLICY_FILE, JSON.stringify(obj, null, 2), "utf8");
|
||
return { ok: true };
|
||
} catch (e) { return { ok: false, error: e?.message || String(e) }; }
|
||
}
|
||
// GET http://127.0.0.1/api/status against the local daemon. Returns the full
|
||
// status document, or {ok:false, error} on any failure (daemon down, port
|
||
// blocked, localApi turned off in policy.json -> the daemon returns 503).
|
||
function ariadneFetchStatus() {
|
||
return new Promise((resolve) => {
|
||
const req = http.request({ host: "127.0.0.1", port: 80, path: "/api/status", method: "GET", headers: { "user-agent": "TheseusNavigator/ariadne-panel" } }, (r) => {
|
||
const chunks = [];
|
||
r.on("data", (c) => chunks.push(c));
|
||
r.on("end", () => {
|
||
if (r.statusCode !== 200) return resolve({ ok: false, error: `daemon HTTP ${r.statusCode}`, body: Buffer.concat(chunks).toString("utf8").slice(0, 400) });
|
||
try { resolve({ ok: true, status: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); }
|
||
catch (e) { resolve({ ok: false, error: "malformed JSON from daemon: " + e.message }); }
|
||
});
|
||
});
|
||
req.setTimeout(4000, () => req.destroy(new Error("daemon timeout on 127.0.0.1/api/status")));
|
||
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
||
req.end();
|
||
});
|
||
}
|
||
ipcMain.handle("ariadne-get-status", () => ariadneFetchStatus());
|
||
ipcMain.handle("ariadne-get-policy", () => ({ ok: true, policy: ariadneReadPolicyFile() }));
|
||
ipcMain.handle("ariadne-set-policy", (_e, value) => {
|
||
const v = String(value || "").toLowerCase();
|
||
if (!["bcnr-first", "icann-first"].includes(v)) return { ok: false, error: `invalid policy '${value}' (expected bcnr-first or icann-first)` };
|
||
const cur = ariadneReadPolicyFile();
|
||
cur.policy = v;
|
||
return ariadneWritePolicyFile(cur);
|
||
});
|
||
ipcMain.handle("ariadne-set-source", (_e, name, enabled) => {
|
||
const known = ["snapshotHttps", "electrumWss", "perQueryLookup", "diskCache", "localApi"];
|
||
if (!known.includes(String(name || ""))) return { ok: false, error: `unknown source '${name}' (known: ${known.join(", ")})` };
|
||
const cur = ariadneReadPolicyFile();
|
||
if (!cur.sources || typeof cur.sources !== "object") cur.sources = {};
|
||
if (!cur.sources[name] || typeof cur.sources[name] !== "object") cur.sources[name] = {};
|
||
cur.sources[name].enabled = !!enabled;
|
||
return ariadneWritePolicyFile(cur);
|
||
});
|
||
// Storage: clear right now (any subset). "history" also drops the saved-session file.
|
||
// ---- Password vault -------------------------------------------------------
|
||
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
|
||
// held in this main-process closure only — never sent to a renderer except
|
||
// in the explicit response to password-get(id). Cleared on quit alongside
|
||
// the other storage clears (see before-quit hook).
|
||
const vaultFile = () => path.join(app.getPath("userData"), "passwords.vault");
|
||
let vaultState = null; // { key, purposeRoot, entries, _salt, _iters }
|
||
// Imports live in a SEPARATE encrypted file (design §3.2) so a bug in one
|
||
// vault can't destroy the other, and so an attacker holding the primary
|
||
// purposeRoot in RAM never yields the imports' seeds/WIFs. Same master
|
||
// password, different KDF salt = disjoint AES keys.
|
||
const importsFile = () => path.join(app.getPath("userData"), "wallet-imports.enc");
|
||
let importsState = null; // { key, accounts, _salt, _iters }
|
||
let importsUnlockPw = null; // held only if we may need to write the file this session
|
||
const vaultOk = () => ({ ok: true });
|
||
const vaultErr = (m) => ({ ok: false, err: String(m) });
|
||
|
||
ipcMain.handle("password-status", () => ({
|
||
setup: fs.existsSync(vaultFile()),
|
||
unlocked: !!vaultState,
|
||
}));
|
||
|
||
ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => {
|
||
try {
|
||
if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short");
|
||
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
|
||
const v = await loadVaultLib();
|
||
let purposeRootHex, messengerRootHex;
|
||
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
|
||
// Same seed, two purpose roots — one for password derivation, one for
|
||
// the Nostr messaging identity. Storing both means Hermes can bind to
|
||
// the vault so the user never re-enters the mnemonic. Different HKDF
|
||
// info strings keep the two subtrees cryptographically disjoint.
|
||
const seed = await v.bip39ToSeed(String(seedSource.mnemonic));
|
||
purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0"));
|
||
messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0"));
|
||
} else {
|
||
// Independent random seed — 32 bytes of purposeRoot directly. No mnemonic
|
||
// means no messenger root; Hermes will fall back to its own mnemonic entry.
|
||
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
|
||
purposeRootHex = v.bytesToHex(root);
|
||
}
|
||
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex,
|
||
messengerRootHex ? { messengerRootHex } : {});
|
||
emitPwAvailability();
|
||
return vaultOk();
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-unlock", async (_e, masterPassword) => {
|
||
try {
|
||
if (!fs.existsSync(vaultFile())) return vaultErr("no vault");
|
||
const v = await loadVaultLib();
|
||
vaultState = await v.unlockVault(vaultFile(), masterPassword);
|
||
// Same master password unlocks wallet-imports.enc when it exists. A
|
||
// mismatched password wouldn't get us here (the primary decrypt would
|
||
// have thrown), so this second decrypt is guaranteed to succeed with
|
||
// the same input — differ only in the salt.
|
||
importsState = null;
|
||
if (fs.existsSync(importsFile())) {
|
||
try { importsState = await v.unlockImports(importsFile(), masterPassword); }
|
||
catch (ie) { console.error("[imports] unlock failed:", ie?.message); }
|
||
}
|
||
importsUnlockPw = masterPassword;
|
||
emitPwAvailability();
|
||
return { ok: true, entries: v.listMetadata(vaultState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-lock", () => {
|
||
vaultState = null;
|
||
importsState = null;
|
||
importsUnlockPw = null;
|
||
emitPwAvailability();
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle("password-list", async () => {
|
||
if (!vaultState) return { ok: false, err: "locked" };
|
||
const v = await loadVaultLib();
|
||
return { ok: true, entries: v.listMetadata(vaultState) };
|
||
});
|
||
|
||
ipcMain.handle("password-get", async (_e, id) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
const password = await v.resolvePassword(vaultState, id);
|
||
return { ok: true, password };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-add", async (_e, spec) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
const entry = v.newEntry(spec || {});
|
||
vaultState.entries.push(entry);
|
||
await v.saveVault(vaultFile(), vaultState);
|
||
return { ok: true, id: entry.id, entries: v.listMetadata(vaultState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-update", async (_e, id, patch) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
const e = vaultState.entries.find((x) => x.id === id);
|
||
if (!e) return vaultErr("no such entry");
|
||
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
|
||
for (const k of ["domain", "username", "literal", "generated"]) if (patch && k in patch) e[k] = patch[k];
|
||
// Switching between literal and generated: drop the other field.
|
||
if (patch && "literal" in patch) delete e.generated;
|
||
if (patch && "generated" in patch) delete e.literal;
|
||
await v.saveVault(vaultFile(), vaultState);
|
||
return { ok: true, entries: v.listMetadata(vaultState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-remove", async (_e, id) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
vaultState.entries = vaultState.entries.filter((x) => x.id !== id);
|
||
await v.saveVault(vaultFile(), vaultState);
|
||
return { ok: true, entries: v.listMetadata(vaultState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("password-generate", async (_e, { domain, username = "", version = 1, rules } = {}) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
const password = await v.derivePassword(vaultState.purposeRoot, { domain, username, version, rules });
|
||
return { ok: true, password };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
// ---- wallet imports (DESIGN-wallet-multi-account-amendment.md §3.2/§3.3) ---
|
||
// Read-only listing — safe for any renderer, does not leak seeds/WIFs.
|
||
ipcMain.handle("wallet-imports-list", async () => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
const v = await loadVaultLib();
|
||
const entries = importsState ? v.listImportsMetadata(importsState) : [];
|
||
return { ok: true, entries };
|
||
});
|
||
|
||
// Add an import. Add-ons (Aegis) call this via api.vault.imports.add(spec).
|
||
// The seed/WIF stay in main-process memory — never re-emitted to renderers.
|
||
// The caller is expected to have already derived cashaddr client-side; we
|
||
// store it verbatim, and Aegis's safety-net check re-derives on load and
|
||
// warns on mismatch (design §6).
|
||
ipcMain.handle("wallet-imports-add", async (_e, spec) => {
|
||
if (!vaultState) return vaultErr("locked");
|
||
if (!importsUnlockPw) return vaultErr("locked");
|
||
try {
|
||
if (!spec || typeof spec !== "object") throw new Error("spec required");
|
||
const kind = String(spec.kind || "");
|
||
if (kind !== "seed" && kind !== "wif") throw new Error(`unknown kind: ${kind}`);
|
||
const cashaddr = String(spec.cashaddr || "").trim();
|
||
if (!cashaddr) throw new Error("cashaddr required (caller derives)");
|
||
const label = String(spec.label || "").trim().slice(0, 120);
|
||
if (!label) throw new Error("label required");
|
||
const category = String(spec.category || "").trim().slice(0, 40) || "operational";
|
||
const source = String(spec.source || "").trim().slice(0, 500);
|
||
const v = await loadVaultLib();
|
||
if (!importsState) {
|
||
importsState = await v.createImports(importsFile(), importsUnlockPw);
|
||
}
|
||
// Choose a URL-safe id: user-provided or derived from the label. Collision-
|
||
// safe: append a short suffix if it already exists.
|
||
const rawId = String(spec.id || label).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "wallet";
|
||
let id = rawId, n = 1;
|
||
while (importsState.accounts[id]) { n++; id = `${rawId}-${n}`; }
|
||
const rec = {
|
||
kind, cashaddr, label, category, source,
|
||
createdAt: Date.now(),
|
||
};
|
||
if (kind === "seed") {
|
||
if (!spec.seed || !spec.path) throw new Error("seed and path required for kind=seed");
|
||
rec.seed = String(spec.seed);
|
||
rec.path = String(spec.path);
|
||
} else {
|
||
if (!spec.wif) throw new Error("wif required for kind=wif");
|
||
rec.wif = String(spec.wif);
|
||
}
|
||
importsState.accounts[id] = rec;
|
||
await v.saveImports(importsFile(), importsState);
|
||
return { ok: true, id, entries: v.listImportsMetadata(importsState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("wallet-imports-remove", async (_e, id) => {
|
||
if (!vaultState || !importsState) return vaultErr("locked");
|
||
try {
|
||
if (!importsState.accounts[id]) return vaultErr("no such import");
|
||
delete importsState.accounts[id];
|
||
const v = await loadVaultLib();
|
||
await v.saveImports(importsFile(), importsState);
|
||
return { ok: true, entries: v.listImportsMetadata(importsState) };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
// Signer material for one import — only for add-ons that already have
|
||
// vault-derive-equivalent trust. NEVER called from a page renderer directly;
|
||
// gated by addon-msg the same way api.vault.derive is.
|
||
ipcMain.handle("wallet-imports-signer", async (_e, id) => {
|
||
if (!vaultState || !importsState) return vaultErr("locked");
|
||
try {
|
||
const v = await loadVaultLib();
|
||
const signer = v.getImportSigner(importsState, id);
|
||
return { ok: true, signer };
|
||
} catch (e) { return vaultErr(e?.message || e); }
|
||
});
|
||
|
||
ipcMain.handle("clear-browsing-data", async (_e, opts) => {
|
||
const o = opts || {};
|
||
await clearBrowsingData({ cookies: !!o.cookies, cache: !!o.cache, storage: !!o.storage });
|
||
if (o.history) await clearHistoryNow();
|
||
return true;
|
||
});
|
||
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
|
||
ipcMain.handle("set-search-engine", (_e, id) => {
|
||
if (allEngines().some((e) => e.id === id && !e.frozen)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
|
||
return settings.searchEngine;
|
||
});
|
||
ipcMain.handle("add-engine", async (_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();
|
||
// Settings pulls the list once on return, so give the icon fetch a moment
|
||
// to land; offline or slow, the row shows the emoji and the toolbar
|
||
// repaints on its own when the icon arrives.
|
||
const host = engineHost(eng.url);
|
||
if (host && !cachedIconFile(host)) await Promise.race([fetchEngineIcon(host).catch(() => null), new Promise((r) => setTimeout(r, 4000))]);
|
||
}
|
||
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.
|
||
dropCustomIcon(id);
|
||
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)) {
|
||
dropCustomIcon(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] && !(on && isFrozen(id))) { // a frozen engine can be turned off, never on
|
||
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 (justClosedByClickAway(enginePicker)) return;
|
||
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 (justClosedByClickAway(downloadsPop)) return;
|
||
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 ? liveHost(t) : "";
|
||
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) => {
|
||
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
|
||
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({ domain: entry.domain, username: entry.username, password });
|
||
} catch (e) { return { ok: false, err: e?.message || String(e) }; }
|
||
});
|
||
// The chrome sends arrow-up/down/enter through so the picker can move its
|
||
// selection cursor without stealing focus from the address input.
|
||
ipcMain.handle("address-cursor", (_e, dir) => {
|
||
if (apVisible && addressPicker) try { addressPicker.webContents.send("address-cursor", dir); } catch {}
|
||
});
|
||
ipcMain.handle("download-open", (_e, id) => {
|
||
const d = downloads.find((x) => x.id === id);
|
||
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
|
||
});
|
||
ipcMain.handle("download-show", (_e, id) => {
|
||
const d = downloads.find((x) => x.id === id);
|
||
if (d?.savePath) { try { shell.showItemInFolder(d.savePath); } catch {} }
|
||
});
|
||
ipcMain.handle("download-cancel", (_e, id) => {
|
||
const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} }
|
||
});
|
||
// Only clear finished downloads; a progressing one is cancelled first.
|
||
ipcMain.handle("download-clear", (_e, id) => {
|
||
const i = downloads.findIndex((x) => x.id === id);
|
||
if (i < 0) return;
|
||
if (downloads[i].state === "progressing") { const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } }
|
||
downloads.splice(i, 1); dlItems.delete(id);
|
||
emitDownloads();
|
||
});
|
||
ipcMain.handle("downloads-clear-all", () => {
|
||
// Keep any still-progressing ones; drop everything else.
|
||
for (let i = downloads.length - 1; i >= 0; i--) if (downloads[i].state !== "progressing") downloads.splice(i, 1);
|
||
emitDownloads();
|
||
});
|
||
// OpenSearch "scan": add the search engine the current page advertises.
|
||
ipcMain.handle("add-detected-engine", () => {
|
||
const d = activeTab()?.detected;
|
||
if (d && d.url && d.url.includes("%s")) {
|
||
const id = "custom-" + Date.now().toString(36);
|
||
settings.customEngines = [...(settings.customEngines || []), { id, name: d.name.slice(0, 40), sym: "🔍", url: d.url.slice(0, 400) }];
|
||
settings.searchEngine = id;
|
||
saveSettings(); emitEngines();
|
||
}
|
||
showEnginePicker(false);
|
||
});
|
||
ipcMain.handle("bookmarks-get", () => bookmarks);
|
||
ipcMain.handle("bookmark-add", (_e, bm) => {
|
||
if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) {
|
||
const entry = { title: bm.title || bm.url, url: bm.url };
|
||
if (bm.favicon) entry.favicon = String(bm.favicon).slice(0, 2048);
|
||
bookmarks.push(entry);
|
||
saveBookmarks(); emitBookmarks();
|
||
}
|
||
return bookmarks;
|
||
});
|
||
// Update fields on an existing bookmark, identified by URL. Used by the
|
||
// inline title editor (window.prompt is disabled in Electron BrowserViews,
|
||
// so the renderer builds its own modal and calls this). Merges partial
|
||
// updates — omitted fields stay as they are, and blank strings are
|
||
// rejected for title so a bad edit can't wipe the label.
|
||
ipcMain.handle("bookmark-update", (_e, url, patch) => {
|
||
if (typeof url !== "string" || !url || !patch || typeof patch !== "object") return bookmarks;
|
||
const bm = bookmarks.find((b) => b.url === url);
|
||
if (!bm) return bookmarks;
|
||
if (typeof patch.title === "string" && patch.title.trim()) bm.title = patch.title.trim().slice(0, 200);
|
||
if (typeof patch.favicon === "string" && patch.favicon) bm.favicon = patch.favicon.slice(0, 2048);
|
||
saveBookmarks(); emitBookmarks();
|
||
return bookmarks;
|
||
});
|
||
ipcMain.handle("bookmark-remove", (_e, url) => {
|
||
bookmarks = bookmarks.filter((b) => b.url !== url);
|
||
saveBookmarks(); emitBookmarks();
|
||
return bookmarks;
|
||
});
|
||
// Reorder: pull `fromUrl` out of the list and reinsert it before or after
|
||
// `targetUrl`. Renderer picks the side by which half of the target chip the
|
||
// pointer is on, same convention the tab strip uses. A missing entry or a
|
||
// self-drop is a no-op, so noisy drag events don't corrupt the list.
|
||
ipcMain.handle("bookmark-move", (_e, fromUrl, targetUrl, place) => {
|
||
if (typeof fromUrl !== "string" || typeof targetUrl !== "string" || fromUrl === targetUrl) return bookmarks;
|
||
const from = bookmarks.findIndex((b) => b.url === fromUrl);
|
||
if (from < 0) return bookmarks;
|
||
const [moved] = bookmarks.splice(from, 1);
|
||
let to = bookmarks.findIndex((b) => b.url === targetUrl);
|
||
if (to < 0) { bookmarks.splice(from, 0, moved); return bookmarks; }
|
||
if (place === "after") to += 1;
|
||
bookmarks.splice(to, 0, moved);
|
||
saveBookmarks(); emitBookmarks();
|
||
return bookmarks;
|
||
});
|
||
ipcMain.handle("settings-get", () => settings);
|
||
ipcMain.handle("settings-set", (_e, key, val) => {
|
||
// The default engine must be one that is enabled and not frozen, whichever path sets it.
|
||
if (key === "searchEngine" && !enabledEnginesList().some((e) => e.id === val)) return settings;
|
||
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
|
||
if (key === "webrtcMode") applyWebRTCPolicy();
|
||
if (key === "dohMode" || key === "dohProvider" || key === "dohCustom") applyDoh();
|
||
if (key === "gpc") applyFingerprintAll();
|
||
if (key === "searchEngine") emitEngines(); // the toolbar button and the picker show the default
|
||
if (key === "theme") applyTheme();
|
||
if (key === "backgroundThrottle") applyThrottle();
|
||
if (key === "freezeBackgroundTabs") applyFreezeSetting();
|
||
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
|
||
"locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
|
||
// Language changes alter the Accept-Language the server sees on the NEXT
|
||
// request; an already-rendered page keeps the body it was first served.
|
||
// Reload the active tab so the user sees the switch take effect without a
|
||
// manual F5. (Only for the language keys; timezone / location are JS-side
|
||
// overrides and don't need the server round-trip.)
|
||
if (key === "languageMode" || key === "languageValue") {
|
||
const t = activeTab();
|
||
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
|
||
try { wc && wc.reload(); } catch {}
|
||
}
|
||
// Quick-links strip: re-layout + push the new state to the strip's view so
|
||
// show/hide and link edits take effect without a relaunch. If the active
|
||
// panel's link was removed, close the panel.
|
||
if (key === "quickLinksShow" || key === "quickLinks") {
|
||
if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) {
|
||
activeQuickLinkId = null;
|
||
}
|
||
layout();
|
||
emitQuickLinksState();
|
||
}
|
||
// Broadcast to every renderer that watches the live settings — the chrome
|
||
// (toolbar sizes, URL-bar language chip) and every open settings tab (so
|
||
// the General Website-language row and the Privacy Anti-fingerprinting
|
||
// Language row stay in sync with the chip and with each other).
|
||
broadcastSettings();
|
||
return settings;
|
||
});
|
||
// Quick-links IPC. getState: list + whichever one is currently open. open: a
|
||
// toggle — click to open the panel on that link, click the active one again
|
||
// to close it, click a different one to switch. The strip subscribes via
|
||
// onState so it can highlight the active icon.
|
||
function emitQuickLinksState() {
|
||
const payload = { links: settings.quickLinks || [], openId: activeQuickLinkId };
|
||
try { quicklinks?.webContents.send("quicklinks-state", payload); } catch {}
|
||
}
|
||
function openQuickPanel(id) {
|
||
const L = (settings.quickLinks || []).find((x) => x.id === id);
|
||
if (!quickPanel || !L || !L.url) return;
|
||
activeQuickLinkId = id;
|
||
try {
|
||
const cur = quickPanel.webContents.getURL();
|
||
// Only re-navigate when the panel isn't already showing this origin —
|
||
// avoids blowing away the user's state (open chat, scroll position)
|
||
// when they click the icon to reopen the panel they just closed.
|
||
const sameHost = (() => { try { return cur && new URL(cur).host === new URL(L.url).host; } catch { return false; } })();
|
||
if (!sameHost) quickPanel.webContents.loadURL(L.url);
|
||
} catch (e) { console.warn("[quicklinks] loadURL failed:", e?.message); }
|
||
layout();
|
||
emitQuickLinksState();
|
||
}
|
||
function closeQuickPanel() {
|
||
activeQuickLinkId = null;
|
||
layout();
|
||
emitQuickLinksState();
|
||
}
|
||
ipcMain.handle("quicklinks-get-state", () => ({ links: settings.quickLinks || [], openId: activeQuickLinkId }));
|
||
ipcMain.handle("quicklinks-open", (_e, id) => {
|
||
if (!id) return false;
|
||
if (id === activeQuickLinkId) { closeQuickPanel(); return true; }
|
||
openQuickPanel(String(id));
|
||
return true;
|
||
});
|
||
ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); return true; });
|
||
ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; });
|
||
function broadcastSettings() {
|
||
try { chrome?.webContents.send("settings-update", settings); } catch {}
|
||
for (const t of tabs) {
|
||
if (t.settings && t.view?.webContents) {
|
||
try { t.view.webContents.send("settings-update", settings); } catch {}
|
||
}
|
||
}
|
||
}
|
||
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.
|
||
// pubkey → name, from the browser's own warm index. Built once per index
|
||
// generation: this used to run a full buildIndex() (an electrum walk) per
|
||
// unknown sender, and anyone can send to a published np key — a stream of
|
||
// wraps from fresh keys was a stream of full chain walks.
|
||
let hermesNpMap = null, hermesNpGen = -1;
|
||
async function hermesReverseResolve(pkHex) {
|
||
if (!hermesState) return null;
|
||
try {
|
||
if (!sharedIndex) await ensureIndex();
|
||
if (!sharedIndex) return null;
|
||
if (hermesNpGen !== indexBuiltAt || !hermesNpMap) {
|
||
const H = await loadHermesLib();
|
||
const m = new Map();
|
||
for (const [name, entry] of sharedIndex) {
|
||
const raw = entry && entry.records && entry.records.np;
|
||
if (typeof raw !== "string" || !raw.trim()) continue;
|
||
try { const hex = H.parseNpRecord(raw); if (hex && !m.has(hex)) m.set(hex, name); } catch { /* malformed np — skip */ }
|
||
}
|
||
hermesNpMap = m; hermesNpGen = indexBuiltAt;
|
||
}
|
||
return hermesNpMap.get(pkHex) ?? null;
|
||
} catch { return null; } // chain unreachable — leave unresolved this round
|
||
}
|
||
|
||
// 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;
|
||
if (hermesState._seen) {
|
||
hermesState._seen.add(key);
|
||
// Bounded: drop the oldest half once it grows past the cap (a Set
|
||
// iterates in insertion order).
|
||
if (hermesState._seen.size > 4000) { let n = 2000; for (const k of hermesState._seen) { if (n-- <= 0) break; hermesState._seen.delete(k); } }
|
||
}
|
||
// Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget
|
||
// wouldn't work — we need the name in the record we push). Await here;
|
||
// the cache short-circuits after the first miss per pk.
|
||
const senderName = await hermesReverseResolve(opened.senderPkHex);
|
||
hermesRecord({
|
||
senderPkHex: opened.senderPkHex,
|
||
senderName,
|
||
text: opened.text,
|
||
createdAt: opened.createdAt,
|
||
});
|
||
} catch { /* unwrap failure = not for us, or malformed — drop silently */ }
|
||
});
|
||
ws.on("close", () => {
|
||
state.ready = false;
|
||
hermesEmitStatus();
|
||
// Attempt reconnect if we're still supposed to be running.
|
||
if (hermesState && hermesState.relays.get(url) === state) {
|
||
setTimeout(open, 3000);
|
||
}
|
||
});
|
||
ws.on("error", () => { /* close handler will retry */ });
|
||
};
|
||
open();
|
||
return state;
|
||
}
|
||
|
||
function hermesTeardown() {
|
||
if (!hermesState) return;
|
||
for (const state of hermesState.relays.values()) {
|
||
try { state.ws?.close(1000); } catch {}
|
||
}
|
||
hermesState = null;
|
||
}
|
||
|
||
ipcMain.handle("hermes-status", () => {
|
||
if (!hermesState) return hOk({ ready: false });
|
||
let connected = 0;
|
||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||
return hOk({
|
||
ready: true,
|
||
npub: hermesState.npub,
|
||
pkHex: hermesState.pkHex,
|
||
relaysConnected: connected,
|
||
relaysTotal: hermesState.relays.size,
|
||
});
|
||
});
|
||
|
||
// Init modes:
|
||
// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user)
|
||
// { useVault: true } — reuse the password vault's messenger root; no re-entry
|
||
// required. Available iff vault is unlocked AND was set
|
||
// up from a mnemonic (so messengerRoot was persisted).
|
||
ipcMain.handle("hermes-init", async (_e, opts = {}) => {
|
||
try {
|
||
hermesTeardown();
|
||
const H = await loadHermesLib();
|
||
let sk, pkHex, npub;
|
||
if (opts.useVault) {
|
||
if (!vaultState || !vaultState.messengerRoot) {
|
||
return hErr("password vault is locked or was set up without a mnemonic");
|
||
}
|
||
({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot));
|
||
} else {
|
||
const mnemonic = opts.mnemonic;
|
||
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
|
||
return hErr("enter a 12- or 24-word mnemonic");
|
||
}
|
||
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()));
|
||
}
|
||
hermesState = {
|
||
skHex: Buffer.from(sk).toString("hex"),
|
||
pkHex, npub,
|
||
inbox: [],
|
||
relays: new Map(),
|
||
_seen: new Set(),
|
||
};
|
||
for (const url of HERMES_DEFAULT_RELAYS) {
|
||
hermesState.relays.set(url, await hermesConnectRelay(url));
|
||
}
|
||
// Give sockets a beat to open before reporting connected count.
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
let connected = 0;
|
||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||
return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic",
|
||
relaysConnected: connected, relaysTotal: hermesState.relays.size });
|
||
} catch (e) { hermesTeardown(); return hErr(e); }
|
||
});
|
||
|
||
// Cheap query: "is the vault-bound sign-in path available right now?" Panel
|
||
// uses this to decide whether to show the 'Use password vault' button.
|
||
ipcMain.handle("hermes-can-use-vault", () => hOk({
|
||
available: !!(vaultState && vaultState.messengerRoot),
|
||
}));
|
||
|
||
ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); });
|
||
|
||
ipcMain.handle("hermes-inbox", () => {
|
||
if (!hermesState) return hErr("not initialised");
|
||
return hOk({ messages: hermesState.inbox.slice() });
|
||
});
|
||
|
||
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
|
||
// via the portable resolver (getResolver above), the `np` record parsed, then
|
||
// wrapped + published to each relay listed in `nr` (falling back to defaults).
|
||
ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => {
|
||
if (!hermesState) return hErr("not initialised");
|
||
if (!to || !text) return hErr("to and text required");
|
||
try {
|
||
const H = await loadHermesLib();
|
||
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
|
||
const trimmed = String(to).trim();
|
||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||
recipientPkHex = trimmed.toLowerCase();
|
||
} else if (trimmed.startsWith("npub1")) {
|
||
recipientPkHex = H.parseNpRecord(trimmed);
|
||
} else {
|
||
const R = await getResolver();
|
||
const name = R.normalizeName(trimmed);
|
||
const entry = await R.resolveName(name, { WebSocket });
|
||
if (!entry) return hErr(`no on-chain registration for ${name}`);
|
||
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
|
||
recipientPkHex = parsed.npPk;
|
||
relays = parsed.relays;
|
||
}
|
||
|
||
const sk = Buffer.from(hermesState.skHex, "hex");
|
||
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
|
||
|
||
const publishOne = (url) => new Promise((resolve) => {
|
||
const ws = new WebSocket(url);
|
||
let settled = false;
|
||
const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); };
|
||
const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000);
|
||
ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap])));
|
||
ws.on("message", (buf) => {
|
||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||
if (msg[0] === "OK" && msg[1] === wrap.id) {
|
||
clearTimeout(t);
|
||
done({ url, ok: !!msg[2], detail: msg[3] || "" });
|
||
}
|
||
});
|
||
ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message }));
|
||
});
|
||
|
||
const results = await Promise.all(relays.map(publishOne));
|
||
const accepted = results.filter((r) => r.ok).length;
|
||
return hOk({ recipientPkHex, accepted, total: results.length, results });
|
||
} catch (e) { return hErr(e); }
|
||
});
|
||
|
||
// ---- "Open link in new window" ----
|
||
// A standalone page window: same session (cookies, the bns:// protocol, the
|
||
// session-wide bcnr preload), Theseus's fingerprint + WebRTC policy, no
|
||
// toolbar. Loads BCNR-first like a tab does: a dotted host with a BCNR
|
||
// record goes over bns://, anything else over clearnet. Collision names
|
||
// follow the configured policy without the "Open with…" interstitial.
|
||
// Add-on page bridges (the wallet inject) are tab-scoped and don't run here.
|
||
const linkWindows = new Set();
|
||
async function targetUrlFor(input) {
|
||
let u;
|
||
try { u = new URL(String(input).includes("://") ? String(input) : "https://" + String(input)); } catch { return null; }
|
||
if (u.protocol !== "http:" && u.protocol !== "https:") return u.href;
|
||
const host = u.hostname.toLowerCase();
|
||
if (!isBnsHost(host)) return u.href;
|
||
let entry = null;
|
||
try { entry = await resolveHost(host); } catch {}
|
||
if (!entry) return u.href;
|
||
const policy = settings.collisionPolicy || "bcnr-first";
|
||
if (!isBcnrNativeTld(tldOf(host)) && policy === "icann-first") return u.href;
|
||
return `bns://${host}${u.pathname}${u.search}${u.hash}`;
|
||
}
|
||
async function openLinkWindow(input) {
|
||
const target = await targetUrlFor(input);
|
||
if (!target) return null;
|
||
const w = new BrowserWindow({
|
||
width: 1100, height: 760, title: "Theseus Navigator",
|
||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff",
|
||
icon: app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico"),
|
||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||
});
|
||
w.setMenuBarVisibility(false);
|
||
const wc = w.webContents;
|
||
styleScrollbars(wc);
|
||
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
|
||
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
|
||
applyFingerprint(wc);
|
||
wc.on("page-title-updated", (_e, title) => { try { w.setTitle(title ? `${title} — Theseus` : "Theseus Navigator"); } catch {} });
|
||
// Cross-host navigations inside the window stay BCNR-first too. Same-host
|
||
// ones go through Chromium untouched (form POSTs must survive).
|
||
wc.on("will-navigate", (e, u) => {
|
||
try {
|
||
const p = new URL(u);
|
||
if (p.protocol !== "http:" && p.protocol !== "https:") return;
|
||
if (!isBnsHost(p.hostname)) return;
|
||
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
|
||
if (cur === p.hostname) return;
|
||
e.preventDefault();
|
||
targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
|
||
} catch {}
|
||
});
|
||
// Popups from a page here go to the main window's tabs when it exists —
|
||
// one place for tabs — otherwise to another plain window.
|
||
wc.setWindowOpenHandler(({ url }) => {
|
||
if (url && /^(?:https?|bns):/i.test(url)) { // web schemes only — see the tab handler
|
||
if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} }
|
||
else openLinkWindow(url);
|
||
}
|
||
return { action: "deny" };
|
||
});
|
||
wc.on("context-menu", (_e, p) => {
|
||
const items = [];
|
||
const haveMain = !!win && !win.isDestroyed();
|
||
if (p.linkURL) {
|
||
items.push(
|
||
{ label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } },
|
||
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
|
||
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
|
||
{ type: "separator" },
|
||
);
|
||
}
|
||
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
|
||
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
|
||
// Add-on context-menu items (same shape as the main-window handler).
|
||
try {
|
||
const addonItems = addonHost ? addonHost.getContextMenuItems({
|
||
selectionText: p.selectionText, linkURL: p.linkURL,
|
||
mediaType: p.mediaType, srcURL: p.srcURL, isEditable: p.isEditable,
|
||
pageURL: p.pageURL,
|
||
}) : [];
|
||
if (addonItems.length) {
|
||
for (const it of addonItems) {
|
||
const label = (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id);
|
||
items.push({ label, click: () => {
|
||
const payload = {
|
||
itemId: it.id,
|
||
selectionText: p.selectionText || "",
|
||
linkURL: p.linkURL || "",
|
||
mediaType: p.mediaType || "",
|
||
srcURL: p.srcURL || "",
|
||
pageURL: p.pageURL || (wc && wc.getURL()) || "",
|
||
host: (() => { try { return new URL(p.pageURL || wc.getURL()).host; } catch { return ""; } })(),
|
||
};
|
||
addonHost.dispatch(it.addonId, "context-menu", payload, { from: "context-menu" })
|
||
.catch((err) => console.warn(`[addons] context-menu ${it.addonId}.${it.id} failed:`, err?.message || err));
|
||
}});
|
||
}
|
||
items.push({ type: "separator" });
|
||
}
|
||
} catch (err) { console.warn("context-menu addon merge failed:", err?.message || err); }
|
||
items.push(
|
||
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
||
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
|
||
{ label: "Reload", click: () => wc.reload() },
|
||
);
|
||
Menu.buildFromTemplate(items).popup({ window: w });
|
||
});
|
||
linkWindows.add(w);
|
||
w.on("closed", () => linkWindows.delete(w));
|
||
wc.loadURL(target).catch(() => {});
|
||
return w;
|
||
}
|
||
|
||
function openHermesWindow() {
|
||
if (hermesWin && !hermesWin.isDestroyed()) {
|
||
hermesWin.focus(); return;
|
||
}
|
||
hermesWin = new BrowserWindow({
|
||
width: 720, height: 620, title: "Messages — Theseus",
|
||
backgroundColor: "#0b0e14",
|
||
webPreferences: {
|
||
preload: path.join(__dirname, "messages-preload.js"),
|
||
contextIsolation: true, nodeIntegration: false,
|
||
},
|
||
});
|
||
hermesWin.setMenuBarVisibility(false);
|
||
hermesWin.loadFile("messages.html");
|
||
hermesWin.on("closed", () => { hermesWin = null; });
|
||
}
|
||
|
||
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
|
||
|
||
// --- BCNR provider (window.bcnr) — read-only surface. See
|
||
// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus
|
||
// already runs; nothing about the user leaks, so no origin/permission gate.
|
||
// A malicious page can call these; the worst it learns is what the chain
|
||
// says publicly, which it could equally get through Argus. The preload that
|
||
// exposes these lives at bcnr-preload.js and is installed session-wide
|
||
// inside whenReady below.
|
||
//
|
||
// B.2b: eTLD+1 origin binding. The read methods below don't need the origin
|
||
// — chain data is public — so they don't compute it. Instead pages that
|
||
// want to see how Theseus will bucket their permissions can call
|
||
// `window.bcnr.getOrigin()` (handler further down). B.3's write methods
|
||
// (signMessage/sendPayment/registerName) will call `callerOrigin(event)` at
|
||
// entry and check the result against wallet-permissions.json.
|
||
const { originOf } = require("./bcnr-origin.js");
|
||
function callerOrigin(event) {
|
||
try { return originOf(event.sender.getURL(), { bcnrTlds }); }
|
||
catch { return null; }
|
||
}
|
||
// wallet-permissions.json — per-origin (eTLD+1) grants for B.3's write
|
||
// methods. Scaffolded in B.2b so B.3 doesn't have to touch main.js's
|
||
// on-disk conventions. Shape is intentionally open — B.3 will define the
|
||
// concrete decision values ("always" | "once" | "never", amount caps,
|
||
// expiries) as each write method lands.
|
||
let walletPermissions = {};
|
||
const walletPermissionsFile = () => path.join(app.getPath("userData"), "wallet-permissions.json");
|
||
function loadWalletPermissions() {
|
||
try {
|
||
if (fs.existsSync(walletPermissionsFile())) {
|
||
const raw = JSON.parse(fs.readFileSync(walletPermissionsFile(), "utf8"));
|
||
if (raw && typeof raw === "object") walletPermissions = raw;
|
||
}
|
||
} catch (e) { console.error("wallet-permissions load failed:", e.message); }
|
||
}
|
||
function saveWalletPermissions() {
|
||
try { fs.writeFileSync(walletPermissionsFile(), JSON.stringify(walletPermissions, null, 2)); }
|
||
catch (e) { console.error("wallet-permissions save failed:", e.message); }
|
||
}
|
||
// B.3 will use these. Kept here so the storage owner is one place.
|
||
function getWalletPermission(origin, method) {
|
||
if (!origin || !method) return null;
|
||
return walletPermissions[origin]?.[method] ?? null;
|
||
}
|
||
function setWalletPermission(origin, method, value) {
|
||
if (!origin || !method) return;
|
||
if (!walletPermissions[origin]) walletPermissions[origin] = {};
|
||
walletPermissions[origin][method] = value;
|
||
saveWalletPermissions();
|
||
}
|
||
void getWalletPermission; void setWalletPermission; // silence unused-in-B.2b
|
||
function serializeEntry(entry) {
|
||
if (!entry) return null;
|
||
// Explicit whitelist — records/category/txid/height are the on-chain facts
|
||
// the design's `resolveName` promises. `updatedTxid` is included because it
|
||
// shifts every UPD and lets `getRecordVersion` distinguish a REG-only entry
|
||
// from one that has been updated in place.
|
||
return {
|
||
name: entry.name,
|
||
category: entry.category,
|
||
records: entry.records ?? {},
|
||
txid: entry.txid,
|
||
height: entry.height,
|
||
updatedTxid: entry.updatedTxid ?? null,
|
||
// Signed DNS records as last fetched: undefined → not known yet (call
|
||
// bcnr:dnsRecords to wait for them), null → none published.
|
||
dns: entry.dns === undefined ? undefined : entry.dns,
|
||
};
|
||
}
|
||
ipcMain.handle("bcnr:resolveName", async (_e, name) => {
|
||
if (typeof name !== "string" || !name) return null;
|
||
try { return serializeEntry(await resolveHost(name)); }
|
||
catch { return null; }
|
||
});
|
||
// Signed DNS records for a registered name, waiting (≤ 3 s) for the fetch.
|
||
// For add-ons that need TXT (verification, cert pins), MX (mail bridges) or
|
||
// A/AAAA. Null when the name is unregistered or has no manifest.
|
||
ipcMain.handle("bcnr:dnsRecords", async (_e, name) => {
|
||
if (typeof name !== "string" || !name) return null;
|
||
try {
|
||
const entry = await resolveHost(name);
|
||
if (!entry) return null;
|
||
const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name);
|
||
return v ? { name: entry.name, ...v } : null;
|
||
} 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.
|
||
// Is this webContents part of the browser window (toolbar, a tab, a panel or
|
||
// overlay)? Views may report no owner; another window reports itself.
|
||
function inMainWindow(wc) {
|
||
if (!win || win.isDestroyed()) return false;
|
||
if (chrome && wc === chrome.webContents) return true;
|
||
if (tabs.some((t) => t.view?.webContents === wc)) return true;
|
||
let owner = null; try { owner = BrowserWindow.fromWebContents(wc); } catch {}
|
||
return !owner || owner === win;
|
||
}
|
||
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();
|
||
}
|
||
// App windows, link windows, Messages: the keys act on the page that got
|
||
// them. The browser-window shortcuts below used to reach the main
|
||
// window's active tab from here (F5 reloaded a tab the user wasn't
|
||
// looking at, F12 inspected it, Ctrl+F opened a hidden find bar).
|
||
if (!inMainWindow(wc)) {
|
||
const k = input.key, code = input.code;
|
||
const isR = k === "R" || k === "r", isI = k === "I" || k === "i";
|
||
try {
|
||
if (k === "F5" || (input.control && isR)) {
|
||
if ((input.control && input.shift && isR) || (input.control && k === "F5")) wc.reloadIgnoringCache(); else wc.reload();
|
||
return e.preventDefault();
|
||
}
|
||
if (k === "F12" || (input.control && input.shift && isI)) { wc.toggleDevTools(); return e.preventDefault(); }
|
||
if (input.control && !input.alt) {
|
||
if (k === "+" || k === "=" || code === "NumpadAdd") { wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9)); return e.preventDefault(); }
|
||
if (k === "-" || k === "_" || code === "NumpadSubtract") { wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8)); return e.preventDefault(); }
|
||
if ((k === "0" || code === "Numpad0") && !input.shift) { wc.setZoomLevel(0); return e.preventDefault(); }
|
||
}
|
||
} catch {}
|
||
return;
|
||
}
|
||
// Ctrl+B -> toggle add-on sidebar (matches the VS Code convention).
|
||
// Silently no-ops if no add-on has registered a sidebar panel yet. Not
|
||
// taken from a web page in a tab: there it is "bold" in every editor
|
||
// (Docs, Notion, webmail), and the toolbar button still toggles the panel.
|
||
const inWebTab = tabs.some((t) => t.view?.webContents === wc && !t.settings && !t.addonId);
|
||
if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b") && !inWebTab) {
|
||
toggleSidebar();
|
||
return e.preventDefault();
|
||
}
|
||
// Zoom: Ctrl + / Ctrl = / numpad + zoom in, Ctrl - / numpad - zoom out,
|
||
// Ctrl 0 reset. Always targets the active tab, whichever view has focus.
|
||
if (input.control && !input.alt) {
|
||
const k = input.key, code = input.code;
|
||
if (k === "+" || k === "=" || code === "NumpadAdd") { zoomStep(activeTab(), 1); return e.preventDefault(); }
|
||
if (k === "-" || k === "_" || code === "NumpadSubtract") { zoomStep(activeTab(), -1); return e.preventDefault(); }
|
||
if ((k === "0" || code === "Numpad0") && !input.shift) { zoomSet(activeTab(), 100); return e.preventDefault(); }
|
||
}
|
||
// Find-in-page: Ctrl+F opens the find bar in chrome. Chrome renderer
|
||
// owns the UI (input, next/prev, count, close); it drives the active
|
||
// tab's webContents.findInPage via IPC (find-in-page / find-stop).
|
||
const isF = input.key === "F" || input.key === "f";
|
||
if (input.control && !input.shift && !input.alt && isF) {
|
||
try { chrome?.webContents.send("find-open"); } catch {}
|
||
return e.preventDefault();
|
||
}
|
||
// DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active
|
||
// TAB. Position follows the devToolsDock setting: "bottom" docks under
|
||
// the tab (Chrome's own default, matches most web-dev muscle memory);
|
||
// "sidebar" docks on the right; "two-sidebars" also docks on the right
|
||
// but leaves the add-on sidebar in place — Electron's mode:right shares
|
||
// the right edge with whatever we've already positioned there. Users
|
||
// who prefer a detached window can still drag out from inside the
|
||
// DevTools frontend itself.
|
||
// F11 toggles window fullscreen, the toolbar kept (Chrome hides it too,
|
||
// Theseus keeps it). Also the way out of a fullscreen the user did not
|
||
// ask for.
|
||
if (input.key === "F11" && !input.control && !input.alt && !input.shift) {
|
||
toggleUserFullscreen();
|
||
return e.preventDefault();
|
||
}
|
||
const isI = input.key === "I" || input.key === "i";
|
||
if (input.key === "F12" || (input.control && input.shift && isI)) {
|
||
const t = activeTab();
|
||
if (t) {
|
||
try {
|
||
const twc = t.view.webContents;
|
||
if (twc.isDevToolsOpened()) twc.closeDevTools();
|
||
else {
|
||
const dock = settings.devToolsDock === "sidebar" ? "right"
|
||
: settings.devToolsDock === "two-sidebars" ? "right"
|
||
: "bottom";
|
||
twc.openDevTools({ mode: dock });
|
||
}
|
||
} catch {}
|
||
}
|
||
return e.preventDefault();
|
||
}
|
||
// Reload: F5 or Ctrl+R soft; Ctrl+F5 or Ctrl+Shift+R hard (bypass cache).
|
||
// Chromium's built-in accelerators are unreliable once the app menu is
|
||
// null (Menu.setApplicationMenu(null) above), and none of them cover
|
||
// the hard variants anyway — so we wire all four explicitly.
|
||
const isR = input.key === "R" || input.key === "r";
|
||
const isF5 = input.key === "F5";
|
||
if (isF5 || (input.control && isR)) {
|
||
const t = activeTab();
|
||
if (t && !t.settings) {
|
||
const hard = (input.control && input.shift && isR) || (input.control && isF5);
|
||
try {
|
||
if (hard) t.view.webContents.reloadIgnoringCache();
|
||
else t.view.webContents.reload();
|
||
} catch {}
|
||
}
|
||
return e.preventDefault();
|
||
}
|
||
});
|
||
});
|
||
|
||
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
|
||
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
|
||
// Opening a page in a normal tab from an app window: the main window may be
|
||
// gone (closed while app windows stayed up) — bring it back first and let
|
||
// the session restore run before the requested page joins the strip.
|
||
const pendingTabUrls = [];
|
||
function openInMainTab(url) {
|
||
if (win && !win.isDestroyed()) { createTab(url); try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} return; }
|
||
pendingTabUrls.push(url);
|
||
createWindow();
|
||
}
|
||
// A second launch: `Theseus.exe --app=<url>` (an installed app's shortcut)
|
||
// opens that app's window here; a plain launch fronts the browser window,
|
||
// recreating it when only app windows are left.
|
||
function handleLaunch(argv) {
|
||
const appUrl = webapps.appUrlFromArgv(argv);
|
||
if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; }
|
||
if (win && !win.isDestroyed()) { try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} }
|
||
else createWindow();
|
||
}
|
||
if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
||
// Another Theseus owns this profile; it got our argv via second-instance.
|
||
app.quit();
|
||
} else if (!process.env.THESEUS_NO_AUTOSTART) {
|
||
app.on("second-instance", (_e, argv) => { try { handleLaunch(argv); } catch (err) { console.warn("second-instance failed:", err?.message); } });
|
||
app.whenReady().then(() => {
|
||
Menu.setApplicationMenu(null); // drop the native File/Edit/View/Help menu bar
|
||
loadSettings();
|
||
applyTheme();
|
||
loadBookmarks();
|
||
loadHistory();
|
||
loadCollisions();
|
||
loadWalletPermissions();
|
||
applyPermissions();
|
||
applyEmbedCookieShim();
|
||
applyAcceptLanguage();
|
||
applyClientHintsSpoof();
|
||
applyDoh();
|
||
// 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 ses = session.defaultSession;
|
||
if (typeof ses.registerPreloadScript === "function") {
|
||
// Electron ≥ 35: setPreloads is deprecated in favour of per-script registration.
|
||
for (const filePath of [bcnrPreload, injectPreload]) ses.registerPreloadScript({ type: "frame", filePath });
|
||
} else {
|
||
const existing = ses.getPreloads();
|
||
const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p));
|
||
if (wanted.length) ses.setPreloads([...existing, ...wanted]);
|
||
}
|
||
} catch (err) { console.warn("[bcnr] preload registration failed:", err?.message ?? err); }
|
||
protocol.handle("bns", serveBns);
|
||
installDownloadTracker();
|
||
installRequestFilter();
|
||
migrateExtensionDirs();
|
||
initAddons();
|
||
// Custom engines saved before icons were cached (or whose fetch never
|
||
// landed) get theirs once the startup burst is over.
|
||
setTimeout(() => { for (const c of settings.customEngines || []) customFavicon(c.url); }, 15000);
|
||
webapps.init({
|
||
parentWindow: () => (win && !win.isDestroyed() ? win : undefined),
|
||
ask: askSheet,
|
||
openInTab: openInMainTab,
|
||
targetUrlFor, isBnsHost,
|
||
prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); },
|
||
changed: () => emitTabs(),
|
||
});
|
||
// Kick off signed add-on update polling 30 s after boot so it never
|
||
// slows launch. Any staged update lands in <userData>/addons-updates-
|
||
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
|
||
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
|
||
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
|
||
// A single boot check missed everything published after launch (2026-09-22:
|
||
// Aegis 0.8.3 and VPN 0.1.3 landed on the channel minutes after the app's
|
||
// check and never showed up), so re-check every 4 h while running, and
|
||
// tell the chrome whenever something is staged so the user sees a
|
||
// "restart to apply" chip instead of finding out in Settings.
|
||
setTimeout(() => pollAddonUpdates("boot"), 30_000);
|
||
setInterval(() => pollAddonUpdates("interval"), 4 * 60 * 60 * 1000);
|
||
// Launched from an installed app's shortcut: just that app's window; the
|
||
// browser window comes up on the next plain launch (second-instance).
|
||
const appUrl = webapps.appUrlFromArgv(process.argv);
|
||
if (appUrl && webapps.launch(appUrl)) { /* app window only */ }
|
||
else createWindow();
|
||
// BNS index: a separate process (bns-indexer.js). Its first phase — the
|
||
// local snapshot, no network — starts now, so the index is warm by the
|
||
// time the toolbar's bns:// bookmark favicons or the first tab ask for
|
||
// it. Its network phase (electrum sync every 30 s, Sia snapshot refresh,
|
||
// server discovery) is released from onChromeReady() after the first
|
||
// page has loaded.
|
||
startIndexer();
|
||
// Cheap update check: fetch the releases manifest and, if a newer
|
||
// version is out, surface a chip in the toolbar. No auto-install —
|
||
// clicking the chip opens the download URL. First check runs from
|
||
// onChromeReady(); recheck every 6h so a browser left running for days
|
||
// catches updates without a relaunch.
|
||
setInterval(() => checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000);
|
||
// Home cards are also polled from a remote URL — brand copy updates then
|
||
// reach every install without a browser release. User's local edits stay
|
||
// authoritative (loadHomeCards checks them first).
|
||
setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS);
|
||
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||
});
|
||
// Electron doesn't wait for an async before-quit listener, so the quit is
|
||
// held until the clear finishes (bounded) and then re-issued.
|
||
let quitCleared = false, quitClearing = false;
|
||
app.on("before-quit", (e) => {
|
||
if (quitCleared) return;
|
||
e.preventDefault();
|
||
if (quitClearing) return;
|
||
quitClearing = true;
|
||
flushAddonStores(); // add-on stores write coalesced; land them now
|
||
// 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
|
||
const clear = (async () => {
|
||
await clearBrowsingData({
|
||
cookies: settings.clearCookiesOnQuit,
|
||
cache: settings.clearCacheOnQuit,
|
||
storage: settings.clearStorageOnQuit,
|
||
});
|
||
// Session file AND the address-bar history (history.json).
|
||
if (settings.clearHistoryOnQuit) { await clearHistoryNow(); sessionDroppedForQuit = true; }
|
||
})().catch((err) => console.error("before-quit clear failed:", err?.message));
|
||
Promise.race([clear, new Promise((r) => setTimeout(r, 5000))])
|
||
.finally(() => { quitCleared = true; app.quit(); });
|
||
});
|
||
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
|
||
}
|
||
|
||
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf, openLinkWindow };
|