2026-07-29 13:54:34 +02:00
// 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.
2026-08-02 18:50:58 +02:00
const { app , BrowserWindow , WebContentsView , ipcMain , protocol , session , Menu , clipboard , nativeTheme , shell , dialog } = require ( "electron" ) ;
2026-07-29 13:54:34 +02:00
const path = require ( "path" ) ;
const http = require ( "http" ) ;
const https = require ( "https" ) ;
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
const tls = require ( "tls" ) ;
2026-07-29 13:54:34 +02:00
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" ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Password vault — same .mjs-in-resources pattern as the resolver.
const VAULT _MOD = app . isPackaged
? path . join ( RES _DIR , "password-vault.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "password-vault.js" ) ;
let vaultLib ;
async function loadVaultLib ( ) {
if ( ! vaultLib ) vaultLib = await import ( ` file:// ${ VAULT _MOD . replace ( /\\/g , "/" ) } ` ) ;
return vaultLib ;
}
2026-08-02 11:39:00 +02:00
// Built-in engines. Users can also add their own (settings.customEngines,
2026-07-30 20:58:45 +02:00
// each { id, name, url } where the url contains "%s" for the query).
2026-07-30 22:55:52 +02:00
// 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.
2026-08-02 11:39:00 +02:00
// 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.
2026-07-30 08:16:55 +02:00
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 ) } ,
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
google : { kind : "search" , tier : "catalog" , name : "Google" , sym : "🔵" , fav : "www.google.com" , url : ( q , h = { } ) => ` https://www.google.com/search?q= ${ encodeURIComponent ( q ) } &hl= ${ h . hl || "en" } &gl= ${ h . gl || "us" } &pws=0 ` } ,
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
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 ) } ,
2026-08-02 14:43:31 +02:00
// 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 ) } ,
2026-08-02 11:39:00 +02:00
// 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 ) } ,
2026-07-30 08:16:55 +02:00
} ;
2026-07-30 22:55:52 +02:00
// 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" ] ;
2026-08-01 01:26:51 +02:00
// 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 ) ;
2026-07-30 22:55:52 +02:00
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 ) ; }
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 ) ;
}
2026-07-30 20:58:45 +02:00
function allEngines ( ) {
2026-07-30 22:55:52 +02:00
const list = Object . entries ( SEARCH _ENGINES ) . map ( ( [ id , e ] ) =>
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 ) } ) ) ;
2026-07-30 22:55:52 +02:00
for ( const c of settings . customEngines || [ ] )
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 ) } ) ;
2026-07-30 23:26:00 +02:00
// 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 ;
} ) ;
2026-07-30 20:58:45 +02:00
}
2026-07-30 22:55:52 +02:00
function enabledEnginesList ( ) { return allEngines ( ) . filter ( ( e ) => e . enabled ) ; }
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Region → 2-letter country code, for engines that accept a `gl`-style hint
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
// a consent redirect or the region-detect start page instead of results).
const REGION _TO _COUNTRY = {
europe : "de" , asia : "jp" , north _america : "us" , south _america : "br" ,
africa : "ke" , middle _east : "ae" , australia : "au" ,
} ;
function searchHints ( ) {
const loc = effLocale ( ) ; // e.g. "en-US" (or null → show real)
const region = settings . locationMode === "spoof" ? settings . locationRegion : null ;
return {
hl : ( loc || app . getLocale ( ) || "en" ) . split ( "-" ) [ 0 ] ,
gl : REGION _TO _COUNTRY [ region ] || ( loc && loc . split ( "-" ) [ 1 ] ? . toLowerCase ( ) ) || "us" ,
} ;
}
2026-07-30 20:58:45 +02:00
function engineUrl ( id , q ) {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const h = searchHints ( ) ;
if ( SEARCH _ENGINES [ id ] ) return SEARCH _ENGINES [ id ] . url ( q , h ) ;
2026-07-30 20:58:45 +02:00
const c = ( settings . customEngines || [ ] ) . find ( ( e ) => e . id === id ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return c ? c . url . replace ( /%s/g , encodeURIComponent ( q ) ) : SEARCH _ENGINES . duckduckgo . url ( q , h ) ;
2026-07-30 20:58:45 +02:00
}
const SEARCH = ( q ) => engineUrl ( settings . searchEngine , q ) ;
2026-07-29 13:54:34 +02:00
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st" ;
// ---- BNS name detection (multi-TLD) --------------------------------------
2026-07-30 22:55:52 +02:00
// 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.
2026-07-29 13:54:34 +02:00
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 ) ;
} ;
2026-07-30 22:55:52 +02:00
// 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
2026-07-29 13:54:34 +02:00
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 = {
2026-07-30 22:00:01 +02:00
webrtcMode : "public_only" , // WebRTC IP policy: default | public_only | public_private | disable_udp
2026-07-29 13:54:34 +02:00
blockCamera : true , // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone : true , // deny microphone by default (also hides mic labels)
2026-07-30 23:11:28 +02:00
hideMediaDevices : true , // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
2026-07-29 13:54:34 +02:00
restoreSession : true , // reopen last session's tabs on launch
backgroundThrottle : true , // throttle inactive tabs / the window when unfocused
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit : true , // drop cookies + logins + saved-form data
clearCacheOnQuit : true , // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit : true , // drop navigation history + saved tabs
clearStorageOnQuit : true , // drop localStorage / IndexedDB / service workers / cache API
2026-07-30 19:29:32 +02:00
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode : "show" , timezoneValue : "Europe/Berlin" , // IANA zone for manual
2026-07-30 23:11:28 +02:00
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
2026-07-30 20:58:45 +02:00
searchEngine : "duckduckgo" , // default search engine (built-in id or a custom id)
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)
2026-07-30 23:26:00 +02:00
engineOrder : [ ] , // user-defined display order of engine ids (empty = natural)
2026-07-30 20:58:45 +02:00
customEngines : [ ] , // user-added: [{ id, name, url-with-%s }]
2026-07-30 22:00:01 +02:00
theme : "dark" , // dark | light | system — drives prefers-color-scheme in all views
2026-08-01 01:26:51 +02:00
// 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" ,
2026-07-29 13:54:34 +02:00
} ;
2026-07-30 22:00:01 +02:00
// 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 { }
}
2026-07-30 19:29:32 +02:00
// 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 } ;
2026-07-29 13:54:34 +02:00
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 ) ; }
}
2026-07-30 08:16:55 +02:00
// ---- 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 { } }
2026-08-01 01:26:51 +02:00
// ---- 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 { }
}
2026-08-02 14:43:31 +02:00
// (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().)
2026-08-01 01:26:51 +02:00
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 ( ) ;
}
2026-07-30 22:55:52 +02:00
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 { }
}
2026-07-30 22:00:01 +02:00
// 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)
} ;
2026-07-29 13:54:34 +02:00
function webrtcPolicy ( ) {
2026-07-30 22:00:01 +02:00
if ( torState === "on" ) return "disable_non_proxied_udp" ;
return WEBRTC _POLICIES [ settings . webrtcMode ] || "default_public_interface_only" ;
2026-07-29 13:54:34 +02:00
}
2026-07-30 19:29:32 +02:00
// ---- anti-fingerprinting: timezone + language + location ----
// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value).
2026-07-29 13:54:34 +02:00
function effTimezone ( ) {
2026-07-30 19:29:32 +02:00
switch ( settings . timezoneMode ) {
case "hide" : return "UTC" ;
case "spoof" : return SPOOF . tz ;
case "manual" : return settings . timezoneValue || "UTC" ;
default : return null ;
}
2026-07-29 13:54:34 +02:00
}
function effLocale ( ) {
2026-07-30 19:29:32 +02:00
switch ( settings . languageMode ) {
case "hide" : return "en-US" ;
2026-07-30 23:11:28 +02:00
case "spoof" : return settings . languageSpoof || SPOOF . lang ; // chosen from the top-languages list
2026-07-30 19:29:32 +02:00
case "manual" : return settings . languageValue || "en-US" ;
default : return null ;
}
}
2026-07-30 23:11:28 +02:00
// 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
} ;
2026-07-30 19:29:32 +02:00
// Geolocation: null = show (real, allowed); "deny" = hide (blocked);
2026-07-30 23:11:28 +02:00
// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page.
2026-07-30 19:29:32 +02:00
function effLocation ( ) {
const m = settings . locationMode ;
if ( m === "hide" ) return "deny" ;
2026-07-30 23:11:28 +02:00
if ( m === "spoof" ) { const r = REGIONS [ settings . locationRegion ] || REGIONS . europe ; return { lat : r . lat , lon : r . lon } ; }
2026-07-30 19:29:32 +02:00
if ( m === "manual" ) return { lat : Number ( settings . locationLat ) || 0 , lon : Number ( settings . locationLon ) || 0 } ;
return null ; // show
2026-07-29 13:54:34 +02:00
}
// 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 ;
}
2026-07-30 19:29:32 +02:00
// Build one injected script covering navigator.language(s) and geolocation.
let src = "" ;
2026-07-29 13:54:34 +02:00
if ( loc ) {
const langs = JSON . stringify ( [ loc , loc . split ( "-" ) [ 0 ] ] ) ;
2026-07-30 19:29:32 +02:00
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){} ` ;
}
2026-07-30 23:11:28 +02:00
// 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){} ` ;
}
2026-07-30 19:29:32 +02:00
if ( src ) {
2026-07-29 13:54:34 +02:00
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 ) ; }
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
// cache, localStorage, IndexedDB, service workers, cache API by default.
// This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
async function clearBrowsingData ( { cookies = false , cache = false , storage = false } = { } ) {
const ses = session . defaultSession ;
if ( cache ) { try { await ses . clearCache ( ) ; } catch ( e ) { console . warn ( "clearCache:" , e . message ) ; } }
const storages = [ ] ;
if ( cookies ) storages . push ( "cookies" ) ;
if ( storage ) storages . push ( "localstorage" , "indexdb" , "serviceworkers" , "cachestorage" , "shadercache" ) ;
if ( storages . length ) {
try { await ses . clearStorageData ( { storages } ) ; }
catch ( e ) { console . warn ( "clearStorageData:" , e . message ) ; }
}
// navigation history lives in each webContents; drop it too when history-clear was asked.
// (called separately by the before-quit hook, since history-clear also deletes session.json)
}
async function clearHistoryNow ( ) {
for ( const t of tabs ) {
try { t . view . webContents . navigationHistory . clear ( ) ; } catch { }
}
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
}
2026-07-29 13:54:34 +02:00
// 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 || [ ] ) ) ;
2026-07-30 19:29:32 +02:00
if ( permission === "geolocation" ) return callback ( effLocation ( ) !== "deny" ) ; // allow unless "hide"
2026-07-29 13:54:34 +02:00
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 ) ;
}
2026-07-30 19:29:32 +02:00
if ( permission === "geolocation" ) return effLocation ( ) !== "deny" ;
2026-07-29 13:54:34 +02:00
if ( SENSITIVE _DEVICE . has ( permission ) ) return false ;
return true ;
} ) ;
}
2026-08-05 18:38:38 +02:00
// 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 } ) ;
} ) ;
}
2026-07-29 13:54:34 +02:00
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 ( ) ) } ;
}
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
// isn't in ICANN DNS → "fetch failed".
// 2. It validates TLS against the public CA store, but BNS certs are signed
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
// record's SHA-256 fingerprint, which we pin against here. See
// Argus/src/lib/ca.js for the underlying trust model, and the parallel
// implementation in Argus/src/gateway/public-gateway.mjs — keep both in
// step. This code path also runs through Tor when Tor is on.
function certFp ( cert ) {
const fp = cert && cert . fingerprint256 ;
return fp ? fp . toLowerCase ( ) . replace ( /:/g , "" ) : "" ;
}
async function pinnedHttpsGet ( ip , port , servername , reqPath , expectedFp ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port , servername , method : "GET" , path : reqPath ,
headers : { host : servername , "user-agent" : "theseus/1" } ,
} ;
opts [ "rejectUnauthorized" ] = false ; // fingerprint pin below is the gate.
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = https . request ( opts , ( res ) => {
const gotFp = certFp ( res . socket . getPeerCertificate ( false ) ) ;
if ( gotFp !== expectedFp ) {
res . socket . destroy ( ) ;
return reject ( new Error ( ` tls fingerprint mismatch for ${ servername } : got ${ gotFp } , expected ${ expectedFp } ` ) ) ;
}
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . setTimeout ( 15000 , ( ) => { req . destroy ( new Error ( "tls request timeout" ) ) ; } ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
async function httpGetByIp ( ip , reqPath , hostHeader ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port : 80 , method : "GET" , path : reqPath ,
headers : { host : hostHeader , "user-agent" : "theseus/1" } ,
} ;
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = http . request ( opts , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ; req . end ( ) ;
} ) ;
}
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin.
async function ipRequest ( ip , reqPath , hostHeader , tlsFingerprint ) {
if ( tlsFingerprint ) return await pinnedHttpsGet ( ip , 443 , hostHeader , reqPath , String ( tlsFingerprint ) . toLowerCase ( ) ) ;
return await httpGetByIp ( ip , reqPath , hostHeader ) ;
}
2026-07-30 22:55:52 +02:00
// 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 ; }
}
2026-07-29 13:54:34 +02:00
// ---- 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 ( ) ;
2026-07-30 19:29:32 +02:00
// 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 ( ) ;
2026-07-29 13:54:34 +02:00
if ( ! electrumPool ) await initElectrumPool ( ) ;
2026-07-30 19:29:32 +02:00
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 } )
2026-08-02 11:39:00 +02:00
. then ( ( idx ) => { sharedIndex = idx ; indexBuiltAt = Date . now ( ) ; refreshBcnrTlds ( idx ) ; return idx ; } )
2026-07-30 19:29:32 +02:00
. 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 ; }
2026-07-29 13:54:34 +02:00
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 ) || "/" ;
2026-08-02 14:43:31 +02:00
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)
2026-08-02 14:43:31 +02:00
2026-07-29 13:54:34 +02:00
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 ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
// whole namespace via Host routing; `s3` semantics are exact-key per name.
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
// apex (host === entry.name), current priority stands. See public-gateway.mjs
// for the full argument; keep this in step with that file.
const isSubdomain = host !== rec . entry . name ;
const serveIp = async ( ) => {
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
// See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls`
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS).
const up = await ipRequest ( r . ip , reqPath + url . search , host , r . tls ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return new Response ( up . buffer , { status : up . status , headers : { "content-type" : up . contentType || guessType ( reqPath ) } } ) ;
} ;
2026-07-29 13:54:34 +02:00
try {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
if ( isSubdomain && r . ip ) return await serveIp ( ) ;
2026-07-29 13:54:34 +02:00
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 } } ) ;
}
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
if ( r . ip ) return await serveIp ( ) ;
2026-07-29 13:54:34 +02:00
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 ----
2026-07-30 19:29:32 +02:00
let win , chrome ;
2026-07-30 08:16:55 +02:00
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 } ;
2026-07-30 20:58:45 +02:00
const POP _W = 360 ; let popH = 210 ; // popH is updated to fit the popover's content
2026-07-30 22:55:52 +02:00
// 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 ;
2026-08-02 11:39:00 +02:00
// 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
2026-07-29 13:54:34 +02:00
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.
2026-08-02 11:39:00 +02:00
// 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 ) } ;
}
2026-07-29 13:54:34 +02:00
function pushNav ( prov ) {
2026-08-02 11:39:00 +02:00
const p = decorate ( prov ) ;
chrome ? . webContents . send ( "nav" , p ) ;
if ( popVisible ) popover ? . webContents . send ( "site-info" , p ) ;
2026-07-29 13:54:34 +02:00
}
function layout ( ) {
if ( ! win ) return ;
const { width , height } = win . getContentBounds ( ) ;
chrome . setBounds ( { x : 0 , y : 0 , width , height : CHROME _H } ) ;
2026-07-30 19:29:32 +02:00
const bodyH = Math . max ( 0 , height - CHROME _H ) ;
2026-07-29 13:54:34 +02:00
for ( const t of tabs ) t . view . setBounds ( { x : 0 , y : CHROME _H , width , height : bodyH } ) ;
2026-07-30 08:16:55 +02:00
positionPopover ( ) ;
2026-07-30 22:55:52 +02:00
positionEnginePicker ( ) ;
2026-08-02 11:39:00 +02:00
positionDownloads ( ) ;
2026-07-30 08:16:55 +02:00
}
function positionPopover ( ) {
if ( ! popover ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( popPos . x , width - POP _W - 6 ) ) ;
2026-07-30 20:58:45 +02:00
popover . setBounds ( { x , y : popPos . y , width : POP _W , height : popH } ) ;
2026-07-30 08:16:55 +02:00
}
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 ;
2026-08-02 11:39:00 +02:00
popover . webContents . send ( "site-info" , decorate ( activeTab ( ) ? . prov ) || { kind : "home" } ) ;
2026-07-30 08:16:55 +02:00
} else { popover . setVisible ( false ) ; popVisible = false ; }
2026-07-29 13:54:34 +02:00
}
2026-07-30 22:55:52 +02:00
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 ; }
}
2026-08-02 11:39:00 +02:00
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 ( ) ;
} ) ;
} ) ;
}
2026-07-29 13:54:34 +02:00
function setActive ( id ) {
activeId = id ;
2026-07-30 08:16:55 +02:00
if ( popVisible ) showPopover ( false ) ; // don't carry a stale popover across tabs
2026-07-30 22:55:52 +02:00
if ( epVisible ) showEnginePicker ( false ) ;
2026-07-29 13:54:34 +02:00
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" , {
2026-07-30 19:29:32 +02:00
tabs : tabs . map ( ( x ) => ( { id : x . id , title : x . title || "New Tab" , active : x . id === activeId , loading : ! ! x . loading } ) ) ,
2026-07-29 13:54:34 +02:00
url : t ? . url || "" ,
2026-07-30 19:29:32 +02:00
loading : ! ! t ? . loading ,
2026-07-29 13:54:34 +02:00
canBack : wc ? wc . navigationHistory . canGoBack ( ) : false ,
canForward : wc ? wc . navigationHistory . canGoForward ( ) : false ,
} ) ;
}
2026-07-30 19:29:32 +02:00
function setLoading ( tab , on ) { if ( tab && tab . loading !== on ) { tab . loading = on ; emitTabs ( ) ; } }
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 { }
}
2026-07-29 13:54:34 +02:00
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 ( ) ; } ) ;
2026-08-06 01:41:31 +02:00
wc . on ( "did-navigate" , ( ) => { refreshTabUrl ( tab ) ; emitTabs ( ) ; } ) ;
wc . on ( "did-navigate-in-page" , ( ) => { refreshTabUrl ( tab ) ; emitTabs ( ) ; } ) ;
2026-08-01 01:37:18 +02:00
wc . on ( "did-start-loading" , ( ) => setLoading ( tab , true ) ) ;
2026-07-30 19:29:32 +02:00
wc . on ( "did-stop-loading" , ( ) => setLoading ( tab , false ) ) ;
2026-07-29 13:54:34 +02:00
wc . on ( "will-navigate" , ( e , u ) => {
try {
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// 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 ;
2026-07-29 13:54:34 +02:00
const parsed = new URL ( u ) ;
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// 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 ) ;
}
2026-07-29 13:54:34 +02:00
if ( parsed . protocol === "bns:" ) return ;
if ( isBnsHost ( parsed . hostname ) ) { e . preventDefault ( ) ; navigateTab ( id , parsed . hostname + parsed . pathname ) ; }
} catch { }
} ) ;
2026-08-02 18:50:58 +02:00
// "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
} ) ;
2026-07-29 13:54:34 +02:00
// 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" ) ;
2026-07-30 08:16:55 +02:00
// 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 ) ;
2026-07-30 22:55:52 +02:00
// 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 ) ;
2026-08-02 11:39:00 +02:00
// 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 ) ;
2026-07-29 13:54:34 +02:00
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 ;
2026-07-30 19:29:32 +02:00
setLoading ( t , true ) ; // show the loading indicator immediately (covers BNS resolution)
2026-07-29 13:54:34 +02:00
// 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 ;
2026-07-30 22:55:52 +02:00
// Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
2026-07-29 13:54:34 +02:00
t . bcnrOffer = null ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , null ) ;
2026-07-30 22:55:52 +02:00
// 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 ) ) ;
2026-07-29 13:54:34 +02:00
2026-07-30 22:55:52 +02:00
// Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
2026-07-29 13:54:34 +02:00
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 ( ) ;
}
2026-07-30 22:55:52 +02:00
// 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.
2026-07-29 13:54:34 +02:00
async function loadBns ( t , id , host , rest , tld ) {
const registry = registryOf ( tld ) ;
if ( id === activeId ) pushNav ( { host , kind : "resolving" , tld , registry } ) ;
2026-07-30 22:09:26 +02:00
const fallbackToWeb = async ( reason ) => {
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
t . internalNav = true ;
try { await t . view . webContents . loadURL ( t . url ) ; }
catch ( e ) { console . warn ( "fallback loadURL failed:" , e ? . message ) ; }
finally { t . internalNav = false ; }
2026-07-30 22:09:26 +02:00
t . prov = { host , kind : "web" , note : ` fallback: ${ reason } ` , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
} ;
2026-08-02 14:43:31 +02:00
// 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 { }
2026-07-29 13:54:34 +02:00
let entry ;
try { entry = await resolveHost ( host ) ; }
2026-07-30 22:55:52 +02:00
catch { setLoading ( t , false ) ; return fallbackToWeb ( "BCNR unreachable" ) ; }
2026-08-02 19:26:47 +02:00
// 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 ) ;
2026-07-30 22:55:52 +02:00
if ( ! entry ) { setLoading ( t , false ) ; return fallbackToWeb ( "no BCNR record" ) ; }
2026-08-02 11:39:00 +02:00
// ---- 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 ) ) {
2026-08-02 14:43:31 +02:00
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
const transient = t . collisionOverride ; t . collisionOverride = null ;
2026-08-02 11:39:00 +02:00
const policy = settings . collisionPolicy || "bcnr-first" ;
2026-08-02 14:43:31 +02:00
// Precedence: urlChoice (one-shot from collision-choose) > transient (chip
// switcher) > persistent per-name/per-TLD > global policy.
let choice = urlChoice || transient || overrideFor ( host , tld ) ;
2026-08-02 11:39:00 +02:00
if ( ! choice ) {
if ( policy === "icann-first" ) choice = "icann" ;
else if ( policy === "soft" ) {
2026-08-02 14:43:31 +02:00
// 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 ;
2026-08-02 11:39:00 +02:00
} else choice = "bcnr" ; // bcnr-first — the default
}
if ( choice === "icann" ) { setLoading ( t , false ) ; return fallbackToWeb ( "collision → ICANN" ) ; }
}
2026-07-29 13:54:34 +02:00
await t . view . webContents . loadURL ( ` bns:// ${ host } ${ rest } ` ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
// routes via the parent's server, not via Sia. See serveBns for the rule.
const _isSub = host !== entry . name ;
const src = ( _isSub && entry . records . ip ) ? "direct server"
: entry . records . h ? "on-chain (chain)"
: entry . records . s3 ? "Sia network"
: entry . records . ip ? "direct server"
: entry . records . u ? "redirect"
: "record" ;
2026-07-29 13:54:34 +02:00
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 ( ) ) ;
2026-07-30 19:29:32 +02:00
ipcMain . handle ( "stop" , ( ) => { const t = activeTab ( ) ; try { t ? . view . webContents . stop ( ) ; } catch { } setLoading ( t , false ) ; } ) ;
2026-07-29 13:54:34 +02:00
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 } ) ; } ) ;
2026-07-30 08:16:55 +02:00
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 ) ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "popover-resize" , ( _e , h ) => { popH = Math . max ( 90 , Math . min ( 380 , Math . round ( h ) || 210 ) ) ; if ( popVisible ) positionPopover ( ) ; } ) ;
2026-08-02 11:39:00 +02:00
// ---- 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 ) ;
2026-08-02 14:43:31 +02:00
// 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).
2026-08-02 11:39:00 +02:00
rememberCollision ( host , tld , choice , arg ? . remember || "no" ) ;
2026-08-02 18:40:38 +02:00
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 {
2026-08-02 19:26:47 +02:00
t . url = "https://" + host + "/" ; // display: https://; internal fetch: bns://
2026-08-02 18:40:38 +02:00
await t . view . webContents . loadURL ( ` bns:// ${ host } / ` ) ;
const rec = entries . get ( host ) ;
const r = rec ? . entry ? . records || { } ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
const _isSub = rec ? . entry ? . name && host !== rec . entry . name ;
const src = ( _isSub && r . ip ) ? "direct server"
: r . h ? "on-chain (chain)"
: r . s3 ? "Sia network"
: r . ip ? "direct server"
: r . u ? "redirect"
: "record" ;
2026-08-02 18:40:38 +02:00
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 ( ) ;
2026-08-02 11:39:00 +02:00
} ) ;
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 ; } ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
// held in this main-process closure only — never sent to a renderer except
// in the explicit response to password-get(id). Cleared on quit alongside
// the other storage clears (see before-quit hook).
const vaultFile = ( ) => path . join ( app . getPath ( "userData" ) , "passwords.vault" ) ;
let vaultState = null ; // { key, purposeRoot, entries, _salt, _iters }
const vaultOk = ( ) => ( { ok : true } ) ;
const vaultErr = ( m ) => ( { ok : false , err : String ( m ) } ) ;
ipcMain . handle ( "password-status" , ( ) => ( {
setup : fs . existsSync ( vaultFile ( ) ) ,
unlocked : ! ! vaultState ,
} ) ) ;
ipcMain . handle ( "password-setup" , async ( _e , { masterPassword , seedSource } ) => {
try {
if ( ! masterPassword || String ( masterPassword ) . length < 4 ) return vaultErr ( "master password too short" ) ;
if ( fs . existsSync ( vaultFile ( ) ) ) return vaultErr ( "vault already exists" ) ;
const v = await loadVaultLib ( ) ;
let purposeRootHex ;
if ( seedSource && seedSource . kind === "mnemonic" && seedSource . mnemonic ) {
const seed = await v . bip39ToSeed ( String ( seedSource . mnemonic ) ) ;
const root = await v . seedToPurposeRoot ( seed , "passwords/0" ) ;
purposeRootHex = v . bytesToHex ( root ) ;
} else {
// Independent random seed — 32 bytes of purposeRoot directly.
const root = require ( "node:crypto" ) . webcrypto . getRandomValues ( new Uint8Array ( 32 ) ) ;
purposeRootHex = v . bytesToHex ( root ) ;
}
vaultState = await v . createVault ( vaultFile ( ) , masterPassword , purposeRootHex ) ;
return vaultOk ( ) ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-unlock" , async ( _e , masterPassword ) => {
try {
if ( ! fs . existsSync ( vaultFile ( ) ) ) return vaultErr ( "no vault" ) ;
const v = await loadVaultLib ( ) ;
vaultState = await v . unlockVault ( vaultFile ( ) , masterPassword ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-lock" , ( ) => { vaultState = null ; return true ; } ) ;
ipcMain . handle ( "password-list" , async ( ) => {
if ( ! vaultState ) return { ok : false , err : "locked" } ;
const v = await loadVaultLib ( ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} ) ;
ipcMain . handle ( "password-get" , async ( _e , id ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const password = await v . resolvePassword ( vaultState , id ) ;
return { ok : true , password } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-add" , async ( _e , spec ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const entry = v . newEntry ( spec || { } ) ;
vaultState . entries . push ( entry ) ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , id : entry . id , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-update" , async ( _e , id , patch ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const e = vaultState . entries . find ( ( x ) => x . id === id ) ;
if ( ! e ) return vaultErr ( "no such entry" ) ;
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
for ( const k of [ "domain" , "username" , "literal" , "generated" ] ) if ( patch && k in patch ) e [ k ] = patch [ k ] ;
// Switching between literal and generated: drop the other field.
if ( patch && "literal" in patch ) delete e . generated ;
if ( patch && "generated" in patch ) delete e . literal ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-remove" , async ( _e , id ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
vaultState . entries = vaultState . entries . filter ( ( x ) => x . id !== id ) ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-generate" , async ( _e , { domain , username = "" , version = 1 , rules } = { } ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const password = await v . derivePassword ( vaultState . purposeRoot , { domain , username , version , rules } ) ;
return { ok : true , password } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "clear-browsing-data" , async ( _e , opts ) => {
const o = opts || { } ;
await clearBrowsingData ( { cookies : ! ! o . cookies , cache : ! ! o . cache , storage : ! ! o . storage } ) ;
if ( o . history ) await clearHistoryNow ( ) ;
return true ;
} ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "search-engines" , ( ) => ( { engines : allEngines ( ) , current : settings . searchEngine } ) ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "set-search-engine" , ( _e , id ) => {
2026-07-30 20:58:45 +02:00
if ( allEngines ( ) . some ( ( e ) => e . id === id ) ) { settings . searchEngine = id ; saveSettings ( ) ; emitEngines ( ) ; }
2026-07-30 08:16:55 +02:00
return settings . searchEngine ;
} ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "add-engine" , ( _e , eng ) => {
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.
2026-07-30 20:58:45 +02:00
if ( eng && eng . name && eng . url && String ( eng . url ) . includes ( "%s" ) ) {
const id = "custom-" + Date . now ( ) . toString ( 36 ) ;
settings . customEngines = [ ... ( settings . customEngines || [ ] ) ,
2026-07-30 22:09:26 +02:00
{ id , name : String ( eng . name ) . slice ( 0 , 40 ) , sym : String ( eng . sym || "🔍" ) . slice ( 0 , 4 ) , url : String ( eng . url ) . slice ( 0 , 400 ) } ] ;
2026-08-06 01:41:31 +02:00
// Custom engines are auto-installed and enabled.
settings . enabledEngines = [ ... new Set ( [ ... ( settings . enabledEngines || DEFAULT _ENABLED ) , id ] ) ] ;
2026-07-30 20:58:45 +02:00
settings . searchEngine = id ; // select the one just added
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
ipcMain . handle ( "remove-engine" , ( _e , id ) => {
2026-08-06 01:41:31 +02:00
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
2026-07-30 20:58:45 +02:00
settings . customEngines = ( settings . customEngines || [ ] ) . filter ( ( e ) => e . id !== id ) ;
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
2026-07-30 22:55:52 +02:00
if ( settings . searchEngine === id ) settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
2026-07-30 20:58:45 +02:00
saveSettings ( ) ; emitEngines ( ) ;
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
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.
2026-07-30 22:55:52 +02:00
ipcMain . handle ( "set-engine-enabled" , ( _e , id , on ) => {
if ( SEARCH _ENGINES [ id ] ) {
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
2026-07-30 22:55:52 +02:00
if ( ! enabledEnginesList ( ) . some ( ( e ) => e . id === settings . searchEngine ) )
settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
2026-07-30 23:26:00 +02:00
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 } ;
} ) ;
2026-07-30 22:55:52 +02:00
// 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 ) ;
} ) ;
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 ) ) ;
} ) ;
2026-08-02 11:39:00 +02:00
// ---- 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 ( ) ;
} ) ;
2026-07-30 22:55:52 +02:00
// 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 ) ;
} ) ;
2026-07-30 08:16:55 +02:00
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 ;
} ) ;
2026-07-29 13:54:34 +02:00
ipcMain . handle ( "settings-get" , ( ) => settings ) ;
ipcMain . handle ( "settings-set" , ( _e , key , val ) => {
if ( key in SETTINGS _DEFAULTS ) { settings [ key ] = val ; saveSettings ( ) ; }
2026-07-30 22:00:01 +02:00
if ( key === "webrtcMode" ) applyWebRTCPolicy ( ) ;
if ( key === "theme" ) applyTheme ( ) ;
2026-07-29 13:54:34 +02:00
if ( key === "backgroundThrottle" ) applyThrottle ( ) ;
2026-07-30 23:11:28 +02:00
if ( [ "timezoneMode" , "timezoneValue" , "languageMode" , "languageSpoof" , "languageValue" ,
"locationMode" , "locationRegion" , "locationLat" , "locationLon" , "hideMediaDevices" ] . includes ( key ) ) { applyFingerprintAll ( ) ; applyAcceptLanguage ( ) ; }
2026-07-29 13:54:34 +02:00
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 ( ( ) => {
2026-07-30 08:16:55 +02:00
Menu . setApplicationMenu ( null ) ; // drop the native File/Edit/View/Help menu bar
2026-07-29 13:54:34 +02:00
loadSettings ( ) ;
2026-07-30 22:00:01 +02:00
applyTheme ( ) ;
2026-07-30 08:16:55 +02:00
loadBookmarks ( ) ;
2026-08-02 11:39:00 +02:00
loadCollisions ( ) ;
2026-07-29 13:54:34 +02:00
applyPermissions ( ) ;
2026-08-05 18:38:38 +02:00
applyEmbedCookieShim ( ) ;
2026-07-29 13:54:34 +02:00
applyAcceptLanguage ( ) ;
protocol . handle ( "bns" , serveBns ) ;
2026-08-02 11:39:00 +02:00
installDownloadTracker ( ) ;
2026-07-29 13:54:34 +02:00
createWindow ( ) ;
2026-07-30 19:29:32 +02:00
ensureIndex ( ) . catch ( ( ) => { } ) ; // warm the chain index so the first .bch load is fast
2026-07-29 13:54:34 +02:00
app . on ( "activate" , ( ) => { if ( BrowserWindow . getAllWindows ( ) . length === 0 ) createWindow ( ) ; } ) ;
} ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
app . on ( "before-quit" , async ( e ) => {
// Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank.
saveSession ( ) ;
stopTor ( ) ;
vaultState = null ; // drop the in-memory vault key + purposeRoot
try {
await clearBrowsingData ( {
cookies : settings . clearCookiesOnQuit ,
cache : settings . clearCacheOnQuit ,
storage : settings . clearStorageOnQuit ,
} ) ;
if ( settings . clearHistoryOnQuit ) {
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
}
} catch ( err ) { console . error ( "before-quit clear failed:" , err ? . message ) ; }
} ) ;
2026-07-29 13:54:34 +02:00
app . on ( "window-all-closed" , ( ) => { stopTor ( ) ; if ( process . platform !== "darwin" ) app . quit ( ) ; } ) ;
}
module . exports = { serveBns , resolveHost , isBnsHost , nativeTld , dualTld , registryOf } ;