The wiz:// page scan ran in the page's own world, where the page can replace RegExp, querySelectorAll or Set and shape what the wallet is handed; it now runs in an isolated world of its own and the results are checked again in main. The extension install sheet now says what a community extension can reach while the vault is unlocked (passwords and the wallet), since extensions still run in the main process.
8473 lines
460 KiB
JavaScript
8473 lines
460 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, safeStorage } = 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"
|
||
// Startup (Settings › Performance). Off = the old behaviour: every enabled
|
||
// extension's activate() runs at launch, before the window exists.
|
||
extensionsOnDemand: true, // extensions whose manifest allows it start on first use
|
||
walletAtLaunch: false, // start Aegis at launch even once it allows on-demand (no effect while it is startup-only)
|
||
preloadMenus: true, // load address suggestions / link pill / site info after the first page (prewarmOverlays)
|
||
addonsStartAtLaunch: [], // on-demand add-ons that asked (api.startAtLaunch) to start at launch anyway
|
||
// 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 unless restoreSession)
|
||
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",
|
||
"vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
|
||
// Raw seeds and WIFs. No page uses these (add-ons go through the
|
||
// vaultImports shim in main), but an unguarded handler is reachable by any
|
||
// renderer that gets code execution.
|
||
"wallet-imports-add", "wallet-imports-list", "wallet-imports-remove", "wallet-imports-signer",
|
||
]);
|
||
// Hermes (Nostr messaging) speaks as the user and reads their messages:
|
||
// only its own window may drive it.
|
||
const HERMES_ONLY = new Set(["hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", "hermes-inbox", "hermes-send"]);
|
||
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,
|
||
HERMES_ONLY.has(channel)
|
||
? (e, ...args) => {
|
||
if (!hermesWin || hermesWin.isDestroyed() || e.sender !== hermesWin.webContents) throw new Error(`${channel}: messages window only`);
|
||
return fn(e, ...args);
|
||
}
|
||
: 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)
|
||
}
|
||
// keepSession: leave session.json (the open tabs) in place. The quit clear
|
||
// passes it while "Open previous windows and tabs" is on — both default to on,
|
||
// and deleting the tab list made the restore setting a no-op for everyone.
|
||
async function clearHistoryNow({ keepSession = false } = {}) {
|
||
for (const t of tabs) {
|
||
try { t.view.webContents.navigationHistory.clear(); } catch {}
|
||
}
|
||
if (!keepSession) { 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, lazy except for the page on screen: only the tab that was
|
||
// active at close loads at launch. Every other restored tab comes up as a
|
||
// dormant WebContentsView in the strip, painted from the saved title +
|
||
// favicon, and loads only when the user activates it (clicks the chip, hits
|
||
// reload, types in the URL bar). Launch therefore costs one page renderer
|
||
// whether the session has 2 or 50 tabs, and a saved heavy page in the
|
||
// background doesn't come back as a 500 MB renderer nobody asked to see.
|
||
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); }
|
||
}
|
||
// The tab that was active at close loads now — it is the page the user
|
||
// sees, and leaving it blank until clicked read as a broken restore. Every
|
||
// other tab stays dormant until it is activated. activeId + visibility are
|
||
// applied by hand rather than through setActive(), which would also run
|
||
// the switch-time side effects (sidebar restore, nav push) for a tab the
|
||
// user did not switch to.
|
||
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 {} }
|
||
if (target.pending) materializePending(target);
|
||
}
|
||
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 || settings.preloadMenus === false) 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 runs in bns-indexer.js, a separate process (utilityProcess),
|
||
// so none of it competes with the browser's main thread. Ariadne's Thread owns
|
||
// BNS indexing on the machine: that process reads Ariadne's indexer over its
|
||
// server-checked pipe, or Ariadne's local copies, and starts Theseus's own
|
||
// electrum indexer only while Ariadne is unhealthy or absent
|
||
// (DESIGN-bns-indexer-service.md). It boots in two phases: the local copies
|
||
// 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");
|
||
// The shared index modules (also Ariadne's): .mjs in the packaged resources,
|
||
// like resolver-web; the engine copies in dev.
|
||
const bnsSharedLib = (name) => (app.isPackaged ? path.join(RES_DIR, `${name}.mjs`) : path.join(__dirname, "..", "Argus", "src", "lib", `${name}.js`));
|
||
// Where the names come from right now (Ariadne's pipe / its files / Theseus's
|
||
// own indexer), as the index process last reported it. Shown in Settings.
|
||
let bnsIndexStatus = null;
|
||
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,
|
||
coreLib: bnsSharedLib("bns-index-core"), chainLib: bnsSharedLib("bns-source-chain"), pipeLib: bnsSharedLib("bns-pipe"),
|
||
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 === "status") {
|
||
bnsIndexStatus = m;
|
||
} 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; }
|
||
|
||
// A built-in extension may answer paths under a name it ships with (Pithos
|
||
// serves its app at pithos.sia/<user>/<drive>/…). The root page stays the
|
||
// name's own site, and whatever the extension does not claim falls through
|
||
// to the normal lookup below.
|
||
if (rawPath !== "/" && addonHost?.siteRouteFor) {
|
||
try {
|
||
const route = await addonHost.siteRouteFor(host);
|
||
if (route) {
|
||
const out = await route.handle(request);
|
||
if (out) return out;
|
||
}
|
||
} catch (e) { console.warn(`[site-route] ${host}${rawPath}: ${e?.message || e}`); }
|
||
}
|
||
|
||
// (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;
|
||
// Language picker: a floating overlay VIEW used instead of a native menu
|
||
// so the dropdown matches the rest of the UI (dark surface, acid accents,
|
||
// rounded corners, a collapsible "more languages" group). Anchored under
|
||
// the globe chip in the toolbar.
|
||
let langPicker, lpVisible = false, lpPos = { x: 8, y: 90 };
|
||
let LP_W = 300; let lpH = 360;
|
||
// 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
|
||
// Add-on panels registered with side: "left" open from the same strip, in
|
||
// their own view (sidebar-preload, so the add-on bridge works) in the slot
|
||
// the quick-link panel uses; the two are never open together.
|
||
let leftPanel;
|
||
let leftPanelId = null; // panelId shown, or null when closed
|
||
let leftPanelLoadedId = null; // what leftPanel has loaded (kept while closed, so reopening keeps state)
|
||
let leftPanelMax = false; // widened by the panel (silentmode.sidebar.toggleMax)
|
||
const LEFT_PANEL_W = 400;
|
||
// 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); }
|
||
}
|
||
}
|
||
// Copies of add-on stores that nothing reads any more still hold whatever
|
||
// was in them when they were copied — for Aegis builds before 0.31 that
|
||
// included the master password behind only a 6-digit PIN (aegis/pin/v1,
|
||
// unsealed) and the stay-unlocked blob. The stale pre-rename addons-data/
|
||
// folder (kept when extensions-data/ already existed), the bchwallet.json
|
||
// left behind by the Aegis absorb, and parse-failure copies are scrubbed of
|
||
// those keys; everything else in them is left as it was.
|
||
const SECRET_STORE_KEYS = ["aegis/pin/v1", "aegis/session/enc"];
|
||
function scrubStaleStoreCopies() {
|
||
const ud = app.getPath("userData");
|
||
const files = [];
|
||
const listJson = (d, test) => { try { for (const n of fs.readdirSync(d)) if (test(n)) files.push(path.join(d, n)); } catch {} };
|
||
listJson(path.join(ud, "addons-data"), (n) => /\.json(\.tmp)?$/i.test(n));
|
||
listJson(path.join(ud, "extensions-data"), (n) => /^bchwallet\.json$/i.test(n) || /\.json\.(corrupt-\d+|tmp)$/i.test(n));
|
||
for (const f of files) {
|
||
try {
|
||
const raw = fs.readFileSync(f, "utf8");
|
||
if (!SECRET_STORE_KEYS.some((k) => raw.includes(JSON.stringify(k)))) continue;
|
||
let data;
|
||
try { data = JSON.parse(raw); } catch { continue; }
|
||
if (!data || typeof data !== "object") continue;
|
||
let n = 0;
|
||
for (const k of SECRET_STORE_KEYS) if (k in data) { delete data[k]; n++; }
|
||
if (!n) continue;
|
||
fs.writeFileSync(f, JSON.stringify(data));
|
||
console.log(`[addons] removed ${n} stale secret key(s) from ${path.relative(ud, f)}`);
|
||
} catch (e) { console.warn("[addons] scrub failed for", f, e?.message); }
|
||
}
|
||
}
|
||
// extensions-backups/ gets a copy of an add-on's folder on every reseed,
|
||
// promote and community update and was never pruned (118 copies, 93 MB on
|
||
// one profile). Keep the newest three per add-on.
|
||
function pruneAddonBackups(keep = 3) {
|
||
const d = addonsBackupDir();
|
||
let names = [];
|
||
try { names = fs.readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name); } catch { return; }
|
||
const byId = new Map();
|
||
for (const name of names) {
|
||
const m = /^(.+?)-(?:\d[\w.]*|unknown|migrated)-/.exec(name);
|
||
const id = m ? m[1] : name;
|
||
let mtime = 0; try { mtime = fs.statSync(path.join(d, name)).mtimeMs; } catch {}
|
||
if (!byId.has(id)) byId.set(id, []);
|
||
byId.get(id).push({ name, mtime });
|
||
}
|
||
for (const [, list] of byId) {
|
||
list.sort((a, b) => b.mtime - a.mtime);
|
||
for (const { name } of list.slice(keep)) {
|
||
try { fs.rmSync(path.join(d, name), { recursive: true, force: true }); } catch {}
|
||
}
|
||
}
|
||
}
|
||
function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); }
|
||
// Ids that ship inside Theseus, and the legacy ids those add-ons absorb
|
||
// (Aegis absorbs "bchwallet" and "siawallet": the wallet's key namespace).
|
||
// Read once from the bundled manifests — the bundle cannot change while the
|
||
// app runs. Used to keep `absorbs` first-party only and to keep community
|
||
// extensions off both sets of ids.
|
||
let firstPartyIdsCache = null;
|
||
function firstPartyAddonIds() {
|
||
if (firstPartyIdsCache) return firstPartyIdsCache;
|
||
const bundled = new Set(), absorbed = new Set();
|
||
try {
|
||
for (const de of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) {
|
||
if (!de.isDirectory()) continue;
|
||
try {
|
||
const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), de.name, "addon.json"), "utf8"));
|
||
if (m && m.id) bundled.add(String(m.id));
|
||
if (m && Array.isArray(m.absorbs)) for (const a of m.absorbs) absorbed.add(String(a));
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
// Ids that have ever shipped inside Theseus stay reserved even after they
|
||
// leave the bundle: their extensions-data/<id>.json and vault.derive
|
||
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
|
||
// add-on installed under a dropped id would inherit both.
|
||
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "screenshot", "translate", "vpn"]) {
|
||
if (!bundled.has(id)) absorbed.add(id);
|
||
}
|
||
firstPartyIdsCache = { bundled, absorbed };
|
||
return firstPartyIdsCache;
|
||
}
|
||
// 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),
|
||
// Settings › Performance › Startup can only move an add-on towards
|
||
// startup, never make a startup-only manifest on-demand.
|
||
activationFor: (m) => {
|
||
if (m.activation !== "on-demand") return "startup";
|
||
if (settings.extensionsOnDemand === false) return "startup";
|
||
if (m.id === "aegis" && settings.walletAtLaunch) return "startup";
|
||
if (Array.isArray(settings.addonsStartAtLaunch) && settings.addonsStartAtLaunch.includes(m.id)) return "startup";
|
||
return "on-demand";
|
||
},
|
||
setStartAtLaunch: (id, on) => {
|
||
const ids = new Set(Array.isArray(settings.addonsStartAtLaunch) ? settings.addonsStartAtLaunch : []);
|
||
if (ids.has(id) === on) return;
|
||
if (on) ids.add(id); else ids.delete(id);
|
||
settings.addonsStartAtLaunch = [...ids];
|
||
saveSettings();
|
||
console.log(`[addons] ${id} ${on ? "asked to start at launch" : "no longer needs to start at launch"}`);
|
||
},
|
||
// A started add-on may register panels beyond the ones it declared, and
|
||
// one that failed to start drops out of the dock.
|
||
onActivated: () => emitSidebarState(),
|
||
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;
|
||
try { vaultPin().resetFails(); } catch {}
|
||
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, tabId) => showApprovalModal(opts, addonId, tabId),
|
||
vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }),
|
||
// The one PIN, for built-in add-ons that draw their own PIN pad (Aegis).
|
||
// unlock() opens the vault here and answers only { ok } or why not — the
|
||
// master password the PIN wraps never leaves main.
|
||
vaultPin: {
|
||
status: () => ({ ...vaultPin().status(), maxFails: vaultPin().MAX_FAILS }),
|
||
unlock: async (pin, addonId) => {
|
||
let pw;
|
||
try { pw = await vaultPin().open(String(pin || "")); }
|
||
catch (err) {
|
||
return { ok: false, code: err.code || "error", remaining: err.remaining ?? 0, lockedMs: err.lockedMs ?? 0,
|
||
error: err.code === "tpm-locked" || err.code === "pin-gone" ? err.message : undefined };
|
||
}
|
||
try { await unlockVaultWithMaster(pw); }
|
||
catch {
|
||
// The PIN opened, but its password no longer opens the vault.
|
||
vaultPin().clear();
|
||
return { ok: false, code: "stale", remaining: 0, lockedMs: 0, error: "Your PIN is out of date. Enter the master password, then set a new PIN." };
|
||
}
|
||
console.log(`[addons] [${addonId}] vault PIN accepted`);
|
||
return { ok: true };
|
||
},
|
||
set: async (pin, masterPassword, addonId) => {
|
||
try { await unlockVaultWithMaster(String(masterPassword || "")); }
|
||
catch { await new Promise((r) => setTimeout(r, 600)); throw new Error("wrong master password"); }
|
||
const r = await vaultPin().set(String(pin || ""), String(masterPassword));
|
||
console.log(`[addons] [${addonId}] vault PIN set`);
|
||
return { ok: true, ...r };
|
||
},
|
||
clear: (addonId) => { vaultPin().clear(); console.log(`[addons] [${addonId}] vault PIN cleared`); return true; },
|
||
},
|
||
isFirstPartyId: (id) => firstPartyAddonIds().bundled.has(String(id)),
|
||
isReservedId: (id) => firstPartyAddonIds().absorbed.has(String(id)),
|
||
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 {}
|
||
}
|
||
// Delivered by what the view has actually loaded, not only by which
|
||
// panel was last selected — an add-on's state (addresses, balances)
|
||
// must never reach a document that is not that add-on's.
|
||
if (leftPanel && leftPanelLoadedId && leftPanelLoadedId.startsWith(addonId + ":") && addonIdForSender(leftPanel.webContents) === addonId) {
|
||
try { leftPanel.webContents.send("addon-event", msg, payload); } catch {}
|
||
}
|
||
if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return;
|
||
if (addonIdForSender(sidebar.webContents) !== 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 });
|
||
// Run in an isolated world of our own (the inject preload uses 999):
|
||
// the page shares the DOM but not its globals, so it cannot replace
|
||
// RegExp, querySelectorAll or Set to shape what the scan returns.
|
||
const found = await wc.executeJavaScriptInIsolatedWorld(1001, [{ code: `(() => {
|
||
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);
|
||
// And checked again here, whatever came back.
|
||
const want = scheme.toLowerCase() + "://";
|
||
const uris = (Array.isArray(found) ? found : [])
|
||
.filter((s) => typeof s === "string" && s.length <= 2048 && s.toLowerCase().startsWith(want))
|
||
.slice(0, Math.max(0, Number(limit) || 0));
|
||
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.installed.filter((x) => x.running).length} started at launch), ${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.
|
||
// Add-on panels carry sidebar-preload. A link in a panel used to navigate
|
||
// the panel itself to a remote page (keeping that preload), and window.open
|
||
// made a bare BrowserWindow with it. Web links open as tabs instead, and a
|
||
// panel never leaves file://.
|
||
function lockPanelView(view) {
|
||
view.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (url && /^(?:https?|bns):/i.test(url)) createTab(url);
|
||
return { action: "deny" };
|
||
});
|
||
view.webContents.on("will-navigate", (e) => {
|
||
const url = String(e.url || "");
|
||
if (/^file:/i.test(url)) return;
|
||
e.preventDefault();
|
||
if (/^(?:https?|bns):/i.test(url)) createTab(url);
|
||
});
|
||
}
|
||
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 quickW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0;
|
||
if (quickPanel) {
|
||
quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH });
|
||
try { quickPanel.setVisible(quickW > 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;
|
||
// An add-on's left panel. Widened, it covers the tab area like a page of
|
||
// its own, up to the right sidebar, which keeps its width: the tabs keep
|
||
// their narrow-panel bounds underneath instead of being squeezed aside.
|
||
const addonLeftW = (leftPanel && leftPanelId && !fsTab) ? LEFT_PANEL_W : 0;
|
||
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(LEFT_PANEL_W, width - leftW - sideW) : addonLeftW;
|
||
if (leftPanel) {
|
||
leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftShownW, height: bodyH });
|
||
try { leftPanel.setVisible(addonLeftShownW > 0); } catch {}
|
||
}
|
||
const panelW = quickW || addonLeftW;
|
||
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 });
|
||
// The vault unlock prompt covers the whole body, sidebar included: the
|
||
// add-on asking for it often lives in the sidebar, which may be widened.
|
||
if (unlockPop) unlockPop.setBounds({ x: 0, y: chromeH, width, 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; }
|
||
}
|
||
// Floating language picker — same shape as the engine picker above. The
|
||
// payload builder below reads the current language setting + the active
|
||
// tab's translate state so the dropdown has everything it needs in one
|
||
// shot; a re-render only needs a fresh payload, not a full reset.
|
||
function langPickerPayload() {
|
||
const osLoc = app.getLocale() || "en-US";
|
||
const isAuto = settings.languageMode === "show";
|
||
const current = isAuto ? "" : (settings.languageValue || "");
|
||
const t = activeTab();
|
||
const target = translationTargetBase();
|
||
const canTranslate = t && !t.settings && !t.addonId && !t.pending && t.pageLang &&
|
||
t.pageLang !== target &&
|
||
isTranslatorSupported(t.pageLang) && isTranslatorSupported(target);
|
||
const st = t ? tabTranslateState(t) : null;
|
||
const row = (L) => ({ tag: L.tag, label: L.label, code: String(L.tag).split("-")[0].toUpperCase() });
|
||
return {
|
||
osLangName: languageNameFor(osLoc),
|
||
auto: isAuto, currentTag: current,
|
||
supported: WEBSITE_LANGUAGE_QUICK.filter((L) => isTranslatorSupported(L.tag)).map(row),
|
||
unsupported: WEBSITE_LANGUAGE_QUICK.filter((L) => !isTranslatorSupported(L.tag)).map(row),
|
||
canTranslate: !!canTranslate,
|
||
translated: !!st?.translated,
|
||
sourceName: st?.source ? languageNameFor(st.source) : (t?.pageLang ? languageNameFor(t.pageLang) : ""),
|
||
pageLangName: t?.pageLang ? languageNameFor(t.pageLang) : "",
|
||
targetName: languageNameFor(target),
|
||
};
|
||
}
|
||
function positionLangPicker() {
|
||
if (!langPicker) return;
|
||
const { width } = win.getContentBounds();
|
||
const x = Math.max(6, Math.min(lpPos.x, width - LP_W - 6));
|
||
langPicker.setBounds({ x, y: lpPos.y, width: LP_W, height: lpH });
|
||
}
|
||
function showLangPicker(show) {
|
||
if (!langPicker) return;
|
||
if (show) {
|
||
if (deferUntilOverlayLoaded(langPicker, () => showLangPicker(true))) return;
|
||
positionLangPicker();
|
||
win.contentView.removeChildView(langPicker);
|
||
win.contentView.addChildView(langPicker);
|
||
langPicker.setVisible(true); lpVisible = true;
|
||
if (win.isFocused()) try { langPicker.webContents.focus(); } catch {}
|
||
langPicker.webContents.send("lang-picker-state", langPickerPayload());
|
||
} else { cancelOverlayShow(langPicker); langPicker.setVisible(false); lpVisible = false; }
|
||
}
|
||
function pushLangPickerState() { if (lpVisible) try { langPicker.webContents.send("lang-picker-state", langPickerPayload()); } catch {} }
|
||
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.
|
||
// Panels that asked for the left side live in the quick-links strip. With
|
||
// the strip turned off they fall back to the right sidebar so they stay
|
||
// reachable.
|
||
const isLeftPanel = (p) => p.side === "left" && !!settings.quickLinksShow;
|
||
const rightPanels = () => (addonHost ? addonHost.getSidebarPanels().filter((p) => !isLeftPanel(p)) : []);
|
||
const leftPanels = () => (addonHost ? addonHost.getSidebarPanels().filter(isLeftPanel) : []);
|
||
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: rightPanels(),
|
||
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;
|
||
if (show && panelId && leftPanels().some((p) => p.panelId === panelId)) { openLeftPanel(panelId); return; }
|
||
const panels = rightPanels();
|
||
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];
|
||
}
|
||
// Opening a panel is a first use: start its add-on alongside the page
|
||
// load. The panel's own calls would start it anyway, but a panel that
|
||
// only listens for api.emit never calls.
|
||
if (addonHost.isDormant(panel.addonId)) {
|
||
addonHost.ensureActive(panel.addonId, "panel opened").catch((e) => console.warn(`[addons] ${panel.addonId} failed to start:`, e?.message));
|
||
}
|
||
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;
|
||
// A tab playing sound (music, a talk) keeps playing, as in any browser;
|
||
// check again later and freeze it once it falls quiet.
|
||
try { if (tab.view.webContents.isCurrentlyAudible()) { scheduleFreeze(tab); return; } } catch {}
|
||
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 (lpVisible) showLangPicker(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);
|
||
// A widened left panel covers the tabs; bringing any tab forward (a tab
|
||
// click, Settings, a new tab) narrows it back to a bar beside the page.
|
||
if (leftPanelMax) setLeftPanelMax(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();
|
||
syncApprovalVisibility();
|
||
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 page that navigated away no longer stands behind what it asked for.
|
||
wc.on("did-navigate", () => cancelApprovalsFor(tab.id));
|
||
// 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, the autoTried latch, and the cached page language. The chip then
|
||
// re-decides on the next pageLang read whether to light up — and auto-
|
||
// translate gets one more shot on the new page. The one bit we preserve
|
||
// across the reset is `pending`: setWebsiteLanguage sets it right before
|
||
// triggering a reload, and the subsequent did-finish-load uses it to
|
||
// translate unconditionally (the user ASKED for a new language).
|
||
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
|
||
if (!isMainFrame) return;
|
||
const wasPending = !!tab._tr?.pending;
|
||
tab._tr = wasPending ? { translated: false, source: "", target: "", error: "", pending: true } : 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). If auto-translate is on and the page
|
||
// is in a language different from the user's, both of them supported, fire
|
||
// the translation now so the user reads the page in their own language
|
||
// without clicking anything.
|
||
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);
|
||
try {
|
||
const target = translationTargetBase();
|
||
const st = tabTranslateState(tab);
|
||
// Two gates fire auto-translate here:
|
||
// (a) Explicit language switch (st.pending) — the user asked for a
|
||
// different language right now; translate the active tab even if
|
||
// auto-offer is off and even if pageLang is empty (let the
|
||
// backend auto-detect the source).
|
||
// (b) Normal auto-offer — translateAutoOffer on, pageLang known and
|
||
// different from the user's target, both ends supported, once
|
||
// per document (autoTried latch).
|
||
const explicit = st.pending && tab.id === activeId;
|
||
const pageSupported = tab.pageLang ? isTranslatorSupported(tab.pageLang) : true; // unknown -> try, backend decides
|
||
const autoFire = settings.translateAutoOffer && tab.id === activeId &&
|
||
isTranslatorSupported(target) && pageSupported &&
|
||
(tab.pageLang ? tab.pageLang !== target : true) &&
|
||
!st.translated && !st.autoTried;
|
||
if (explicit || autoFire) {
|
||
st.autoTried = true;
|
||
st.pending = false;
|
||
await translateActiveTab();
|
||
}
|
||
} catch (e) { console.warn("[translate] auto failed:", e?.message); }
|
||
});
|
||
// 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(String(wc.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
|
||
const init = e.initiator;
|
||
if (!installRequesterOk(requester) || (init && init.frameTreeNodeId !== wc.mainFrame.frameTreeNodeId)) {
|
||
console.log("[addons] install link ignored: not from theseus.x's top frame");
|
||
return;
|
||
}
|
||
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();
|
||
// Only the top document speaks for the top origin. A cross-origin
|
||
// iframe (an ad on a trusted dapp) could navigate _top to a wiz://
|
||
// link and the pairing prompt would name the trusted site.
|
||
const init = e.initiator;
|
||
if (init && init.frameTreeNodeId !== wc.mainFrame.frameTreeNodeId) {
|
||
console.log("[wiz] ignored a pairing link from a sub-frame");
|
||
return;
|
||
}
|
||
routeWizUri(u, 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((details) => {
|
||
const { url, disposition } = details;
|
||
// 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)) {
|
||
// The open handler does not say which frame called window.open, but
|
||
// the referrer does: a link or window.open from the top document
|
||
// carries the top origin. Anything else (a sub-frame, or a referrer
|
||
// stripped with noreferrer) is not credited to the top site.
|
||
let refOrigin = null;
|
||
try { refOrigin = details.referrer && details.referrer.url ? new URL(details.referrer.url).origin : null; } catch {}
|
||
let topOrigin = null;
|
||
try { topOrigin = new URL(wc.getURL()).origin; } catch {}
|
||
if (!refOrigin || refOrigin !== topOrigin) {
|
||
console.log("[wiz] ignored a pairing window from another frame or without a referrer");
|
||
return { action: "deny" };
|
||
}
|
||
routeWizUri(url, wc.getURL(), tab.id);
|
||
return { action: "deny" };
|
||
}
|
||
const installId = installLinkId(url);
|
||
if (installId) {
|
||
let requester = null; try { requester = new URL(String(wc.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
|
||
let refHost = null; try { refHost = details.referrer && details.referrer.url ? new URL(String(details.referrer.url).replace(/^bns:\/\//i, "https://")).host : null; } catch {}
|
||
// Same rule as the bcnr call: the catalog site's own top frame only.
|
||
if (installRequesterOk(requester) && refHost === requester) installExtensionWithConsent(installId, requester);
|
||
else console.log("[addons] install window ignored: not from theseus.x's top frame");
|
||
} 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);
|
||
cancelApprovalsFor(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 language picker overlay — the globe chip's dropdown. Shares
|
||
// the same floating-overlay plumbing (deferred load, click-away close,
|
||
// addChildView bring-to-front) as the engine picker.
|
||
langPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "lang-picker-preload.js") } });
|
||
try { langPicker.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(langPicker);
|
||
styleScrollbars(langPicker.webContents);
|
||
deferOverlayLoad(langPicker, "lang-picker.html");
|
||
langPicker.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(langPicker, () => lpVisible, () => showLangPicker(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") } });
|
||
lockPanelView(sidebar);
|
||
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);
|
||
// Left add-on panels: same preload and IPC surface as the right sidebar.
|
||
leftPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
|
||
lockPanelView(leftPanel);
|
||
try { leftPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
|
||
win.contentView.addChildView(leftPanel);
|
||
styleScrollbars(leftPanel.webContents);
|
||
leftPanel.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);
|
||
// Vault unlock prompt (PIN or master password), shown per request.
|
||
unlockPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "unlock-preload.js") } });
|
||
try { unlockPop.setBackgroundColor("#00000000"); } catch {}
|
||
win.contentView.addChildView(unlockPop);
|
||
deferOverlayLoad(unlockPop, "unlock.html");
|
||
unlockPop.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; leftPanel = null; approvalPop = null; unlockPop = 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) {
|
||
// Address-bar navigation is for the page, so a widened left panel steps aside.
|
||
if (leftPanelMax) setLeftPanelMax(false);
|
||
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) => { if (leftPanelMax) setLeftPanelMax(false); return 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: "中文(繁體)" },
|
||
];
|
||
// Base BCP-47 codes the translator backend (silentmode.st/libre) currently has
|
||
// models for. The picker shows every entry in WEBSITE_LANGUAGE_QUICK but
|
||
// greys out the ones not on this list — a user who picks an unsupported one
|
||
// still gets its Accept-Language sent, but the translator stays dark on
|
||
// pages served in another language because there's no model path from the
|
||
// server's language to theirs. Grow this when the backend --load-only grows.
|
||
const TRANSLATOR_SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
|
||
function isTranslatorSupported(tag) {
|
||
return TRANSLATOR_SUPPORTED.has(String(tag || "").split("-")[0].toLowerCase());
|
||
}
|
||
async 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), and
|
||
// if the page is already translated, revert first so the fresh reload lands
|
||
// on original HTML rather than a mix of translated nodes and new ones.
|
||
// After the reload lands the did-finish-load hook does the translation to
|
||
// the new target — unconditionally for an explicit language change (we
|
||
// bypass the auto-offer toggle, because the user ASKING to switch languages
|
||
// IS the request to translate the current page too).
|
||
if (changed) {
|
||
const t = activeTab();
|
||
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
|
||
if (wc) {
|
||
try {
|
||
if (tabTranslateState(t).translated) await wc.executeJavaScript(PAGE_TRANSLATE_REVERT).catch(() => null);
|
||
} catch {}
|
||
t._tr = { translated: false, source: "", target: "", error: "", pending: true };
|
||
try { 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 {}
|
||
// The language picker (if open) uses this same state to decide whether
|
||
// to show "Translate" / "Show original" at the top, so repaint it too.
|
||
pushLangPickerState();
|
||
}
|
||
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");
|
||
// Globe-chip dropdown — a floating overlay, not the native Windows menu.
|
||
// The chrome passes the chip's viewport-rect; we anchor the picker's top-
|
||
// right corner under it so the dropdown sits flush with the toolbar. The
|
||
// chip toggles — a second click (or any click outside) closes it.
|
||
ipcMain.handle("website-language-menu-popup", (e, rect) => {
|
||
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");
|
||
if (lpVisible) { showLangPicker(false); return true; }
|
||
// Anchor top-right of the picker under the chip. The chrome WebContentsView
|
||
// sits at y=0; add the chip's bottom-y and sub-pixel offset to clear it.
|
||
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
|
||
const anchorRight = Math.round(chromeBounds.x + (rect?.x || 0) + (rect?.width || 24));
|
||
lpPos = { x: Math.max(6, anchorRight - LP_W), y: Math.round(chromeBounds.y + (rect?.y || 0) + (rect?.height || 24) + 4) };
|
||
showLangPicker(true);
|
||
return true;
|
||
});
|
||
ipcMain.handle("lp-pick", (_e, tag) => {
|
||
if (tag === "__auto__") setWebsiteLanguage("show");
|
||
else if (tag) setWebsiteLanguage("manual", String(tag));
|
||
showLangPicker(false);
|
||
return true;
|
||
});
|
||
ipcMain.handle("lp-translate", () => { showLangPicker(false); return translateActiveTab(); });
|
||
ipcMain.handle("lp-revert", () => { showLangPicker(false); return revertActiveTab(); });
|
||
ipcMain.handle("lp-open-settings", () => { showLangPicker(false); try { openSettingsTab("language"); } catch {} return true; });
|
||
ipcMain.handle("lp-close", () => { showLangPicker(false); return true; });
|
||
// Picker renderer measures its own content height after each render and
|
||
// asks us to resize to fit — a short "automatic + 6 languages + settings"
|
||
// menu stays short, expanding the "More languages" group grows it.
|
||
ipcMain.handle("lp-resize", (_e, h, w) => {
|
||
const nextH = Math.max(80, Math.min(560, Number(h) || lpH));
|
||
const nextW = Math.max(220, Math.min(360, Number(w) || LP_W));
|
||
const changed = nextH !== lpH || nextW !== LP_W;
|
||
lpH = nextH; LP_W = nextW;
|
||
if (changed && lpVisible) positionLangPicker();
|
||
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", (e) => { if (isLeftSender(e)) { closeLeftPanel(); return false; } 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;
|
||
if (wanted && leftPanelMax) setLeftPanelMax(false);
|
||
layout();
|
||
try { sidebar?.webContents.send("sidebar-max-change", sidebarMaximized); } catch {}
|
||
return sidebarMaximized;
|
||
}
|
||
const isLeftSender = (e) => !!leftPanel && e.sender === leftPanel.webContents;
|
||
function setLeftPanelMax(next) {
|
||
if (!!next === leftPanelMax) return leftPanelMax;
|
||
leftPanelMax = !!next;
|
||
if (leftPanelMax) {
|
||
// One wide view at a time: a maximized right sidebar steps back, and the
|
||
// panel goes on top of tab views created since it opened.
|
||
if (sidebarMaximized) setSidebarMaximized(false);
|
||
try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {}
|
||
}
|
||
layout();
|
||
try { leftPanel?.webContents.send("sidebar-max-change", leftPanelMax); } catch {}
|
||
return leftPanelMax;
|
||
}
|
||
ipcMain.handle("sidebar-maximize", (e) => isLeftSender(e) ? setLeftPanelMax(true) : setSidebarMaximized(true));
|
||
ipcMain.handle("sidebar-restore", (e) => isLeftSender(e) ? setLeftPanelMax(false) : setSidebarMaximized(false));
|
||
ipcMain.handle("sidebar-toggle-max", (e) => isLeftSender(e) ? setLeftPanelMax(!leftPanelMax) : setSidebarMaximized(!sidebarMaximized));
|
||
ipcMain.handle("sidebar-is-max", (e) => isLeftSender(e) ? leftPanelMax : 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 rightPanels()) 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.
|
||
if (leftPanelId && !leftPanels().some((p) => p.panelId === leftPanelId)) closeLeftPanel();
|
||
const panels = rightPanels();
|
||
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" };
|
||
// Also the manifest ids (a folder can be named differently) and the
|
||
// legacy ids a built-in add-on absorbs: "bchwallet" is Aegis's key
|
||
// namespace even though no folder of that name ships any more.
|
||
const fp = firstPartyAddonIds();
|
||
if (fp.bundled.has(id) || fp.absorbed.has(id)) return { ok: false, error: "id is reserved by 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 inside Theseus with the same access as Theseus itself: while your vault is unlocked it can reach your saved passwords and your Aegis wallet. Install it only if you would install a program from this 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; }));
|
||
// Only the catalog site may ask, and only from its top frame. Any page used
|
||
// to be able to raise the install sheet without a click (bcnr is in every
|
||
// page's main world), timed so a double-click landed on Install — and an
|
||
// installed extension runs in the main process at once.
|
||
const INSTALL_REQUESTERS = new Set(["theseus.x"]);
|
||
function installRequesterOk(host) { return !!host && INSTALL_REQUESTERS.has(String(host).toLowerCase()); }
|
||
ipcMain.handle("bcnr:installExtension", (e, id) => {
|
||
if (!e.senderFrame || e.senderFrame !== e.sender.mainFrame) return { ok: false, error: "only the top frame may install" };
|
||
let requester = null;
|
||
try { requester = new URL(String(e.sender.getURL()).replace(/^bns:\/\//i, "https://")).host || null; } catch {}
|
||
if (!installRequesterOk(requester)) return { ok: false, error: "extensions can only be installed from theseus.x" };
|
||
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.
|
||
// `pageUrl` is the top document's URL; the pairing is credited to its origin
|
||
// only if the wallet may be on that page at all (the same inject policy that
|
||
// decides whether the page gets the wallet bridge).
|
||
function routeWizUri(uri, pageUrl, tabId) {
|
||
if (!addonHost) return false;
|
||
const u = String(uri || "");
|
||
if (!/^wiz:/i.test(u) || u.length > 4096) return false;
|
||
const origin = pageOriginOf(pageUrl);
|
||
if (!origin || !addonHost.pageAllowed("aegis", String(pageUrl || ""))) {
|
||
console.log("[wiz] pairing link ignored: the wallet is not enabled on this page");
|
||
return false;
|
||
}
|
||
const send = () => addonHost
|
||
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
|
||
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
|
||
// A wallet that starts on first use is woken by the link itself.
|
||
if (addonHost.isDormant("aegis")) {
|
||
addonHost.ensureActive("aegis", "wiz link")
|
||
.then(() => { if (addonHost.hasHandler("aegis", "wcConnectFromPage")) send(); })
|
||
.catch((err) => console.log("[wiz] wallet failed to start:", err?.message || err));
|
||
return true;
|
||
}
|
||
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false;
|
||
send();
|
||
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");
|
||
// Only the tab's top-level document speaks for its origin. The inject
|
||
// preload runs in the main frame alone, so a message from any other frame
|
||
// did not come through it — and it would be credited with the top-level
|
||
// URL below. A frame that has since navigated away (senderFrame null)
|
||
// gets the same answer.
|
||
let mainFrame = null;
|
||
try { mainFrame = e.sender.mainFrame; } catch {}
|
||
if (!e.senderFrame || (mainFrame && e.senderFrame !== mainFrame)) throw new Error("not the top-level 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;
|
||
// Nothing goes over a wallet approval or the unlock prompt. A dapp could
|
||
// ask for a signature and then alert(): its sheet covered the approval,
|
||
// and closing it handed focus back to the page while the approval's
|
||
// buttons were already armed, so a double-click on "OK" landed on
|
||
// "Approve". The dialog waits (its page is blocked anyway) until the
|
||
// approval or prompt is answered.
|
||
if (approvalCurrent || unlockCurrent) 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);
|
||
// Never hand focus to the page while an approval or the unlock prompt is
|
||
// up: keystrokes meant for the prompt would go to the page.
|
||
if (!approvalCurrent && !unlockCurrent) {
|
||
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;
|
||
let unlockPop = null; // vault unlock prompt (unlock.html), see requestVaultUnlock
|
||
const approvalQueue = [];
|
||
let approvalCurrent = null; // { reqId, resolve }
|
||
let approvalSeq = 0;
|
||
// An approval a page asked for belongs to that page's tab, like a page
|
||
// dialog: it shows only while that tab is in front and waits otherwise, and
|
||
// it is cancelled when the tab closes or navigates. A background tab used to
|
||
// be able to time its request to pop over whatever the user was doing.
|
||
function pumpApproval() {
|
||
if (approvalCurrent || !approvalQueue.length || !approvalPop) return;
|
||
for (let i = approvalQueue.length - 1; i >= 0; i--) {
|
||
const q = approvalQueue[i];
|
||
if (q.tabId != null && !tabById(q.tabId)) { approvalQueue.splice(i, 1); try { q.resolve("cancel"); } catch {} }
|
||
}
|
||
const at = approvalQueue.findIndex((q) => q.tabId == null || q.tabId === activeId);
|
||
if (at < 0) return;
|
||
const next = approvalQueue.splice(at, 1)[0];
|
||
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, tabId = null) {
|
||
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, tabId: tabId == null ? null : tabId });
|
||
pumpApproval();
|
||
});
|
||
}
|
||
function cancelApprovalsFor(tabId) {
|
||
for (let i = approvalQueue.length - 1; i >= 0; i--) {
|
||
if (approvalQueue[i].tabId === tabId) { const q = approvalQueue.splice(i, 1)[0]; try { q.resolve("cancel"); } catch {} }
|
||
}
|
||
if (approvalCurrent && approvalCurrent.tabId === tabId) {
|
||
const c = approvalCurrent; approvalCurrent = null;
|
||
try { approvalPop.setVisible(false); } catch {}
|
||
try { c.resolve("cancel"); } catch {}
|
||
pumpApproval();
|
||
pumpJsDialog();
|
||
}
|
||
}
|
||
// Tab switch: the current approval hides with its tab and comes back (shown
|
||
// afresh, so re-armed) when the tab does; another tab's waiting approval may
|
||
// now show.
|
||
function syncApprovalVisibility() {
|
||
if (!approvalPop) return;
|
||
const cur = approvalCurrent;
|
||
if (!cur) { pumpApproval(); return; }
|
||
const show = cur.tabId == null || cur.tabId === activeId;
|
||
if (!show) {
|
||
try { approvalPop.setVisible(false); } catch {}
|
||
approvalCurrent = null;
|
||
approvalQueue.unshift(cur);
|
||
pumpApproval();
|
||
return;
|
||
}
|
||
}
|
||
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);
|
||
pumpJsDialog(); // a page dialog held back by the approval can show now
|
||
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 'Ariadne BNS Indexer','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)]; }));
|
||
// TASK_STATE: 1 disabled, 2 queued, 3 ready, 4 running. Since Ariadne
|
||
// 0.2 its indexer is the process that matters to Theseus; the resolver
|
||
// only runs in All-browsers mode.
|
||
const indexerTask = map["Ariadne BNS Indexer"];
|
||
const primary = indexerTask && indexerTask !== "MISSING" ? indexerTask : map["BNS Resolver Daemon"];
|
||
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.
|
||
const resolverTask = map["BNS Resolver Daemon"];
|
||
return {
|
||
state, installedVersion, bundledVersion: latestVersion, canUpdate, hasUninstaller: !!quietUninstall,
|
||
resolverRunning: resolverTask === "4" || resolverTask === "Running",
|
||
index: bnsIndexStatus,
|
||
};
|
||
});
|
||
}
|
||
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) });
|
||
|
||
// ---- Quick-unlock PIN + the vault unlock prompt ----------------------------
|
||
// The PIN wraps the master password (lib/vault-pin.cjs), so every unlock
|
||
// still ends at the master password; three wrong PINs require it outright.
|
||
// Extensions ask for an unlock with api.vault.requestUnlock(); Theseus shows
|
||
// its own prompt (unlock.html) and the PIN or password never reaches them.
|
||
const { createVaultPin } = require("./lib/vault-pin.cjs");
|
||
let vaultPinInst = null;
|
||
const vaultPin = () => (vaultPinInst ||= createVaultPin({ file: path.join(app.getPath("userData"), "vault-pin.json"), safeStorage, log: (...a) => console.log("[vault-pin]", ...a) }));
|
||
|
||
async function unlockVaultWithMaster(masterPassword) {
|
||
if (!fs.existsSync(vaultFile())) throw new Error("no vault");
|
||
const v = await loadVaultLib();
|
||
vaultState = await v.unlockVault(vaultFile(), masterPassword); // throws on a wrong password
|
||
importsState = null;
|
||
if (fs.existsSync(importsFile())) {
|
||
try { importsState = await v.unlockImports(importsFile(), masterPassword); }
|
||
catch (ie) { console.error("[imports] unlock failed:", ie?.message); }
|
||
}
|
||
importsUnlockPw = masterPassword;
|
||
try { vaultPin().resetFails(); } catch {}
|
||
emitPwAvailability();
|
||
return v;
|
||
}
|
||
|
||
const unlockQueue = [];
|
||
let unlockCurrent = null;
|
||
let unlockSeq = 0;
|
||
// Resolves { ok: true } once the vault is unlocked, { ok: false, reason }
|
||
// when there is no vault or the user cancels.
|
||
function requestVaultUnlock({ reason, addonId } = {}) {
|
||
if (vaultState) return Promise.resolve({ ok: true, already: true });
|
||
if (!fs.existsSync(vaultFile())) return Promise.resolve({ ok: false, reason: "no-vault" });
|
||
const a = addonId && addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
|
||
const req = { reqId: ++unlockSeq, addonName: a ? a.manifest.name : "Theseus", reason: String(reason || "").slice(0, 200) };
|
||
return new Promise((resolve) => { unlockQueue.push({ req, resolve }); pumpUnlock(); });
|
||
}
|
||
function pumpUnlock() {
|
||
if (unlockCurrent || !unlockQueue.length || !unlockPop) return;
|
||
const next = unlockQueue.shift();
|
||
if (vaultState) { next.resolve({ ok: true, already: true }); pumpUnlock(); return; }
|
||
unlockCurrent = next;
|
||
const st = vaultPin().status();
|
||
overlayReady(unlockPop).then(() => {
|
||
unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS });
|
||
try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front
|
||
unlockPop.setVisible(true);
|
||
unlockPop.webContents.focus();
|
||
}).catch((err) => {
|
||
unlockCurrent = null;
|
||
next.resolve({ ok: false, reason: "error" });
|
||
console.warn("[vault] unlock prompt failed:", err?.message);
|
||
pumpUnlock();
|
||
});
|
||
}
|
||
function finishUnlock(result) {
|
||
const cur = unlockCurrent;
|
||
unlockCurrent = null;
|
||
try { unlockPop?.setVisible(false); } catch {}
|
||
cur?.resolve(result);
|
||
pumpUnlock(); // queued requests resolve at once if the vault is now open
|
||
pumpJsDialog();
|
||
}
|
||
ipcMain.handle("unlock-submit", async (e, reqId, mode, value) => {
|
||
if (!unlockPop || e.sender !== unlockPop.webContents) return { ok: false, error: "denied" };
|
||
if (!unlockCurrent || unlockCurrent.req.reqId !== reqId) return { ok: false, error: "This prompt has expired." };
|
||
let masterPassword = value;
|
||
if (mode === "pin") {
|
||
try { masterPassword = await vaultPin().open(value); }
|
||
catch (err) {
|
||
// Same words as Aegis's PIN pads: one PIN, one policy, one wording.
|
||
if (err.code === "wrong-pin") return { ok: false, mode: "pin", error: `Wrong PIN. ${err.remaining} attempt${err.remaining === 1 ? "" : "s"} left before a 15 min lockout.` };
|
||
if (err.code === "locked") return { ok: false, mode: "password", lockedMs: err.lockedMs, error: `Too many failed attempts. Try again in ${Math.max(1, Math.ceil((err.lockedMs || 0) / 60000))} min or use the master password.` };
|
||
return { ok: false, mode: "password", error: err.message };
|
||
}
|
||
}
|
||
try {
|
||
await unlockVaultWithMaster(masterPassword);
|
||
} catch {
|
||
if (mode === "pin") {
|
||
// The PIN opened, but its master password no longer opens the vault
|
||
// (the password was changed): the PIN is stale.
|
||
vaultPin().clear();
|
||
return { ok: false, mode: "password", error: "Your PIN is out of date. Enter the master password, then set a new PIN in Settings." };
|
||
}
|
||
await new Promise((r) => setTimeout(r, 600));
|
||
return { ok: false, mode: "password", error: "Wrong master password." };
|
||
}
|
||
finishUnlock({ ok: true });
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("unlock-cancel", (e, reqId) => {
|
||
if (!unlockPop || e.sender !== unlockPop.webContents) return false;
|
||
if (unlockCurrent && unlockCurrent.req.reqId === reqId) finishUnlock({ ok: false, reason: "cancelled" });
|
||
return true;
|
||
});
|
||
// Settings › Passwords: set, change or remove the PIN.
|
||
ipcMain.handle("vault-pin-status", () => ({ ...vaultPin().status(), vault: fs.existsSync(vaultFile()), unlocked: !!vaultState, maxFails: vaultPin().MAX_FAILS }));
|
||
ipcMain.handle("vault-pin-set", async (_e, { pin, masterPassword } = {}) => {
|
||
try {
|
||
// Proves the password before it is wrapped; also unlocks the vault.
|
||
await unlockVaultWithMaster(String(masterPassword || ""));
|
||
} catch { await new Promise((r) => setTimeout(r, 600)); return vaultErr("wrong master password"); }
|
||
try { const r = await vaultPin().set(String(pin || ""), String(masterPassword)); return { ...vaultOk(), ...r }; }
|
||
catch (e) { return vaultErr(e.message); }
|
||
});
|
||
ipcMain.handle("vault-pin-clear", () => { vaultPin().clear(); return vaultOk(); });
|
||
ipcMain.handle("vault-pin-unlock", () => requestVaultUnlock({ reason: "Open your saved passwords." }));
|
||
|
||
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;
|
||
try { vaultPin().resetFails(); } catch {}
|
||
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();
|
||
// Switching deferral off (or asking for the wallet at launch) starts what
|
||
// is still waiting now rather than at the next launch. Switching it on
|
||
// leaves running add-ons alone; it applies from the next launch.
|
||
if (addonHost && key === "extensionsOnDemand" && val === false) addonHost.activateAllDormant("setting").catch(() => {});
|
||
if (addonHost && key === "walletAtLaunch" && val && addonHost.isDormant("aegis")) addonHost.ensureActive("aegis", "setting").catch(() => {});
|
||
if (key === "preloadMenus" && val && chromeReadyDone) prewarmOverlays();
|
||
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, revert any current translation first, and set
|
||
// the "pending" bit so the did-finish-load hook translates the new page
|
||
// to the new target even if auto-offer is off — a user who just picked
|
||
// a different language expects the current page to follow.
|
||
if (key === "languageMode" || key === "languageValue") {
|
||
const t = activeTab();
|
||
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
|
||
if (wc) {
|
||
(async () => {
|
||
try {
|
||
if (tabTranslateState(t).translated) await wc.executeJavaScript(PAGE_TRANSLATE_REVERT).catch(() => null);
|
||
} catch {}
|
||
t._tr = { translated: false, source: "", target: "", error: "", pending: true };
|
||
try { 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;
|
||
}
|
||
if (key === "quickLinksShow" && !settings.quickLinksShow) closeLeftPanel();
|
||
layout();
|
||
emitQuickLinksState();
|
||
emitSidebarState();
|
||
}
|
||
// 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 quickLinksPayload() {
|
||
return {
|
||
links: settings.quickLinks || [],
|
||
openId: leftPanelId ? "p:" + leftPanelId : activeQuickLinkId,
|
||
panels: leftPanels().map((p) => ({ id: "p:" + p.panelId, title: p.addonName || p.title, icon: p.icon || "" })),
|
||
};
|
||
}
|
||
function emitQuickLinksState() {
|
||
try { quicklinks?.webContents.send("quicklinks-state", quickLinksPayload()); } catch {}
|
||
}
|
||
function openLeftPanel(panelId) {
|
||
const p = leftPanels().find((x) => x.panelId === panelId);
|
||
if (!leftPanel || !p) return false;
|
||
activeQuickLinkId = null;
|
||
leftPanelId = panelId;
|
||
if (leftPanelLoadedId !== panelId) {
|
||
leftPanelLoadedId = panelId;
|
||
leftPanelMax = false;
|
||
try { leftPanel.webContents.loadFile(p.pageFile); } catch (e) { console.warn("left panel loadFile failed:", e?.message); }
|
||
}
|
||
try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {}
|
||
layout();
|
||
emitQuickLinksState();
|
||
try { leftPanel.webContents.send("sidebar-visibility", true); } catch {}
|
||
return true;
|
||
}
|
||
function closeLeftPanel() {
|
||
if (!leftPanelId) return;
|
||
leftPanelId = null;
|
||
layout();
|
||
emitQuickLinksState();
|
||
try { leftPanel?.webContents.send("sidebar-visibility", false); } catch {}
|
||
}
|
||
function openQuickPanel(id) {
|
||
const L = (settings.quickLinks || []).find((x) => x.id === id);
|
||
if (!quickPanel || !L || !L.url) return;
|
||
if (leftPanelId) closeLeftPanel();
|
||
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", () => quickLinksPayload());
|
||
ipcMain.handle("quicklinks-open", (_e, id) => {
|
||
if (!id) return false;
|
||
if (String(id).startsWith("p:")) {
|
||
const panelId = String(id).slice(2);
|
||
if (panelId === leftPanelId) closeLeftPanel(); else openLeftPanel(panelId);
|
||
return true;
|
||
}
|
||
if (id === activeQuickLinkId) { closeQuickPanel(); return true; }
|
||
openQuickPanel(String(id));
|
||
return true;
|
||
});
|
||
ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); closeLeftPanel(); 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 {}
|
||
}
|
||
}
|
||
// Language picker (if open) reflects the current languageMode/Value — a
|
||
// Settings-side change should re-paint the ✓.
|
||
pushLangPickerState();
|
||
}
|
||
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();
|
||
scrubStaleStoreCopies();
|
||
setTimeout(() => { try { pruneAddonBackups(); } catch {} }, 20000);
|
||
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; clearing
|
||
// history keeps that tab list while restoreSession is on, and deletes it
|
||
// otherwise 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) {
|
||
const keepSession = !!settings.restoreSession;
|
||
await clearHistoryNow({ keepSession });
|
||
if (!keepSession) 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 };
|