theseus/main.js

1300 lines
70 KiB
JavaScript
Raw Normal View History

// Theseus Navigator — Electron main process.
// Native .bch via a custom `bns://` protocol: resolves names with the shared
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog } = require("electron");
const path = require("path");
const http = require("http");
const https = require("https");
const { spawn } = require("child_process");
const fs = require("fs");
const WebSocket = require("ws");
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app.isPackaged
? path.join(RES_DIR, "resolver-web.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
// Built-in engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own).
Theseus: download tracker, search split, discover-more tier, UX polish Bundle of UX + feature work. Split from packaging by intent so the diff is reviewable; the next Theseus rebuild ships it. Features - Download tracker (new): session.on("will-download") → per-item state {id, filename, url, mime, total, received, state, savePath, startedAt} with updated/done event handlers. New downloadsPop WebContentsView loads downloads.html (new file) + downloads-preload.js (new file); panel positioned under a new #downloads toolbar button between search and Tor. Full IPC: downloads-get, toggle/close/resize-downloads, download-open/show/cancel/clear, downloads-clear-all. In-memory only — cross-session persistence is a future addition. Button badge shows active count + spin/done/err color. - Search engines split by kind + tier: * kind: "search" | "llm" — separate headers in picker + settings ("Search with" / "Ask an AI"). Empty sections hidden. * tier: "catalog" | "extra" — Settings now has THREE panes behind the "+ Add search engine" button: curated catalog, wider discoverable bank filtered by a live search input, custom URL form. * DEFAULT_ENABLED unchanged (5 major engines). * Custom user-added engines carry tier="custom" (never in catalog/extra panes). - 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract, Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule as LLMs: if one starts bouncing to a login gate, drop it. Bug fixes - Loadbar collapses to 0px when idle (was reserving a permanent 2px strip below the address bar). .loadbar {height:0} + .loadbar.on {height:2px} + 120ms transition. - Native <select> popup theme sync via :root { color-scheme: dark } + @media(prefers-color-scheme: light). nativeTheme.themeSource already drives prefers-color-scheme, so the OS popup color follows the app theme automatically (fixed light popup on dark app / vice versa). - .ctl layout flipped to flex-direction: row with flex-wrap so anti-fingerprint mode + value fields fit side-by-side. Settings restructure - General section: Startup group at the top ("Open previous windows and tabs" toggle), then Appearance below with three visual THEME CARDS (System / Light / Dark) — small mock-browser previews per theme, Firefox-style, active card gets a blue ring. System pipes through to nativeTheme.themeSource = "system". - Search promoted to a top-level sidebar item between General and Naming. Search-engine controls moved out of General into Search. - Search section: enabled list shows only enabled engines, grouped by kind, drag-reorder within a kind. "+ Add search engine" opens the catalog/extras/custom-URL panel. Search engine catalog trims (already flagged in prior work) - Removed ChatGPT / Claude / You.com (login-gated ?q=). - Removed SearXNG (federated; every single-instance default rots). Docs - TheseusNavigator/PENDING.md and GOTCHAS.md born with this work (see the HANDOFF.md commit for the convention). - PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be emptied after this ship lands. Preview harness - _preview.html + _settings-preview.html stubs updated with kind + tier + downloads seed + tier="extra" samples so the preview reflects reality. Both files are gitignored — local only. Coordination - Parallel session's collision-policy work (chrome.html registry chips, popover switcher, Naming section, in-tab collision prompt) already landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds cleanly on top.
2026-08-02 15:31:47 +02:00
// fav = the domain to load a real favicon from; sym = emoji fallback.
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
Theseus: download tracker, search split, discover-more tier, UX polish Bundle of UX + feature work. Split from packaging by intent so the diff is reviewable; the next Theseus rebuild ships it. Features - Download tracker (new): session.on("will-download") → per-item state {id, filename, url, mime, total, received, state, savePath, startedAt} with updated/done event handlers. New downloadsPop WebContentsView loads downloads.html (new file) + downloads-preload.js (new file); panel positioned under a new #downloads toolbar button between search and Tor. Full IPC: downloads-get, toggle/close/resize-downloads, download-open/show/cancel/clear, downloads-clear-all. In-memory only — cross-session persistence is a future addition. Button badge shows active count + spin/done/err color. - Search engines split by kind + tier: * kind: "search" | "llm" — separate headers in picker + settings ("Search with" / "Ask an AI"). Empty sections hidden. * tier: "catalog" | "extra" — Settings now has THREE panes behind the "+ Add search engine" button: curated catalog, wider discoverable bank filtered by a live search input, custom URL form. * DEFAULT_ENABLED unchanged (5 major engines). * Custom user-added engines carry tier="custom" (never in catalog/extra panes). - 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract, Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule as LLMs: if one starts bouncing to a login gate, drop it. Bug fixes - Loadbar collapses to 0px when idle (was reserving a permanent 2px strip below the address bar). .loadbar {height:0} + .loadbar.on {height:2px} + 120ms transition. - Native <select> popup theme sync via :root { color-scheme: dark } + @media(prefers-color-scheme: light). nativeTheme.themeSource already drives prefers-color-scheme, so the OS popup color follows the app theme automatically (fixed light popup on dark app / vice versa). - .ctl layout flipped to flex-direction: row with flex-wrap so anti-fingerprint mode + value fields fit side-by-side. Settings restructure - General section: Startup group at the top ("Open previous windows and tabs" toggle), then Appearance below with three visual THEME CARDS (System / Light / Dark) — small mock-browser previews per theme, Firefox-style, active card gets a blue ring. System pipes through to nativeTheme.themeSource = "system". - Search promoted to a top-level sidebar item between General and Naming. Search-engine controls moved out of General into Search. - Search section: enabled list shows only enabled engines, grouped by kind, drag-reorder within a kind. "+ Add search engine" opens the catalog/extras/custom-URL panel. Search engine catalog trims (already flagged in prior work) - Removed ChatGPT / Claude / You.com (login-gated ?q=). - Removed SearXNG (federated; every single-instance default rots). Docs - TheseusNavigator/PENDING.md and GOTCHAS.md born with this work (see the HANDOFF.md commit for the convention). - PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be emptied after this ship lands. Preview harness - _preview.html + _settings-preview.html stubs updated with kind + tier + downloads seed + tier="extra" samples so the preview reflects reality. Both files are gitignored — local only. Coordination - Parallel session's collision-policy work (chrome.html registry chips, popover switcher, Naming section, in-tab collision prompt) already landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds cleanly on top.
2026-08-02 15:31:47 +02:00
// 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 = {
Theseus: download tracker, search split, discover-more tier, UX polish Bundle of UX + feature work. Split from packaging by intent so the diff is reviewable; the next Theseus rebuild ships it. Features - Download tracker (new): session.on("will-download") → per-item state {id, filename, url, mime, total, received, state, savePath, startedAt} with updated/done event handlers. New downloadsPop WebContentsView loads downloads.html (new file) + downloads-preload.js (new file); panel positioned under a new #downloads toolbar button between search and Tor. Full IPC: downloads-get, toggle/close/resize-downloads, download-open/show/cancel/clear, downloads-clear-all. In-memory only — cross-session persistence is a future addition. Button badge shows active count + spin/done/err color. - Search engines split by kind + tier: * kind: "search" | "llm" — separate headers in picker + settings ("Search with" / "Ask an AI"). Empty sections hidden. * tier: "catalog" | "extra" — Settings now has THREE panes behind the "+ Add search engine" button: curated catalog, wider discoverable bank filtered by a live search input, custom URL form. * DEFAULT_ENABLED unchanged (5 major engines). * Custom user-added engines carry tier="custom" (never in catalog/extra panes). - 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract, Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule as LLMs: if one starts bouncing to a login gate, drop it. Bug fixes - Loadbar collapses to 0px when idle (was reserving a permanent 2px strip below the address bar). .loadbar {height:0} + .loadbar.on {height:2px} + 120ms transition. - Native <select> popup theme sync via :root { color-scheme: dark } + @media(prefers-color-scheme: light). nativeTheme.themeSource already drives prefers-color-scheme, so the OS popup color follows the app theme automatically (fixed light popup on dark app / vice versa). - .ctl layout flipped to flex-direction: row with flex-wrap so anti-fingerprint mode + value fields fit side-by-side. Settings restructure - General section: Startup group at the top ("Open previous windows and tabs" toggle), then Appearance below with three visual THEME CARDS (System / Light / Dark) — small mock-browser previews per theme, Firefox-style, active card gets a blue ring. System pipes through to nativeTheme.themeSource = "system". - Search promoted to a top-level sidebar item between General and Naming. Search-engine controls moved out of General into Search. - Search section: enabled list shows only enabled engines, grouped by kind, drag-reorder within a kind. "+ Add search engine" opens the catalog/extras/custom-URL panel. Search engine catalog trims (already flagged in prior work) - Removed ChatGPT / Claude / You.com (login-gated ?q=). - Removed SearXNG (federated; every single-instance default rots). Docs - TheseusNavigator/PENDING.md and GOTCHAS.md born with this work (see the HANDOFF.md commit for the convention). - PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be emptied after this ship lands. Preview harness - _preview.html + _settings-preview.html stubs updated with kind + tier + downloads seed + tier="extra" samples so the preview reflects reality. Both files are gitignored — local only. Coordination - Parallel session's collision-policy work (chrome.html registry chips, popover switcher, Naming section, in-tab collision prompt) already landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds cleanly on top.
2026-08-02 15:31:47 +02:00
duckduckgo: { kind: "search", tier: "catalog", name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { kind: "search", tier: "catalog", name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
brave: { kind: "search", tier: "catalog", name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { kind: "search", tier: "catalog", name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { kind: "search", tier: "catalog", name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
yandex: { kind: "search", tier: "catalog", name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
ecosia: { kind: "search", tier: "catalog", name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
mojeek: { kind: "search", tier: "catalog", name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
// SearXNG is federated (dozens of public instances at searx.space); any single
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
// Users who want SearXNG add their preferred instance via the custom URL form.
Theseus: download tracker, search split, discover-more tier, UX polish Bundle of UX + feature work. Split from packaging by intent so the diff is reviewable; the next Theseus rebuild ships it. Features - Download tracker (new): session.on("will-download") → per-item state {id, filename, url, mime, total, received, state, savePath, startedAt} with updated/done event handlers. New downloadsPop WebContentsView loads downloads.html (new file) + downloads-preload.js (new file); panel positioned under a new #downloads toolbar button between search and Tor. Full IPC: downloads-get, toggle/close/resize-downloads, download-open/show/cancel/clear, downloads-clear-all. In-memory only — cross-session persistence is a future addition. Button badge shows active count + spin/done/err color. - Search engines split by kind + tier: * kind: "search" | "llm" — separate headers in picker + settings ("Search with" / "Ask an AI"). Empty sections hidden. * tier: "catalog" | "extra" — Settings now has THREE panes behind the "+ Add search engine" button: curated catalog, wider discoverable bank filtered by a live search input, custom URL form. * DEFAULT_ENABLED unchanged (5 major engines). * Custom user-added engines carry tier="custom" (never in catalog/extra panes). - 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract, Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule as LLMs: if one starts bouncing to a login gate, drop it. Bug fixes - Loadbar collapses to 0px when idle (was reserving a permanent 2px strip below the address bar). .loadbar {height:0} + .loadbar.on {height:2px} + 120ms transition. - Native <select> popup theme sync via :root { color-scheme: dark } + @media(prefers-color-scheme: light). nativeTheme.themeSource already drives prefers-color-scheme, so the OS popup color follows the app theme automatically (fixed light popup on dark app / vice versa). - .ctl layout flipped to flex-direction: row with flex-wrap so anti-fingerprint mode + value fields fit side-by-side. Settings restructure - General section: Startup group at the top ("Open previous windows and tabs" toggle), then Appearance below with three visual THEME CARDS (System / Light / Dark) — small mock-browser previews per theme, Firefox-style, active card gets a blue ring. System pipes through to nativeTheme.themeSource = "system". - Search promoted to a top-level sidebar item between General and Naming. Search-engine controls moved out of General into Search. - Search section: enabled list shows only enabled engines, grouped by kind, drag-reorder within a kind. "+ Add search engine" opens the catalog/extras/custom-URL panel. Search engine catalog trims (already flagged in prior work) - Removed ChatGPT / Claude / You.com (login-gated ?q=). - Removed SearXNG (federated; every single-instance default rots). Docs - TheseusNavigator/PENDING.md and GOTCHAS.md born with this work (see the HANDOFF.md commit for the convention). - PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be emptied after this ship lands. Preview harness - _preview.html + _settings-preview.html stubs updated with kind + tier + downloads seed + tier="extra" samples so the preview reflects reality. Both files are gitignored — local only. Coordination - Parallel session's collision-policy work (chrome.html registry chips, popover switcher, Naming section, in-tab collision prompt) already landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds cleanly on top.
2026-08-02 15:31:47 +02:00
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.
Theseus: download tracker, search split, discover-more tier, UX polish Bundle of UX + feature work. Split from packaging by intent so the diff is reviewable; the next Theseus rebuild ships it. Features - Download tracker (new): session.on("will-download") → per-item state {id, filename, url, mime, total, received, state, savePath, startedAt} with updated/done event handlers. New downloadsPop WebContentsView loads downloads.html (new file) + downloads-preload.js (new file); panel positioned under a new #downloads toolbar button between search and Tor. Full IPC: downloads-get, toggle/close/resize-downloads, download-open/show/cancel/clear, downloads-clear-all. In-memory only — cross-session persistence is a future addition. Button badge shows active count + spin/done/err color. - Search engines split by kind + tier: * kind: "search" | "llm" — separate headers in picker + settings ("Search with" / "Ask an AI"). Empty sections hidden. * tier: "catalog" | "extra" — Settings now has THREE panes behind the "+ Add search engine" button: curated catalog, wider discoverable bank filtered by a live search input, custom URL form. * DEFAULT_ENABLED unchanged (5 major engines). * Custom user-added engines carry tier="custom" (never in catalog/extra panes). - 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract, Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule as LLMs: if one starts bouncing to a login gate, drop it. Bug fixes - Loadbar collapses to 0px when idle (was reserving a permanent 2px strip below the address bar). .loadbar {height:0} + .loadbar.on {height:2px} + 120ms transition. - Native <select> popup theme sync via :root { color-scheme: dark } + @media(prefers-color-scheme: light). nativeTheme.themeSource already drives prefers-color-scheme, so the OS popup color follows the app theme automatically (fixed light popup on dark app / vice versa). - .ctl layout flipped to flex-direction: row with flex-wrap so anti-fingerprint mode + value fields fit side-by-side. Settings restructure - General section: Startup group at the top ("Open previous windows and tabs" toggle), then Appearance below with three visual THEME CARDS (System / Light / Dark) — small mock-browser previews per theme, Firefox-style, active card gets a blue ring. System pipes through to nativeTheme.themeSource = "system". - Search promoted to a top-level sidebar item between General and Naming. Search-engine controls moved out of General into Search. - Search section: enabled list shows only enabled engines, grouped by kind, drag-reorder within a kind. "+ Add search engine" opens the catalog/extras/custom-URL panel. Search engine catalog trims (already flagged in prior work) - Removed ChatGPT / Claude / You.com (login-gated ?q=). - Removed SearXNG (federated; every single-instance default rots). Docs - TheseusNavigator/PENDING.md and GOTCHAS.md born with this work (see the HANDOFF.md commit for the convention). - PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be emptied after this ship lands. Preview harness - _preview.html + _settings-preview.html stubs updated with kind + tier + downloads seed + tier="extra" samples so the preview reflects reality. Both files are gitignored — local only. Coordination - Parallel session's collision-policy work (chrome.html registry chips, popover switcher, Naming section, in-tab collision prompt) already landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds cleanly on top.
2026-08-02 15:31:47 +02:00
perplexity: { kind: "llm", tier: "catalog", name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
phind: { kind: "llm", tier: "catalog", name: "Phind", sym: "🧑‍💻", fav: "www.phind.com", url: (q) => "https://www.phind.com/search?q=" + encodeURIComponent(q) },
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
// These are known-working engines that don't require sign-in on ?q= but aren't
// first-class enough to sit in the primary catalog. Keep the list vetted — if
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
marginalia: { kind: "search", tier: "extra", name: "Marginalia", sym: "🕸", fav: "search.marginalia.nu", url: (q) => "https://search.marginalia.nu/search?query=" + encodeURIComponent(q) },
stract: { kind: "search", tier: "extra", name: "Stract", sym: "🧵", fav: "stract.com", url: (q) => "https://stract.com/search?q=" + encodeURIComponent(q) },
yep: { kind: "search", tier: "extra", name: "Yep", sym: "✳️", fav: "yep.com", url: (q) => "https://yep.com/web?q=" + encodeURIComponent(q) },
presearch: { kind: "search", tier: "extra", name: "Presearch", sym: "🔷", fav: "presearch.com", url: (q) => "https://presearch.com/search?q=" + encodeURIComponent(q) },
metager: { kind: "search", tier: "extra", name: "MetaGer", sym: "🇩🇪", fav: "metager.org", url: (q) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent(q) },
qwant: { kind: "search", tier: "extra", name: "Qwant", sym: "🇫🇷", fav: "www.qwant.com", url: (q) => "https://www.qwant.com/?q=" + encodeURIComponent(q) },
swisscows: { kind: "search", tier: "extra", name: "Swisscows", sym: "🐄", fav: "swisscows.com", url: (q) => "https://swisscows.com/en/web?query=" + encodeURIComponent(q) },
naver: { kind: "search", tier: "extra", name: "Naver", sym: "🇰🇷", fav: "www.naver.com", url: (q) => "https://search.naver.com/search.naver?query=" + encodeURIComponent(q) },
baidu: { kind: "search", tier: "extra", name: "Baidu", sym: "🇨🇳", fav: "www.baidu.com", url: (q) => "https://www.baidu.com/s?wd=" + encodeURIComponent(q) },
};
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
const DEFAULT_ENABLED = ["duckduckgo", "google", "brave", "bing", "startpage"];
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null);
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// 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]) =>
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) }));
for (const c of settings.customEngines || [])
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
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); }
function engineUrl(id, q) {
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q);
const c = (settings.customEngines || []).find((e) => e.id === id);
return c ? c.url.replace(/%s/g, encodeURIComponent(q)) : SEARCH_ENGINES.duckduckgo.url(q);
}
const SEARCH = (q) => engineUrl(settings.searchEngine, q);
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st";
// ---- BNS name detection (multi-TLD) --------------------------------------
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
// <label>.<tld> from the BCNR beacon. Flow:
// 1. User navigates to `<label>.<tld>` (address bar or link click)
// 2. Theseus asks BCNR first
// 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u)
// 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web
// (https://<host><path>), so users aren't locked out of the clearnet
// when the chain is down or the name isn't registered.
// Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames,
// non-http schemes) bypass BCNR and load directly.
// The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN.
const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = (host) => {
const h = String(host).toLowerCase().replace(/\.$/, "");
const dot = h.lastIndexOf(".");
return dot < 0 ? null : h.slice(dot + 1);
};
// Any dotted host that isn't an IP or localhost is a BCNR candidate.
const isBnsHost = (host) => {
if (!host) return false;
const h = String(host).toLowerCase().replace(/\.$/, "");
if (h === "localhost" || h.startsWith("localhost:")) return false;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(h)) return false; // IPv4[:port]
if (h.startsWith("[")) return false; // IPv6 literal
const dot = h.lastIndexOf(".");
return dot > 0 && dot < h.length - 1; // has a real TLD
};
// Kept as aliases so external callers (tests, module.exports) don't break.
const nativeTld = (host) => isBnsHost(host) ? tldOf(host) : null;
const dualTld = () => null; // dual-priority mode is gone — no ICANN-first TLDs
const registryOf = (_tld) => REGISTRY;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl(q) {
if (!q) return false;
if (/\s/.test(q)) return false; // has whitespace -> search
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(q)) return true; // scheme://…
if (/^localhost(:\d+)?([/?#]|$)/i.test(q)) return true; // localhost[:port]
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/.test(q)) return true; // IPv4[:port]
const host = q.split(/[/?#]/)[0]; // strip path/query/frag
return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host
}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS_DEFAULTS = {
webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone: true, // deny microphone by default (also hides mic labels)
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual
languageMode: "show", languageSpoof: "en-US", languageValue: "en-US", // spoof = top-10 pick, manual = free text
locationMode: "hide", locationRegion: "europe", locationLat: "40.7128", locationLon: "-74.0060", // spoof by region, or manual coords
searchEngine: "duckduckgo",// default search engine (built-in id or a custom id)
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
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",
};
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
function applyTheme() {
try { nativeTheme.themeSource = ["dark", "light", "system"].includes(settings.theme) ? settings.theme : "dark"; } catch {}
}
// Auto decoys used by "spoof" mode (plausible but not the user's real values).
const SPOOF = { tz: "America/New_York", lang: "en-US", lat: 40.7128, lon: -74.0060 };
let settings = { ...SETTINGS_DEFAULTS };
const settingsFile = () => path.join(app.getPath("userData"), "settings.json");
function loadSettings() {
try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; }
catch (e) { console.error("settings load failed:", e.message); }
}
function saveSettings() {
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
}
// ---- bookmarks / saved pages (userData/bookmarks.json) ----
let bookmarks = [];
const bookmarksFile = () => path.join(app.getPath("userData"), "bookmarks.json");
function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } }
function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } }
function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} }
// ---- BCNR/ICANN collisions (userData/collisions.json) --------------------
// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode.
// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model.
let collisions = { byName: {}, byTld: {} };
const collisionsFile = () => path.join(app.getPath("userData"), "collisions.json");
function loadCollisions() {
try { if (fs.existsSync(collisionsFile())) collisions = { byName: {}, byTld: {}, ...JSON.parse(fs.readFileSync(collisionsFile(), "utf8")) }; }
catch (e) { console.error("collisions load failed:", e.message); }
}
function saveCollisions() {
try { fs.writeFileSync(collisionsFile(), JSON.stringify(collisions, null, 2)); } catch (e) { console.error("collisions save failed:", e.message); }
}
// per-name > per-TLD > hard policy. Returns "bcnr" | "icann" | null (null = ask in soft).
function overrideFor(host, tld) {
const n = String(host).toLowerCase();
const t = String(tld || "").toLowerCase();
if (collisions.byName[n]) return collisions.byName[n];
if (collisions.byTld[t]) return collisions.byTld[t];
return null;
}
// Cached BCNR-native TLD list from tlds.bch. Registered names under a native TLD
// are NOT collision candidates (whole TLD belongs to BCNR); non-native = might collide.
let bcnrTlds = ["bch"];
function isBcnrNativeTld(tld) { return bcnrTlds.includes(String(tld || "").toLowerCase()); }
function refreshBcnrTlds(index) {
try {
const raw = index?.get?.("tlds.bch")?.records?.tlds;
if (typeof raw === "string") {
const list = raw.split(/\s+/).filter(Boolean).map((s) => s.toLowerCase());
if (list.length) bcnrTlds = list;
}
} catch {}
}
// (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the
// prompt is now an in-tab full-page interstitial loaded from collision.html,
// wired via the bns://collision-choose/ handler in serveBns().)
function rememberCollision(host, tld, choice, remember) {
if (choice !== "bcnr" && choice !== "icann") return;
if (remember === "name") collisions.byName[String(host).toLowerCase()] = choice;
else if (remember === "tld") collisions.byTld[String(tld || "").toLowerCase()] = choice;
if (remember !== "no") saveCollisions();
}
function emitEngines() {
try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {}
if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {}
}
// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter"
// Chrome extension provides, done natively (that extension's chrome.privacy API
// isn't available in Electron, and this is more reliable). Tor forces the strongest.
const WEBRTC_POLICIES = {
default: "default", // allow all (may expose local IP)
public_only: "default_public_interface_only", // only the default public interface
public_private: "default_public_and_private_interfaces",
disable_udp: "disable_non_proxied_udp", // strongest (only proxied UDP)
};
function webrtcPolicy() {
if (torState === "on") return "disable_non_proxied_udp";
return WEBRTC_POLICIES[settings.webrtcMode] || "default_public_interface_only";
}
// ---- anti-fingerprinting: timezone + language + location ----
// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value).
function effTimezone() {
switch (settings.timezoneMode) {
case "hide": return "UTC";
case "spoof": return SPOOF.tz;
case "manual": return settings.timezoneValue || "UTC";
default: return null;
}
}
function effLocale() {
switch (settings.languageMode) {
case "hide": return "en-US";
case "spoof": return settings.languageSpoof || SPOOF.lang; // chosen from the top-languages list
case "manual": return settings.languageValue || "en-US";
default: return null;
}
}
// Representative coordinates per world region — used when the spoofed location is
// set to a region rather than exact coordinates (a major city stands in for each).
const REGIONS = {
europe: { lat: 52.5200, lon: 13.4050 }, // Berlin
asia: { lat: 35.6762, lon: 139.6503 }, // Tokyo
north_america: { lat: 40.7128, lon: -74.0060 }, // New York
south_america: { lat: -23.5505, lon: -46.6333 }, // São Paulo
africa: { lat: -1.2921, lon: 36.8219 }, // Nairobi
middle_east: { lat: 25.2048, lon: 55.2708 }, // Dubai
australia: { lat: -33.8688, lon: 151.2093 }, // Sydney
};
// Geolocation: null = show (real, allowed); "deny" = hide (blocked);
// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page.
function effLocation() {
const m = settings.locationMode;
if (m === "hide") return "deny";
if (m === "spoof") { const r = REGIONS[settings.locationRegion] || REGIONS.europe; return { lat: r.lat, lon: r.lon }; }
if (m === "manual") return { lat: Number(settings.locationLat) || 0, lon: Number(settings.locationLon) || 0 };
return null; // show
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint(wc) {
try {
if (!wc.debugger.isAttached()) wc.debugger.attach("1.3");
const tz = effTimezone();
await wc.debugger.sendCommand("Emulation.setTimezoneOverride", { timezoneId: tz || "" });
const loc = effLocale();
await wc.debugger.sendCommand("Emulation.setLocaleOverride", loc ? { locale: loc } : {});
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc.debugger.sendCommand("Page.enable");
if (wc._langScript) {
try { await wc.debugger.sendCommand("Page.removeScriptToEvaluateOnNewDocument", { identifier: wc._langScript }); } catch {}
wc._langScript = null;
}
// Build one injected script covering navigator.language(s) and geolocation.
let src = "";
if (loc) {
const langs = JSON.stringify([loc, loc.split("-")[0]]);
src += `Object.defineProperty(navigator,'language',{get:()=>${JSON.stringify(loc)},configurable:true});` +
`Object.defineProperty(navigator,'languages',{get:()=>${langs},configurable:true});`;
}
const geo = effLocation();
if (geo && geo !== "deny") { // spoof/manual: override the reported coordinates
const pos = `{coords:{latitude:${geo.lat},longitude:${geo.lon},accuracy:100,altitude:null,altitudeAccuracy:null,heading:null,speed:null},timestamp:Date.now()}`;
src += `try{const p=()=>(${pos});if(navigator.geolocation){navigator.geolocation.getCurrentPosition=(ok)=>{try{ok(p())}catch(e){}};navigator.geolocation.watchPosition=(ok)=>{try{ok(p())}catch(e){}return 0};}}catch(e){}`;
}
// Media-device privacy: Chromium leaks audiooutput (speaker) labels + deviceIds
// via enumerateDevices even when camera/mic are blocked. Like Firefox, blank
// every device's label/deviceId/groupId and collapse to one entry per kind.
if (settings.hideMediaDevices) {
src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`;
}
if (src) {
const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src });
wc._langScript = res.identifier;
try { await wc.executeJavaScript(src); } catch {} // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() {
const loc = effLocale() || app.getLocale() || "en-US";
try {
const ua = session.defaultSession.getUserAgent();
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
} catch {}
}
// ---- session restore + background throttling ----
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
function saveSession() {
try { fs.writeFileSync(sessionFile(), JSON.stringify(tabs.filter((t) => !t.settings && t.url).map((t) => t.url))); }
catch (e) { console.error("session save failed:", e.message); }
}
function loadSession() {
try { if (fs.existsSync(sessionFile())) return JSON.parse(fs.readFileSync(sessionFile(), "utf8")); } catch {}
return [];
}
function applyThrottle() {
for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed(kinds) {
if (kinds.includes("video") && settings.blockCamera) return false;
if (kinds.includes("audio") && settings.blockMicrophone) return false;
return true;
}
function applyPermissions() {
const ses = session.defaultSession;
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
if (SENSITIVE_DEVICE.has(permission)) return callback(false);
callback(true); // benign UX permissions (fullscreen, pointerLock, …)
});
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
if (permission === "media") {
if (details?.mediaType === "video") return !settings.blockCamera;
if (details?.mediaType === "audio") return !settings.blockMicrophone;
return !(settings.blockCamera && settings.blockMicrophone);
}
if (permission === "geolocation") return effLocation() !== "deny";
if (SENSITIVE_DEVICE.has(permission)) return false;
return true;
});
}
// Cookie shim for cross-site embeds. Sites like the faucet hub's captcha-gated testnet
// faucets set session cookies with no SameSite attribute; Chromium defaults those to
// Lax and withholds them inside cross-site iframes, so cookie-bound captcha endpoints
// fail (tbch.googol.cash /captcha 500s without its session cookie). Rewriting their
// Set-Cookie to SameSite=None; Secure makes the cookie frame-eligible. Allowlist only —
// SameSite is CSRF protection, never relax it globally. NOTE: Electron keeps a single
// onHeadersReceived listener per session; if another is ever added, merge them.
const EMBED_COOKIE_SITES = ["https://tbch.googol.cash/*", "https://signetfaucet.com/*"];
function applyEmbedCookieShim() {
session.defaultSession.webRequest.onHeadersReceived({ urls: EMBED_COOKIE_SITES }, (details, callback) => {
const headers = details.responseHeaders || {};
for (const key of Object.keys(headers)) {
if (key.toLowerCase() !== "set-cookie") continue;
headers[key] = headers[key].map((c) => (/;\s*samesite=/i.test(c) ? c : c + "; SameSite=None; Secure"));
}
callback({ responseHeaders: headers });
});
}
protocol.registerSchemesAsPrivileged([
{ scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
]);
let resolver;
async function getResolver() {
if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`);
return resolver;
}
// ---- Tor (optional onion routing, toggled from the UI) ----
// IP privacy, not full anonymity: this browser can still be fingerprinted.
const TOR_PORT = 9152;
const TOR_BIN = path.join(RES_DIR, "tor", "tor", "tor.exe");
const TOR_GEOIP = path.join(RES_DIR, "tor", "data", "geoip");
const TOR_GEOIP6 = path.join(RES_DIR, "tor", "data", "geoip6");
let torProc = null, torState = "off";
let torWsAgent = null;
let SocksProxyAgent;
async function loadSocks() { if (!SocksProxyAgent) ({ SocksProxyAgent } = await import("socks-proxy-agent")); }
function sendTor() { try { chrome?.webContents.send("tor", { state: torState }); } catch {} }
async function startTor() {
if (torProc) return;
torState = "connecting"; sendTor();
await loadSocks();
const dataDir = path.join(app.getPath("userData"), "tor-data");
torProc = spawn(TOR_BIN, ["--SocksPort", String(TOR_PORT), "--ControlPort", "0",
"--DataDirectory", dataDir, "--GeoIPFile", TOR_GEOIP, "--GeoIPv6File", TOR_GEOIP6], { windowsHide: true });
torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); });
torProc.stderr.on("data", () => {});
torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); });
}
function torReady() {
torState = "on";
torWsAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
session.defaultSession.setProxy({ proxyRules: `socks5://127.0.0.1:${TOR_PORT}` });
applyWebRTCPolicy();
sendTor();
}
function torOff() {
torState = "off"; torWsAgent = null;
session.defaultSession.setProxy({ proxyRules: "" });
applyWebRTCPolicy();
sendTor();
}
function stopTor() { torOff(); if (torProc) { try { torProc.kill(); } catch {} torProc = null; } }
// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy
// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the
// way the Tor Browser does). This is the usual reason a site still sees your IP.
function applyWebRTCPolicy() {
const policy = webrtcPolicy();
for (const t of tabs) { try { t.view.webContents.setWebRTCIPHandlingPolicy(policy); } catch {} }
}
class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torWsAgent, ...opts }); } }
const currentWS = () => (torState === "on" ? TorWebSocket : WebSocket);
function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const lib = u.protocol === "https:" ? https : http;
const req = lib.request(u, { method, headers, agent }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
});
req.on("error", reject); req.end();
});
}
async function contentFetch(url, init = {}) {
if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); }
const r = await fetch(url, init);
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) };
}
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
async function fetchOpenSearch(href) {
try {
const r = await contentFetch(href, {});
const xml = r.buffer.toString("utf8");
const nameM = xml.match(/<ShortName>([^<]+)<\/ShortName>/i);
const urlM = xml.match(/<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i)
|| xml.match(/<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i);
if (!urlM) return null;
const template = urlM[1].replace(/\{searchTerms\??\}/gi, "%s").replace(/\{[^}]*\}/g, ""); // drop other {params}
if (!template.includes("%s") || !/^https?:\/\//i.test(template)) return null;
return { name: (nameM ? nameM[1] : new URL(href).hostname).trim().slice(0, 40), url: template };
} catch { return null; }
}
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
// build. The last discovered list is cached to disk and tried first next launch.
let electrumPool = null;
let lastElectrumRefresh = 0;
const electrumFile = () => path.join(app.getPath("userData"), "electrum-servers.json");
const serverKey = (s) => (typeof s === "string" ? s : s && s.url);
function mergeServers(preferred, rest) {
const seen = new Set(), out = [];
for (const s of [...(preferred || []), ...(rest || [])]) {
const k = serverKey(s);
if (k && !seen.has(k)) { seen.add(k); out.push(s); }
}
return out;
}
async function initElectrumPool() {
const { CHIPNET_ELECTRUM } = await getResolver();
let saved = [];
try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {}
electrumPool = mergeServers(saved, CHIPNET_ELECTRUM); // discovered first, seed always kept
}
async function refreshElectrumPool() {
try {
const { fetchElectrumServers } = await getResolver();
const found = await fetchElectrumServers({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
if (found && found.length) {
electrumPool = mergeServers(found, electrumPool);
try { fs.writeFileSync(electrumFile(), JSON.stringify(found, null, 2)); } catch {}
}
} catch { /* list unpublished or unreachable — keep the current pool */ }
}
function maybeRefreshElectrum() {
if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return;
lastElectrumRefresh = Date.now();
refreshElectrumPool(); // fire-and-forget
}
const entries = new Map();
// Cached chain index. Building it (connect + fetch every beacon tx) is the slow
// part, and it was happening on EVERY navigation. Build once, reuse for lookups,
// and refresh in the background — so .bch pages open near-instantly after the first.
let sharedIndex = null, indexBuiltAt = 0, indexBuilding = null;
const INDEX_TTL = 45_000;
async function ensureIndex(force = false) {
const { buildIndex } = await getResolver();
if (!electrumPool) await initElectrumPool();
if (!force && sharedIndex && Date.now() - indexBuiltAt < INDEX_TTL) return sharedIndex;
if (indexBuilding) return indexBuilding; // dedupe concurrent builds
indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool })
.then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; })
.finally(() => { indexBuilding = null; });
// If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return (sharedIndex && !force) ? sharedIndex : indexBuilding;
}
async function resolveHost(host) {
const { normalizeName } = await getResolver();
let key; try { key = normalizeName(host); } catch { return null; }
let idx = await ensureIndex();
let entry = idx.get(key) ?? null;
// Miss on a possibly-stale index → one fresh build (the name may be newly registered).
if (!entry && Date.now() - indexBuiltAt > 8_000) { idx = await ensureIndex(true); entry = idx.get(key) ?? null; }
if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() });
maybeRefreshElectrum();
return entry;
}
const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml",
ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" };
const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
async function serveBns(request) {
const url = new URL(request.url);
const host = url.hostname.toLowerCase();
const reqPath = decodeURIComponent(url.pathname) || "/";
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)
let rec = entries.get(host);
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
const r = rec.entry.records;
try {
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
if (r.s3) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {});
const ct = up.contentType && up.contentType !== "application/octet-stream"
? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath);
let body = up.buffer;
if (ct.includes("text/html")) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
}
return new Response(body, { status: up.status, headers: { "content-type": ct } });
}
if (r.ip) {
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
}
if (r.u) return Response.redirect(r.u, 302);
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
}
// ---- window + tabs ----
let win, chrome;
let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown
// Site-info popover: a floating overlay VIEW on top of the page content, so it
// never pushes the page down. Positioned under the address-bar badge on demand.
let popover, popVisible = false, popPos = { x: 8, y: 90 };
const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
const EP_W = 250; let epH = 320;
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop, dlVisible = false, dlPos = { x: 8, y: 90 };
const DL_W = 340; let dlH = 240;
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id);
const activeTab = () => tabById(activeId);
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
// Enrich prov with a "collision candidate?" flag so the popover switcher can
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
function decorate(prov) {
if (!prov || !prov.tld) return prov;
return { ...prov, bcnrNativeTld: isBcnrNativeTld(prov.tld) };
}
function pushNav(prov) {
const p = decorate(prov);
chrome?.webContents.send("nav", p);
if (popVisible) popover?.webContents.send("site-info", p);
}
function layout() {
if (!win) return;
const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH });
positionPopover();
positionEnginePicker();
positionDownloads();
}
function positionPopover() {
if (!popover) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(popPos.x, width - POP_W - 6));
popover.setBounds({ x, y: popPos.y, width: POP_W, height: popH });
}
function showPopover(show) {
if (!popover) return;
if (show) {
positionPopover();
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
win.contentView.removeChildView(popover);
win.contentView.addChildView(popover);
popover.setVisible(true); popVisible = true;
popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" });
} else { popover.setVisible(false); popVisible = false; }
}
function positionEnginePicker() {
if (!enginePicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(epPos.x, width - EP_W - 6));
enginePicker.setBounds({ x, y: epPos.y, width: EP_W, height: epH });
}
function showEnginePicker(show) {
if (!enginePicker) return;
if (show) {
positionEnginePicker();
win.contentView.removeChildView(enginePicker);
win.contentView.addChildView(enginePicker);
enginePicker.setVisible(true); epVisible = true;
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
} else { enginePicker.setVisible(false); epVisible = false; }
}
function positionDownloads() {
if (!downloadsPop) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(dlPos.x, width - DL_W - 6));
downloadsPop.setBounds({ x, y: dlPos.y, width: DL_W, height: dlH });
}
function showDownloads(show) {
if (!downloadsPop) return;
if (show) {
positionDownloads();
win.contentView.removeChildView(downloadsPop);
win.contentView.addChildView(downloadsPop);
downloadsPop.setVisible(true); dlVisible = true;
downloadsPop.webContents.send("downloads", downloadsPublic());
} else { downloadsPop.setVisible(false); dlVisible = false; }
}
// Public view of a download — no DownloadItem refs leak to the renderer.
const downloadsPublic = () => downloads.map((d) => ({ ...d }));
function emitDownloads() {
const pub = downloadsPublic();
try { chrome?.webContents.send("downloads", pub); } catch {}
if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {}
}
// Attach the will-download listener to the SHARED default session. Every tab's
// WebContents inherits it, so we catch downloads regardless of which tab
// initiated them (including anchor clicks with `download`, form posts serving
// attachments, and manual save-as gestures).
function installDownloadTracker() {
session.defaultSession.on("will-download", (_e, item /*, wc */) => {
const id = nextDlId++;
const rec = {
id,
filename: item.getFilename(),
url: item.getURL(),
mime: item.getMimeType(),
total: item.getTotalBytes() || 0,
received: 0,
state: "progressing", // progressing | paused | completed | cancelled | interrupted
savePath: "",
startedAt: Date.now(),
};
downloads.unshift(rec);
dlItems.set(id, item);
emitDownloads();
item.on("updated", (_ev, state) => {
rec.state = state; // "progressing" | "interrupted"
rec.received = item.getReceivedBytes();
rec.total = item.getTotalBytes() || rec.total;
rec.savePath = item.getSavePath() || rec.savePath;
emitDownloads();
});
item.once("done", (_ev, state) => {
rec.state = state; // "completed" | "cancelled" | "interrupted"
rec.received = item.getReceivedBytes();
rec.savePath = item.getSavePath() || rec.savePath;
dlItems.delete(id);
emitDownloads();
});
});
}
function setActive(id) {
activeId = id;
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
for (const t of tabs) t.view.setVisible(t.id === id);
const t = activeTab();
if (t?.prov) pushNav(t.prov);
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
emitTabs();
}
function emitTabs() {
const t = activeTab();
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading })),
url: t?.url || "",
loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false,
canForward: wc ? wc.navigationHistory.canGoForward() : false,
});
}
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
// https:// is what the user sees regardless of how the bytes were fetched.
function refreshTabUrl(tab) {
if (!tab || tab.prov?.kind === "home") return; // home is loadFile → file://; leave t.url = ""
try {
const raw = tab.view.webContents.getURL();
if (raw && !raw.startsWith("file:")) tab.url = raw.replace(/^bns:\/\//, "https://");
} catch {}
}
function loadHome(id) {
const t = tabById(id); if (!t) return;
t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" };
t.view.webContents.loadFile("home.html");
if (id === activeId) pushNav(t.prov);
emitTabs();
}
function createTab(initial, opts = {}) {
const id = ++tabSeq;
const view = new WebContentsView(opts.settings ? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } } : {});
const wc = view.webContents;
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", prov: null, settings: !!opts.settings };
tabs.push(tab);
win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); });
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); });
wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false));
wc.on("will-navigate", (e, u) => {
try {
// Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/")
// and that host is often a BCNR-registered dotted name — without this guard,
// isBnsHost() would send us right back into navigateTab, canceling the
// fallback (blank-page bug 2026-08-02).
if (tab.internalNav) return;
const parsed = new URL(u);
// Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns
// doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync.
if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") {
e.preventDefault();
const p = parsed.searchParams;
const target = String(p.get("host") || "").toLowerCase();
const cTld = String(p.get("tld") || "").toLowerCase();
const cChoice = p.get("choice");
const cRem = p.get("remember") || "no";
const rest = p.get("resturl") || "/";
if (!target) return;
if (cChoice === "bcnr" || cChoice === "icann") {
rememberCollision(target, cTld, cChoice, cRem);
tab.collisionOverride = cChoice; // one-shot, consumed by loadBns
}
return navigateTab(id, target + rest);
}
if (parsed.protocol === "bns:") return;
if (isBnsHost(parsed.hostname)) { e.preventDefault(); navigateTab(id, parsed.hostname + parsed.pathname); }
} catch {}
});
// "You have unsaved changes" confirmation: fires when the page's beforeunload
// handler is trying to keep the user on the page (e.g. an unsent form draft,
// an editor with a dirty document). Show a native confirm; on "Leave", call
// preventDefault to override the block. Applies to both link clicks AND our
// programmatic loads (chip switcher, address-bar navigation).
wc.on("will-prevent-unload", (e) => {
const parent = BrowserWindow.getFocusedWindow() || win;
const choice = dialog.showMessageBoxSync(parent, {
type: "question",
buttons: ["Stay on page", "Leave anyway"],
defaultId: 0,
cancelId: 0,
title: "Unsaved changes",
message: "This page is asking you to stay.",
detail: "You may have unsaved changes that will be lost if you leave.",
});
if (choice === 1) e.preventDefault(); // Leave anyway -> override the beforeunload
});
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => {
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
return { action: "deny" };
});
// Right-click context menu.
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in new tab", click: () => createTab(p.linkURL) },
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
items.push(
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
{ label: "Reload", click: () => wc.reload() },
);
Menu.buildFromTemplate(items).popup();
});
layout();
if (opts.background) { view.setVisible(false); emitTabs(); }
else setActive(id);
if (opts.settings) {
tab.prov = { host: "", kind: "home" };
wc.loadFile("settings.html");
if (id === activeId) pushNav(tab.prov);
emitTabs();
} else if (initial) navigateTab(id, initial);
else loadHome(id);
return id;
}
function closeTab(id) {
const i = tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const [t] = tabs.splice(i, 1);
win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.();
if (tabs.length === 0) { createTab(); return; }
if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id);
else emitTabs();
}
function createWindow() {
win = new BrowserWindow({ width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: "#0f1420" });
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
win.contentView.addChildView(chrome);
chrome.webContents.loadFile("chrome.html");
// Floating site-info overlay (hidden until the address-bar badge is clicked).
popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } });
try { popover.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(popover);
popover.webContents.loadFile("popover.html");
popover.setVisible(false);
// Floating engine-picker overlay (custom dropdown with real favicons).
enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } });
try { enginePicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(enginePicker);
enginePicker.webContents.loadFile("engine-picker.html");
enginePicker.setVisible(false);
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } });
try { downloadsPop.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(downloadsPop);
downloadsPop.webContents.loadFile("downloads.html");
downloadsPop.setVisible(false);
chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
});
win.on("resize", layout);
layout();
}
async function navigateTab(id, input) {
const t = tabById(id); if (!t) return;
let q = String(input).trim();
if (!q) return;
setLoading(t, true); // show the loading indicator immediately (covers BNS resolution)
// Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search.
if (!looksLikeUrl(q)) q = SEARCH(q);
const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase();
const rest = raw.slice(host.length) || "/";
t.nav = (t.nav || 0) + 1;
// Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
t.bcnrOffer = null;
if (id === activeId) chrome.webContents.send("bcnr-offer", null);
// BCNR-first for every dotted host. loadBns falls through to https://<host>
// on NXDOMAIN or resolver failure, so clearnet still works.
if (isBnsHost(host)) return loadBns(t, id, host, rest, tldOf(host));
// Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
t.url = q.includes("://") ? q : "https://" + q;
await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web" };
if (id === activeId) pushNav(t.prov);
emitTabs();
}
// Load a name from BCNR into a tab. Called for every dotted host — BCNR is
// tried first; on NXDOMAIN or resolver failure we always fall through to the
// clearnet (https://<host><rest>) so the user isn't stranded when the chain
// is down or the name isn't registered.
async function loadBns(t, id, host, rest, tld) {
const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
const fallbackToWeb = async (reason) => {
t.url = "https://" + host + (rest === "/" ? "" : rest);
t.internalNav = true;
try { await t.view.webContents.loadURL(t.url); }
catch (e) { console.warn("fallback loadURL failed:", e?.message); }
finally { t.internalNav = false; }
t.prov = { host, kind: "web", note: `fallback: ${reason}`, tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
};
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
// (harmless) if absent.
let urlChoice = null;
try {
const u = new URL(rest, `bns://${host}/`);
if (u.searchParams.has("_collision")) {
urlChoice = u.searchParams.get("_collision");
u.searchParams.delete("_collision");
rest = u.pathname + (u.search ? u.search : "");
}
} catch {}
let entry;
try { entry = await resolveHost(host); }
catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
// 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}`);
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
}
ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input));
ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
ipcMain.handle("go-home", () => loadHome(activeId));
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
ipcMain.handle("reload", () => activeTab()?.view.webContents.reload());
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
ipcMain.handle("toggle-site-info", (_e, rect) => {
if (popVisible) return showPopover(false);
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showPopover(true);
});
ipcMain.handle("close-site-info", () => showPopover(false));
ipcMain.handle("popover-resize", (_e, h) => { popH = Math.max(90, Math.min(380, Math.round(h) || 210)); if (popVisible) positionPopover(); });
// ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
ipcMain.handle("collision-switch", async (_e, arg) => {
const t = activeTab(); if (!t?.prov?.host) return null;
const host = t.prov.host, tld = tldOf(host);
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
const raw = arg?.choice;
const choice = (raw === "bcdn" || raw === "bcnr") ? "bcnr"
: (raw === "icann") ? "icann"
: (t.prov.kind === "ok" ? "icann" : "bcnr"); // flip the current one
// Optional persistent memory ("Always use…" from the popover switcher).
rememberCollision(host, tld, choice, arg?.remember || "no");
const registry = registryOf(tld);
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
// collision-decision path can trigger a re-prompt on an explicit user switch.
// The user clicked the switcher; they've made their choice. Just load it.
setLoading(t, true);
t.internalNav = true;
try {
if (choice === "icann") {
t.url = "https://" + host + "/";
await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web", note: "switched to ICANN", tld, registry };
} else {
t.url = "https://" + host + "/"; // display: https://; internal fetch: bns://
await t.view.webContents.loadURL(`bns://${host}/`);
const rec = entries.get(host);
const r = rec?.entry?.records || {};
const src = r.h ? "on-chain (chain)" : r.s3 ? "Sia network" : r.ip ? "direct server" : r.u ? "redirect" : "record";
t.prov = { host, kind: "ok", source: src, category: rec?.entry?.category, records: Object.keys(r), tld, registry };
}
} catch (e) { console.warn("collision-switch load failed:", e?.message); }
finally { t.internalNav = false; setLoading(t, false); }
if (t.id === activeId) pushNav(t.prov);
emitTabs();
});
ipcMain.handle("collision-state", () => ({
policy: settings.collisionPolicy,
byName: collisions.byName,
byTld: collisions.byTld,
bcnrTlds,
}));
ipcMain.handle("collision-set-policy", (_e, p) => {
if (["bcnr-first", "icann-first", "soft"].includes(p)) { settings.collisionPolicy = p; saveSettings(); }
return settings.collisionPolicy;
});
ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }; saveCollisions(); return true; });
ipcMain.handle("search-engines", () => ({ engines: allEngines(), current: settings.searchEngine }));
ipcMain.handle("set-search-engine", (_e, id) => {
if (allEngines().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
return settings.searchEngine;
});
ipcMain.handle("add-engine", (_e, eng) => {
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// 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) }];
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// Custom engines are auto-installed and enabled.
settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])];
settings.searchEngine = id; // select the one just added
saveSettings(); emitEngines();
}
return { engines: allEngines(), current: settings.searchEngine };
});
ipcMain.handle("remove-engine", (_e, id) => {
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine };
});
// Right-click "Remove from list": drops a built-in from installedEngines AND
// enabledEngines so it goes back to the catalog. For custom engines this
// aliases to remove-engine (they don't live in installedEngines).
ipcMain.handle("remove-from-list", (_e, id) => {
if ((settings.customEngines || []).some((e) => e.id === id)) {
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
} else if (SEARCH_ENGINES[id]) {
settings.installedEngines = (settings.installedEngines || DEFAULT_ENABLED).filter((x) => x !== id);
} else {
return { engines: allEngines(), current: settings.searchEngine };
}
settings.enabledEngines = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (settings.enabledEngines.length === 0) settings.enabledEngines = ["duckduckgo"]; // never empty
if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine };
});
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
// 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]) {
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
let installed = (settings.installedEngines || DEFAULT_ENABLED).slice();
let enabled = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (on) {
if (!installed.includes(id)) installed.push(id);
enabled.push(id);
}
settings.installedEngines = installed;
settings.enabledEngines = enabled.length ? enabled : ["duckduckgo"]; // never empty
if (!enabledEnginesList().some((e) => e.id === settings.searchEngine))
settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
}
return { engines: allEngines(), current: settings.searchEngine };
});
ipcMain.handle("set-engine-order", (_e, ids) => {
if (Array.isArray(ids)) { settings.engineOrder = ids.filter((x) => typeof x === "string"); saveSettings(); emitEngines(); }
return { engines: allEngines(), current: settings.searchEngine };
});
// The custom dropdown overlay (real favicons).
ipcMain.handle("toggle-engine-picker", (_e, rect) => {
if (epVisible) return showEnginePicker(false);
if (rect) epPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showEnginePicker(true);
});
ipcMain.handle("close-engine-picker", () => showEnginePicker(false));
ipcMain.handle("ep-resize", (_e, h) => { epH = Math.max(80, Math.min(440, Math.round(h) || 320)); if (epVisible) positionEnginePicker(); });
ipcMain.handle("pick-engine", (_e, id) => {
if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
showEnginePicker(false);
});
Theseus: shield green polish, picker→Search section, toggle-vs-remove Three follow-up asks from the previous ship: 1. Shield "secure" colour bumped from #4fd1a5 (mint) to #3fb950 — the GitHub-style saturated green, matches the +N/-N diff colour the user pointed at as reference. 2. Engine-picker "Search settings…" now opens the Search section directly instead of General. New IPC channel `focus-section` fires from main after picker-open-settings, carried through settings-preload as `onFocusSection`, and the settings.html sidebar handler exposes showSection(sec) so any section can be focused programmatically. Works for both a fresh settings tab (fires on did-finish-load) and an already-open one (fires immediately). 3. Toggle no longer removes an engine from the list. Two-tier state: INSTALLED (visible in the Settings list) and ENABLED (toggled on in the toolbar dropdown). Toggling off keeps the row visible with an .off class (dimmed 55%). Right-click any row → new context menu with "Remove from list" is what actually removes an engine (built-ins go back to the catalog, customs are dropped entirely). Model changes: - New settings.installedEngines persistent array (defaults to DEFAULT_ENABLED). enabledEngines becomes a subset of installedEngines. - isInstalled(id) helper; allEngines() carries `installed: bool` alongside `enabled`. - New IPC `remove-from-list` (right-click action); exposed as removeFromList in settings-preload. - set-engine-enabled now also INSTALLS when enabling (the catalog "+ Add" flow), preserves installed state when disabling. - add-engine (custom URL) auto-adds the new id to enabledEngines too. - remove-engine (custom delete) prunes from enabledEngines as well. - Never-empty invariant kept: enabledEngines falls back to ["duckduckgo"] if everything gets removed. Settings UI: - Enabled list shows all INSTALLED engines (was: only enabled), rendered with toggle reflecting enabled state; rows carry data-builtin so the context menu picks the right remove IPC. - Catalog panel and Discover-more pane filter on !installed instead of !enabled — a toggled-off engine stays in the enabled list, not here. - Ctxmenu is a floating .ctxmenu div; closes on outside click / Escape. - .eng.off dims the row and mutes the name colour. Preview harness stubs updated to include the `installed` field on every engine + `removeFromList` and `onFocusSection` no-op stubs so _settings-preview.html renders the new UI accurately.
2026-08-06 01:41:31 +02:00
ipcMain.handle("picker-open-settings", () => {
showEnginePicker(false);
const focus = (t) => { try { t.view.webContents.send("focus-section", "search"); } catch {} };
const ex = tabs.find((t) => t.settings);
if (ex) { setActive(ex.id); focus(ex); return; }
const id = createTab(null, { settings: true });
const t = tabById(id);
if (t) t.view.webContents.once("did-finish-load", () => focus(t));
});
// ---- Downloads --------------------------------------------------------------
ipcMain.handle("downloads-get", () => downloadsPublic());
ipcMain.handle("toggle-downloads", (_e, rect) => {
if (dlVisible) return showDownloads(false);
if (rect) dlPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showDownloads(true);
});
ipcMain.handle("close-downloads", () => showDownloads(false));
ipcMain.handle("downloads-resize", (_e, h) => { dlH = Math.max(80, Math.min(480, Math.round(h) || 240)); if (dlVisible) positionDownloads(); });
ipcMain.handle("download-open", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.state === "completed" && d.savePath) shell.openPath(d.savePath).catch(() => {});
});
ipcMain.handle("download-show", (_e, id) => {
const d = downloads.find((x) => x.id === id);
if (d?.savePath) { try { shell.showItemInFolder(d.savePath); } catch {} }
});
ipcMain.handle("download-cancel", (_e, id) => {
const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} }
});
// Only clear finished downloads; a progressing one is cancelled first.
ipcMain.handle("download-clear", (_e, id) => {
const i = downloads.findIndex((x) => x.id === id);
if (i < 0) return;
if (downloads[i].state === "progressing") { const item = dlItems.get(id); if (item) { try { item.cancel(); } catch {} } }
downloads.splice(i, 1); dlItems.delete(id);
emitDownloads();
});
ipcMain.handle("downloads-clear-all", () => {
// Keep any still-progressing ones; drop everything else.
for (let i = downloads.length - 1; i >= 0; i--) if (downloads[i].state !== "progressing") downloads.splice(i, 1);
emitDownloads();
});
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain.handle("add-detected-engine", () => {
const d = activeTab()?.detected;
if (d && d.url && d.url.includes("%s")) {
const id = "custom-" + Date.now().toString(36);
settings.customEngines = [...(settings.customEngines || []), { id, name: d.name.slice(0, 40), sym: "🔍", url: d.url.slice(0, 400) }];
settings.searchEngine = id;
saveSettings(); emitEngines();
}
showEnginePicker(false);
});
ipcMain.handle("bookmarks-get", () => bookmarks);
ipcMain.handle("bookmark-add", (_e, bm) => {
if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) {
bookmarks.push({ title: bm.title || bm.url, url: bm.url });
saveBookmarks(); emitBookmarks();
}
return bookmarks;
});
ipcMain.handle("bookmark-remove", (_e, url) => {
bookmarks = bookmarks.filter((b) => b.url !== url);
saveBookmarks(); emitBookmarks();
return bookmarks;
});
ipcMain.handle("settings-get", () => settings);
ipcMain.handle("settings-set", (_e, key, val) => {
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
if (key === "webrtcMode") applyWebRTCPolicy();
if (key === "theme") applyTheme();
if (key === "backgroundThrottle") applyThrottle();
if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue",
"locationMode", "locationRegion", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
return settings;
});
ipcMain.handle("set-chrome-height", (_e, h) => {
const next = Math.max(74, Math.min(260, Math.round(h) || 84));
if (next !== CHROME_H) { CHROME_H = next; layout(); }
});
ipcMain.handle("switch-to-bcnr", () => {
const t = activeTab(); if (!t || !t.bcnrOffer) return;
const { host, rest, tld } = t.bcnrOffer;
t.bcnrOffer = null;
chrome.webContents.send("bcnr-offer", null);
return loadBns(t, activeId, host, rest || "/", tld);
});
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
if (!process.env.THESEUS_NO_AUTOSTART) {
app.whenReady().then(() => {
Menu.setApplicationMenu(null); // drop the native File/Edit/View/Help menu bar
loadSettings();
applyTheme();
loadBookmarks();
loadCollisions();
applyPermissions();
applyEmbedCookieShim();
applyAcceptLanguage();
protocol.handle("bns", serveBns);
installDownloadTracker();
createWindow();
ensureIndex().catch(() => {}); // warm the chain index so the first .bch load is fast
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on("before-quit", () => { saveSession(); stopTor(); });
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
}
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf };