2026-09-27 11:24:36 +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.
const { app , BrowserWindow , WebContentsView , ipcMain , protocol , session , Menu , clipboard , nativeTheme , shell , dialog } = require ( "electron" ) ;
const path = require ( "path" ) ;
const url = require ( "url" ) ;
const http = require ( "http" ) ;
const https = require ( "https" ) ;
const tls = require ( "tls" ) ;
const { spawn } = require ( "child_process" ) ;
const fs = require ( "fs" ) ;
const WebSocket = require ( "ws" ) ;
const webapps = require ( "./webapps" ) ;
// A browser has no business dying because its stdout went away. Launched from
// a shell — a dev run, a test harness — Theseus inherits that shell's pipe;
// when the shell exits the pipe breaks, and the next console.log raises EPIPE
// in the main process, which Electron reports as a fatal uncaught exception.
// These streams only ever carry diagnostics, and by then nobody is reading
// them, so a write that cannot land is not an error worth stopping for.
for ( const stream of [ process . stdout , process . stderr ] ) stream . on ( "error" , ( ) => { } ) ;
// Node's ws turns "closed while still connecting" into a crash: close() on a
// CONNECTING socket aborts the handshake and emits an error on the next tick,
// and an error event with no listener is an uncaught exception. Browser
// WebSockets shrug the same thing off, so libraries written for both do it —
// nostr-tools drops its onerror handler and then closes the socket, which is
// what the WizardConnect relay teardown in Aegis runs on every wallet
// disconnect. Seen 2026-09-27 as the "JavaScript error occurred in the main
// process" dialog. Every consumer in this process (messenger, add-ons,
// WizardConnect via isomorphic-ws) shares this one ws module, so guard it here.
{
const close = WebSocket . prototype . close ;
WebSocket . prototype . close = function ( ... args ) {
if ( this . readyState === WebSocket . CONNECTING && this . listenerCount ( "error" ) === 0 ) this . once ( "error" , ( ) => { } ) ;
return close . apply ( this , args ) ;
} ;
}
// Anything else that escapes to the top of the main process: Electron would
// show a modal "JavaScript error occurred in the main process" and carry on.
// Carry on without the modal, and keep the report where it can be found.
process . on ( "uncaughtException" , ( err ) => {
const line = ` [ ${ new Date ( ) . toISOString ( ) } ] uncaught: ${ ( err && err . stack ) || err } \n ` ;
try { console . error ( line ) ; } catch { }
try {
const f = path . join ( app . getPath ( "userData" ) , "main-errors.log" ) ;
try { if ( fs . statSync ( f ) . size > 512 * 1024 ) fs . truncateSync ( f , 0 ) ; } catch { }
fs . appendFileSync ( f , line ) ;
} catch { }
} ) ;
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES _DIR = app . isPackaged ? process . resourcesPath : _ _dirname ;
// THESEUS_USER_DATA points a dev run at a throwaway profile so it never
// touches (or races) the real install's settings, vault and add-ons.
if ( process . env . THESEUS _USER _DATA ) {
try { app . setPath ( "userData" , path . resolve ( process . env . THESEUS _USER _DATA ) ) ; } catch ( e ) { console . warn ( "userData override failed:" , e ? . message ) ; }
} else {
// The profile lives at <appData>\Theseus. Electron's default was the
// product name ("Theseus Navigator"); a profile from before this change is
// moved once, on the first start that finds it: a rename when possible
// (same volume — instant, nothing copied), a copy when the rename is
// refused (another process still holds the folder, or a junction to
// another volume), in which case the old folder is left as it was.
try { app . setPath ( "userData" , relocateProfile ( app . getPath ( "appData" ) ) ) ; }
catch ( e ) { console . warn ( "profile relocation failed:" , e ? . message ) ; }
}
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
// Native boxes (and anything else that reads app.name) say "Theseus Navigator",
// not the package name. After the userData paths above: the default profile
// location derives from the name, and those are set explicitly already.
try { app . setName ( "Theseus Navigator" ) ; } catch { }
2026-09-27 11:24:36 +02:00
function relocateProfile ( appData ) {
const newDir = path . join ( appData , "Theseus" ) ;
// Two possible previous locations: "Theseus Navigator" (what the code
// originally checked for, based on the assumed productName), and
// "theseus-navigator" (what Electron ACTUALLY used because package.json
// has no top-level productName, so app.getName() falls back to the "name"
// field). Whichever exists is the profile the user has been running
// against; migrate it in place so 0.3.51 does not silently create a fresh
// Theseus\ next to a still-populated old dir the user cannot see.
if ( fs . existsSync ( newDir ) ) return newDir ;
for ( const candidate of [ "Theseus Navigator" , "theseus-navigator" ] ) {
const oldDir = path . join ( appData , candidate ) ;
if ( ! fs . existsSync ( oldDir ) ) continue ;
try { fs . renameSync ( oldDir , newDir ) ; return newDir ; }
catch {
try { fs . cpSync ( oldDir , newDir , { recursive : true } ) ; return newDir ; }
catch ( e ) { console . warn ( ` [profile] relocate ${ candidate } failed: ` , e ? . message ) ; }
}
}
return newDir ;
}
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app . isPackaged
? path . join ( RES _DIR , "resolver-web.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "resolver-web.js" ) ;
// Password vault — same .mjs-in-resources pattern as the resolver.
const VAULT _MOD = app . isPackaged
? path . join ( RES _DIR , "password-vault.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "password-vault.js" ) ;
let vaultLib ;
async function loadVaultLib ( ) {
if ( ! vaultLib ) vaultLib = await import ( ` file:// ${ VAULT _MOD . replace ( /\\/g , "/" ) } ` ) ;
return vaultLib ;
}
// Hermes messaging module — same .mjs-in-resources / .js-in-dev pattern.
const HERMES _MOD = app . isPackaged
? path . join ( RES _DIR , "lib" , "hermes.mjs" )
: path . join ( _ _dirname , "lib" , "hermes.js" ) ;
let hermesLib ;
async function loadHermesLib ( ) {
if ( ! hermesLib ) hermesLib = await import ( ` file:// ${ HERMES _MOD . replace ( /\\/g , "/" ) } ` ) ;
return hermesLib ;
}
// Built-in engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own).
// fav = the domain to load a real favicon from; sym = emoji fallback.
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
// tier = "catalog" (curated first-class options shown in the primary Add
// panel) or "extra" (a wider bank hidden behind a filter box for
// discovery). Missing tier defaults to "catalog".
// URL routing is identical for all — kind and tier are display-only grouping.
const SEARCH _ENGINES = {
duckduckgo : { kind : "search" , tier : "catalog" , name : "DuckDuckGo" , sym : "🦆" , fav : "duckduckgo.com" , url : ( q ) => "https://duckduckgo.com/?q=" + encodeURIComponent ( q ) } ,
google : { kind : "search" , tier : "catalog" , name : "Google" , sym : "🔵" , fav : "www.google.com" , url : ( q , h = { } ) => ` https://www.google.com/search?q= ${ encodeURIComponent ( q ) } &hl= ${ h . hl || "en" } &gl= ${ h . gl || "us" } ` } ,
brave : { kind : "search" , tier : "catalog" , name : "Brave" , sym : "🦁" , fav : "search.brave.com" , url : ( q ) => "https://search.brave.com/search?q=" + encodeURIComponent ( q ) } ,
bing : { kind : "search" , tier : "catalog" , name : "Bing" , sym : "🔎" , fav : "www.bing.com" , url : ( q ) => "https://www.bing.com/search?q=" + encodeURIComponent ( q ) } ,
startpage : { kind : "search" , tier : "catalog" , name : "Startpage" , sym : "🛡️" , fav : "www.startpage.com" , url : ( q ) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent ( q ) } ,
yandex : { kind : "search" , tier : "catalog" , name : "Yandex" , sym : "🔴" , fav : "yandex.com" , url : ( q ) => "https://yandex.com/search/?text=" + encodeURIComponent ( q ) } ,
ecosia : { kind : "search" , tier : "catalog" , name : "Ecosia" , sym : "🌱" , fav : "www.ecosia.org" , url : ( q ) => "https://www.ecosia.org/search?q=" + encodeURIComponent ( q ) } ,
mojeek : { kind : "search" , tier : "catalog" , name : "Mojeek" , sym : "🧭" , fav : "www.mojeek.com" , url : ( q ) => "https://www.mojeek.com/search?q=" + encodeURIComponent ( q ) } ,
// SearXNG is federated (dozens of public instances at searx.space); any single
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
// Users who want SearXNG add their preferred instance via the custom URL form.
wikipedia : { kind : "search" , tier : "catalog" , name : "Wikipedia" , sym : "📖" , fav : "en.wikipedia.org" , url : ( q ) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent ( q ) } ,
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
perplexity : { kind : "llm" , tier : "catalog" , name : "Perplexity" , sym : "🧠" , fav : "www.perplexity.ai" , url : ( q ) => "https://www.perplexity.ai/search?q=" + encodeURIComponent ( q ) } ,
phind : { kind : "llm" , tier : "catalog" , name : "Phind" , sym : "🧑💻" , fav : "www.phind.com" , url : ( q ) => "https://www.phind.com/search?q=" + encodeURIComponent ( q ) } ,
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
// These are known-working engines that don't require sign-in on ?q= but aren't
// first-class enough to sit in the primary catalog. Keep the list vetted — if
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
marginalia : { kind : "search" , tier : "extra" , name : "Marginalia" , sym : "🕸" , fav : "search.marginalia.nu" , url : ( q ) => "https://search.marginalia.nu/search?query=" + encodeURIComponent ( q ) } ,
stract : { kind : "search" , tier : "extra" , name : "Stract" , sym : "🧵" , fav : "stract.com" , url : ( q ) => "https://stract.com/search?q=" + encodeURIComponent ( q ) } ,
yep : { kind : "search" , tier : "extra" , name : "Yep" , sym : "✳️" , fav : "yep.com" , url : ( q ) => "https://yep.com/web?q=" + encodeURIComponent ( q ) } ,
presearch : { kind : "search" , tier : "extra" , name : "Presearch" , sym : "🔷" , fav : "presearch.com" , url : ( q ) => "https://presearch.com/search?q=" + encodeURIComponent ( q ) } ,
metager : { kind : "search" , tier : "extra" , name : "MetaGer" , sym : "🇩🇪" , fav : "metager.org" , url : ( q ) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent ( q ) } ,
qwant : { kind : "search" , tier : "extra" , name : "Qwant" , sym : "🇫🇷" , fav : "www.qwant.com" , url : ( q ) => "https://www.qwant.com/?q=" + encodeURIComponent ( q ) } ,
swisscows : { kind : "search" , tier : "extra" , name : "Swisscows" , sym : "🐄" , fav : "swisscows.com" , url : ( q ) => "https://swisscows.com/en/web?query=" + encodeURIComponent ( q ) } ,
naver : { kind : "search" , tier : "extra" , name : "Naver" , sym : "🇰🇷" , fav : "www.naver.com" , url : ( q ) => "https://search.naver.com/search.naver?query=" + encodeURIComponent ( q ) } ,
baidu : { kind : "search" , tier : "extra" , name : "Baidu" , sym : "🇨🇳" , fav : "www.baidu.com" , url : ( q ) => "https://www.baidu.com/s?wd=" + encodeURIComponent ( q ) } ,
} ;
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
const DEFAULT _ENABLED = [ "startpage" , "duckduckgo" , "google" , "brave" , "bing" ] ;
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
// Search-engine favicon source. DuckDuckGo's icons.duckduckgo.com/ip3/…
// service was returning inconsistent results (Brave, Bing, Yandex etc.
// came back 404 → the settings row fell through to an emoji). Google's
// /s2/favicons service is materially more reliable, returns a real 32× 32
// PNG for essentially every host, and doesn't require login. Kept as a
// single point so the fallback source can be swapped again in one place.
const faviconUrl = ( domain ) => ( domain ? ` https://www.google.com/s2/favicons?domain= ${ domain } &sz=32 ` : null ) ;
function customFavicon ( url ) { try { return faviconUrl ( new URL ( String ( url ) . replace ( "%s" , "x" ) ) . hostname ) ; } catch { return null ; } }
function isEnabled ( id ) { return ( settings . enabledEngines || DEFAULT _ENABLED ) . includes ( id ) ; }
// Two-tier state: an engine is INSTALLED if it's in the user's Additional
// list (visible in Settings), and ENABLED if it's currently toggled on
// (visible in the toolbar dropdown). Toggle flips enabled only; right-click
// "Remove from list" is what actually removes an installed engine.
function isInstalled ( id ) {
if ( ( settings . customEngines || [ ] ) . some ( ( e ) => e . id === id ) ) return true ; // customs are always installed
return ( settings . installedEngines || DEFAULT _ENABLED ) . includes ( id ) ;
}
function allEngines ( ) {
const list = Object . entries ( SEARCH _ENGINES ) . map ( ( [ id , e ] ) =>
( { id , name : e . name , sym : e . sym , favicon : faviconUrl ( e . fav ) , kind : e . kind || "search" , tier : e . tier || "catalog" , builtin : true , installed : isInstalled ( id ) , enabled : isEnabled ( id ) } ) ) ;
for ( const c of settings . customEngines || [ ] )
list . push ( { id : c . id , name : c . name , sym : c . sym || "🔍" , favicon : customFavicon ( c . url ) , kind : c . kind || "search" , tier : "custom" , builtin : false , installed : true , enabled : isEnabled ( c . id ) } ) ;
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
const order = settings . engineOrder || [ ] ;
return list . slice ( ) . sort ( ( a , b ) => {
const ia = order . indexOf ( a . id ) , ib = order . indexOf ( b . id ) ;
if ( ia === - 1 && ib === - 1 ) return 0 ;
if ( ia === - 1 ) return 1 ;
if ( ib === - 1 ) return - 1 ;
return ia - ib ;
} ) ;
}
function enabledEnginesList ( ) { return allEngines ( ) . filter ( ( e ) => e . enabled ) ; }
// Region → 2-letter country code, for engines that accept a `gl`-style hint
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
// a consent redirect or the region-detect start page instead of results).
const REGION _TO _COUNTRY = {
europe : "de" , asia : "jp" , north _america : "us" , south _america : "br" ,
africa : "ke" , middle _east : "ae" , australia : "au" ,
} ;
function searchHints ( ) {
const loc = effLocale ( ) ; // e.g. "en-US" (or null → show real)
const region = settings . locationMode === "spoof" ? settings . locationRegion : null ;
return {
hl : ( loc || app . getLocale ( ) || "en" ) . split ( "-" ) [ 0 ] ,
gl : REGION _TO _COUNTRY [ region ] || ( loc && loc . split ( "-" ) [ 1 ] ? . toLowerCase ( ) ) || "us" ,
} ;
}
function engineUrl ( id , q ) {
const h = searchHints ( ) ;
if ( SEARCH _ENGINES [ id ] ) return SEARCH _ENGINES [ id ] . url ( q , h ) ;
const c = ( settings . customEngines || [ ] ) . find ( ( e ) => e . id === id ) ;
return c ? c . url . replace ( /%s/g , encodeURIComponent ( q ) ) : SEARCH _ENGINES . duckduckgo . url ( q , h ) ;
}
const SEARCH = ( q ) => engineUrl ( settings . searchEngine , q ) ;
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st" ;
// ---- Signed DNS records ----------------------------------------------------
// Owners can publish an owner-signed `_records.json` manifest on Sia with
// classic DNS data (A/AAAA/MX/TXT/CNAME/NS). The gateway verifies the
// signature against the current NFT holder and serves the verified `dns`
// block as GET /api/dns/<name> (Decentralized.DNS/INTEGRATION-signed-records-
// clients.md). These records EXTEND on-chain records and never override them:
// h/s3/ip/p/u stay authoritative for content. We fetch them in the background
// on every BCDN resolution with a 3 s cap and hang the result on the entry as
// `entry.dns`; a navigation never waits for the fetch, except when a name has
// no on-chain content record at all and a signed A record is the only way to
// reach it. Only registered names are looked up, so ICANN hosts the user
// visits are never sent to the gateway.
const DNS _RECORDS _TTL = 30_000 ; // matches the gateway's Cache-Control max-age=30
const dnsRecordsCache = new Map ( ) ; // name -> { value, at, seq, pending }
function dnsRecordsCached ( name ) {
const c = dnsRecordsCache . get ( name ) ;
return c && Date . now ( ) - c . at < DNS _RECORDS _TTL ? c . value : undefined ;
}
function fetchDnsRecords ( name ) {
const c = dnsRecordsCache . get ( name ) ;
if ( c ? . pending ) return c . pending ;
if ( c && Date . now ( ) - c . at < DNS _RECORDS _TTL ) return Promise . resolve ( c . value ) ;
const prev = c ? . value ? ? null ;
const pending = ( async ( ) => {
let value = prev ;
try {
const r = await fetch ( ` ${ GATEWAY } /api/dns/ ${ encodeURIComponent ( name ) } ` , { signal : AbortSignal . timeout ( 3000 ) , cache : "no-store" } ) ;
if ( r . ok ) {
const j = await r . json ( ) ;
const seq = Number ( j ? . seq ) || 0 ;
// Rollback guard: a manifest with a lower seq than one already seen
// for this name is stale (or replayed) — keep what we had.
if ( j && j . dns && typeof j . dns === "object" && seq >= ( c ? . seq ? ? - 1 ) ) {
value = { dns : j . dns , seq , updatedAt : j . updated _at || null , owner : j . verified _owner || null } ;
}
} else if ( r . status === 404 || r . status === 409 ) {
value = null ; // no manifest declared / nowhere to keep one
}
} catch { /* offline, timeout, bad JSON — records are optional */ }
dnsRecordsCache . set ( name , { value , at : Date . now ( ) , seq : Math . max ( c ? . seq ? ? - 1 , value ? . seq ? ? - 1 ) , pending : null } ) ;
return value ;
} ) ( ) ;
dnsRecordsCache . set ( name , { value : prev , at : c ? . at ? ? 0 , seq : c ? . seq ? ? - 1 , pending } ) ;
return pending ;
}
// Kick off the fetch for a resolved entry and attach the answer when it lands.
// `entry.dns` is undefined while unknown, null when the owner published no
// manifest, or { dns, seq, updatedAt, owner }.
function attachDnsRecords ( entry ) {
if ( ! entry || ! entry . name ) return ;
const cached = dnsRecordsCached ( entry . name ) ;
if ( cached !== undefined ) { entry . dns = cached ; return ; }
fetchDnsRecords ( entry . name ) . then ( ( v ) => { entry . dns = v ; } , ( ) => { } ) ;
}
// Record types the manifest actually carries (for the site-info popover).
function dnsRecordKinds ( entry ) {
const d = entry ? . dns ? . dns ;
if ( ! d || typeof d !== "object" ) return [ ] ;
return Object . keys ( d ) . filter ( ( k ) => Array . isArray ( d [ k ] ) ? d [ k ] . length > 0 : d [ k ] != null && d [ k ] !== "" ) ;
}
// First signed IPv4 address for a name — the reachability fallback when the
// chain carries no content record. Waits for an in-flight fetch (≤ 3 s) only
// because there is nothing else to serve.
async function dnsAddressFor ( entry ) {
if ( ! entry ? . name ) return null ;
const v = entry . dns !== undefined ? entry . dns : await fetchDnsRecords ( entry . name ) ;
const a = v ? . dns ? . A ;
const ip = Array . isArray ( a ) ? a . find ( ( x ) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/ . test ( x ) ) : null ;
return ip || null ;
}
// ---- BNS name detection (multi-TLD) --------------------------------------
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
// <label>.<tld> from the BCNR beacon. Flow:
// 1. User navigates to `<label>.<tld>` (address bar or link click)
// 2. Theseus asks BCNR first
// 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u)
// 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web
// (https://<host><path>), so users aren't locked out of the clearnet
// when the chain is down or the name isn't registered.
// Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames,
// non-http schemes) bypass BCNR and load directly.
// The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN.
const REGISTRY = "BCNR" ; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = ( host ) => {
const h = String ( host ) . toLowerCase ( ) . replace ( /\.$/ , "" ) ;
const dot = h . lastIndexOf ( "." ) ;
return dot < 0 ? null : h . slice ( dot + 1 ) ;
} ;
// Any dotted host that isn't an IP or localhost is a BCNR candidate.
const isBnsHost = ( host ) => {
if ( ! host ) return false ;
const h = String ( host ) . toLowerCase ( ) . replace ( /\.$/ , "" ) ;
if ( h === "localhost" || h . startsWith ( "localhost:" ) ) return false ;
if ( /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/ . test ( h ) ) return false ; // IPv4[:port]
if ( h . startsWith ( "[" ) ) return false ; // IPv6 literal
const dot = h . lastIndexOf ( "." ) ;
return dot > 0 && dot < h . length - 1 ; // has a real TLD
} ;
// Kept as aliases so external callers (tests, module.exports) don't break.
const nativeTld = ( host ) => isBnsHost ( host ) ? tldOf ( host ) : null ;
const dualTld = ( ) => null ; // dual-priority mode is gone — no ICANN-first TLDs
const registryOf = ( _tld ) => REGISTRY ;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl ( q ) {
if ( ! q ) return false ;
if ( /\s/ . test ( q ) ) return false ; // has whitespace -> search
if ( /^[a-z][a-z0-9+.-]*:\/\//i . test ( q ) ) return true ; // scheme://…
if ( /^localhost(:\d+)?([/?#]|$)/i . test ( q ) ) return true ; // localhost[:port]
if ( /^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/ . test ( q ) ) return true ; // IPv4[:port]
const host = q . split ( /[/?#]/ ) [ 0 ] ; // strip path/query/frag
return host . includes ( "." ) && ! host . startsWith ( "." ) && ! host . endsWith ( "." ) ; // dotted host
}
// Local files typed or pasted into the address bar: a file:// URL, a Windows
// drive path (D:\x\y.html, mixed slashes allowed), a UNC share, or on POSIX
// an absolute / ~ path. Returns the file:// URL to load, or null when the
// input isn't a local path. Checked before looksLikeUrl — a path has no dotted
// host, so the URL heuristic would otherwise hand it to the search engine.
function localFileUrl ( q ) {
if ( ! q ) return null ;
if ( /^file:/i . test ( q ) ) { try { return new URL ( q ) . href ; } catch { return null ; } }
const isWin = process . platform === "win32" ;
const winPath = /^[a-z]:[\\/]/i . test ( q ) || /^\\\\[^\\]/ . test ( q ) ;
const posixPath = ! isWin && ( q . startsWith ( "/" ) || q . startsWith ( "~/" ) ) ;
if ( ! winPath && ! posixPath ) return null ;
let p = q ;
if ( posixPath && p . startsWith ( "~/" ) ) p = path . join ( app . getPath ( "home" ) , p . slice ( 2 ) ) ;
try { return url . pathToFileURL ( path . resolve ( p ) ) . href ; } catch { return null ; }
}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS _DEFAULTS = {
webrtcMode : "public_only" , // WebRTC IP policy: default | public_only | public_private | disable_udp
blockCamera : true , // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone : true , // deny microphone by default (also hides mic labels)
hideMediaDevices : true , // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
restoreSession : true , // reopen last session's tabs on launch
backgroundThrottle : true , // throttle inactive tabs / the window when unfocused
// Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit : true , // drop cookies + logins + saved-form data
clearCacheOnQuit : true , // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit : true , // drop navigation history + saved tabs
clearStorageOnQuit : true , // drop localStorage / IndexedDB / service workers / cache API
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode : "show" , timezoneValue : "Europe/Berlin" , // IANA zone for manual
languageMode : "show" , languageSpoof : "en-US" , languageValue : "en-US" , // spoof = top-10 pick, manual = free text
locationMode : "hide" , locationRegion : "europe" , locationLat : "40.7128" , locationLon : "-74.0060" , // spoof by region, or manual coords
searchEngine : "startpage" , // default search engine (built-in id or a custom id)
installedEngines : DEFAULT _ENABLED . slice ( ) , // built-in engines added to the user's list (visible in Settings)
enabledEngines : DEFAULT _ENABLED . slice ( ) , // subset that's currently toggled on (shown in the toolbar dropdown)
engineOrder : [ ] , // user-defined display order of engine ids (empty = natural)
customEngines : [ ] , // user-added: [{ id, name, url-with-%s }]
theme : "dark" , // dark | light | system — drives prefers-color-scheme in all views
// BCNR/ICANN collision policy (see SilentMode/Argus/DESIGN-collision-modes.md):
// "bcnr-first" — BCNR wins collisions (default).
// "icann-first" — ICANN wins collisions; BCNR fills gaps.
// "soft" — "Open with…" prompt on collision, remembered per name/TLD.
collisionPolicy : "bcnr-first" ,
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons : [ ] ,
// Toolbar extension dock: user-chosen button order (keys "p:<panelId>" /
// "m:<addonId>", unknown keys keep registration order after these) and
// buttons hidden from the toolbar via the dock's right-click menu.
dockOrder : [ ] ,
dockHidden : [ ] ,
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth : 340 ,
// Toolbar bar sizes. The URL bar is flex:1 by default so it eats all
// remaining space; `compact`/`medium` cap it so the extension dock has
// room to grow. The search box is fixed-width; hidden removes it.
urlBarSize : "wide" , // compact | medium | wide (default)
searchBoxSize : "normal" , // hidden | compact | normal (default) | wide
// Drag-set widths in pixels. Non-zero → override the corresponding size
// preset; zero/null → follow the preset. Set by the toolbar drag handles.
urlBarWidthPx : 0 ,
searchBoxWidthPx : 0 ,
// DevTools dock position. "bottom" (default) opens the console under the
// tab, matching Chrome's own default; "sidebar" docks it in the right
// sidebar, replacing the add-on sidebar while it's open; "two-sidebars"
// opens the console AND lets the add-on sidebar stay visible on its own
// right-side dock — Electron's mode:right takes over the right edge, so
// "two-sidebars" is drawn as mode:right and the add-on sidebar is not
// forced closed; the two share the right area (add-on sidebar keeps its
// width, DevTools takes what's left).
devToolsDock : "bottom" , // bottom | sidebar | two-sidebars
} ;
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
function applyTheme ( ) {
try { nativeTheme . themeSource = [ "dark" , "light" , "system" ] . includes ( settings . theme ) ? settings . theme : "dark" ; } catch { }
}
// Auto decoys used by "spoof" mode (plausible but not the user's real values).
const SPOOF = { tz : "America/New_York" , lang : "en-US" , lat : 40.7128 , lon : - 74.0060 } ;
let settings = { ... SETTINGS _DEFAULTS } ;
const settingsFile = ( ) => path . join ( app . getPath ( "userData" ) , "settings.json" ) ;
function loadSettings ( ) {
try { if ( fs . existsSync ( settingsFile ( ) ) ) settings = { ... SETTINGS _DEFAULTS , ... JSON . parse ( fs . readFileSync ( settingsFile ( ) , "utf8" ) ) } ; }
catch ( e ) { console . error ( "settings load failed:" , e . message ) ; }
// Restore the persisted sidebar width so the first-open of a session
// uses whatever the user left it at last time.
const w = Number ( settings . sidebarWidth ) || SIDEBAR _W _DEFAULT ;
sidebarW = Math . max ( SIDEBAR _W _MIN , Math . min ( SIDEBAR _W _MAX , w ) ) ;
// Normalize the search-engine state so the toolbar picker and Settings tab
// can never disagree. Two invariants:
// 1. Every enabledEngines id must also be in installedEngines. If a user
// manually edited settings.json (or an upgrade left the two out of
// sync), we add the missing installed rows now.
// 2. settings.searchEngine must be an enabled engine. If the default from
// SETTINGS_DEFAULTS points at an id the user has disabled, fall back
// to the first currently-enabled engine.
try {
const enabled = Array . isArray ( settings . enabledEngines ) ? settings . enabledEngines : DEFAULT _ENABLED . slice ( ) ;
const installed = Array . isArray ( settings . installedEngines ) ? settings . installedEngines : DEFAULT _ENABLED . slice ( ) ;
settings . installedEngines = [ ... new Set ( [ ... installed , ... enabled ] ) ] ;
if ( ! enabled . includes ( settings . searchEngine ) ) {
settings . searchEngine = enabled [ 0 ] || DEFAULT _ENABLED [ 0 ] ;
}
} catch ( e ) { console . warn ( "engine normalize:" , e ? . message ) ; }
}
function saveSettings ( ) {
try { fs . writeFileSync ( settingsFile ( ) , JSON . stringify ( settings , null , 2 ) ) ; } catch ( e ) { console . error ( "settings save failed:" , e . message ) ; }
}
// ---- bookmarks / saved pages (userData/bookmarks.json) ----
let bookmarks = [ ] ;
const bookmarksFile = ( ) => path . join ( app . getPath ( "userData" ) , "bookmarks.json" ) ;
function loadBookmarks ( ) { try { if ( fs . existsSync ( bookmarksFile ( ) ) ) bookmarks = JSON . parse ( fs . readFileSync ( bookmarksFile ( ) , "utf8" ) ) ; } catch ( e ) { console . error ( "bookmarks load failed:" , e . message ) ; } }
function saveBookmarks ( ) { try { fs . writeFileSync ( bookmarksFile ( ) , JSON . stringify ( bookmarks , null , 2 ) ) ; } catch ( e ) { console . error ( "bookmarks save failed:" , e . message ) ; } }
function emitBookmarks ( ) { try { chrome ? . webContents . send ( "bookmarks" , bookmarks ) ; } catch { } }
// ---- in-app update check (cheap) ------------------------------------------
// Fetch the releases manifest at startup + every 6h. If it names a Theseus
// version newer than ours, surface a chip in the toolbar with a link to the
// download URL. No auto-install, no signing check — the on-chain pointer at
// releases.silentmode.bch publishes the SAME manifest URL, so users who want
// to verify integrity can compare the manifest hash to what BCNR returns.
const UPDATE _MANIFEST _URL = "https://dl.silentmode.st/releases-manifest.json" ;
const UPDATE _DOWNLOAD _BASE = "https://dl.silentmode.st/" ;
let updateAvailable = null ; // { version, setupUrl, portableUrl, setupHash, portableHash, date }
let updateDismissedThisSession = false ;
// Simple string version compare — "0.0.4" > "0.0.3" and "0.10.0" > "0.9.9".
function versionIsNewer ( candidate , current ) {
const a = String ( candidate || "" ) . split ( "." ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
const b = String ( current || "" ) . split ( "." ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
const len = Math . max ( a . length , b . length ) ;
for ( let i = 0 ; i < len ; i ++ ) {
const x = a [ i ] || 0 , y = b [ i ] || 0 ;
if ( x > y ) return true ;
if ( x < y ) return false ;
}
return false ; // equal → not newer
}
async function checkForUpdate ( ) {
// Dev harness guard: a throwaway instance (THESEUS_USER_DATA) that finds a
// newer release on the mirror shows the same one-click "Install & restart"
// chip as a real install, and that installer targets the REAL install dir.
// 2026-09-15 a test run reinstalled the user's Theseus that way.
if ( process . env . THESEUS _NO _UPDATE _CHECK ) return ;
try {
const controller = new AbortController ( ) ;
const to = setTimeout ( ( ) => controller . abort ( ) , 5000 ) ;
const r = await fetch ( UPDATE _MANIFEST _URL , { signal : controller . signal , cache : "no-store" } ) ;
clearTimeout ( to ) ;
if ( ! r . ok ) return ;
const manifest = await r . json ( ) ;
const rel = ( manifest . releases || [ ] ) . find ( ( x ) => x . id === "theseus-navigator" ) ;
if ( ! rel || ! rel . version ) return ;
if ( ! versionIsNewer ( rel . version , app . getVersion ( ) ) ) {
// Same version or older — nothing to offer. Clear any stale state so the
// chip disappears after the user has updated + relaunched.
if ( updateAvailable ) { updateAvailable = null ; updateDownloadState = "idle" ; updateDownloadPath = null ; emitUpdateAvailable ( ) ; }
return ;
}
const files = rel . files || { } ;
const setupFile = Object . keys ( files ) . find ( ( k ) => / Setup / i . test ( k ) ) ;
const portableFile = Object . keys ( files ) . find ( ( k ) => / portable / i . test ( k ) ) ;
const nextAvailable = {
version : rel . version ,
date : rel . date || "" ,
setupUrl : setupFile ? UPDATE _DOWNLOAD _BASE + setupFile : null ,
portableUrl : portableFile ? UPDATE _DOWNLOAD _BASE + portableFile : null ,
setupHash : setupFile ? files [ setupFile ] : null ,
portableHash : portableFile ? files [ portableFile ] : null ,
} ;
const versionChanged = ! updateAvailable || updateAvailable . version !== nextAvailable . version ;
updateAvailable = nextAvailable ;
if ( versionChanged ) {
// New candidate — reset any prior download state and kick off a fresh
// silent background fetch so the chip lands as "ready to install".
updateDownloadState = "idle" ;
updateDownloadPath = null ;
autoDownloadUpdate ( ) ;
}
emitUpdateAvailable ( ) ;
} catch { /* offline / manifest unreachable — silent */ }
}
// Silent background pre-download of the update installer. The user never
// has to click Download — clicking the chip goes straight to Install.
// State machine: idle -> downloading -> ready | failed.
let updateDownloadState = "idle" ;
let updateDownloadPath = null ; // path on disk once "ready"
let updateDownloadReceived = 0 ; // bytes so far
let updateDownloadTotal = 0 ; // total bytes
function autoDownloadUpdate ( ) {
if ( ! updateAvailable || ! updateAvailable . setupUrl ) return ;
if ( updateDownloadState !== "idle" ) return ;
updateDownloadState = "downloading" ;
updateDownloadReceived = 0 ;
updateDownloadTotal = 0 ;
try {
session . defaultSession . downloadURL ( updateAvailable . setupUrl ) ;
console . log ( ` [update] silent fetch started: ${ updateAvailable . setupUrl } ` ) ;
} catch ( e ) {
console . warn ( "update prefetch failed:" , e ? . message ) ;
updateDownloadState = "failed" ;
}
emitUpdateAvailable ( ) ;
}
function emitUpdateAvailable ( ) {
const base = ( updateDismissedThisSession || ! updateAvailable ) ? null : updateAvailable ;
const payload = base ? {
... base ,
downloadState : updateDownloadState , // idle | downloading | ready | failed
downloadReceived : updateDownloadReceived ,
downloadTotal : updateDownloadTotal ,
} : null ;
try { chrome ? . webContents . send ( "update-available" , payload ) ; } catch { }
}
// ---- home page editable cards (userData/home-cards.json) ------------------
// Rendered by home.html as the "quick links" grid on the new-tab page. User
// can add/edit/remove via the page's edit mode. First-run seed = the classic
// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.).
const DEFAULT _HOME _CARDS = [
{ title : "hello.bch" , url : "https://hello.bch/" , sub : "A small page on the blockchain itself." , badge : "on-chain" } ,
{ title : "siatest.bch" , url : "https://siatest.bch/" , sub : "A page with no server, backed by Sia." , badge : "Sia" } ,
{ title : "SilentMode.X" , url : "https://silentmode.x/" , sub : "Infrastructure development for a decentralized web." , badge : "Infrastructure" } ,
{ title : "Theseus.X" , url : "https://theseus.x/" , sub : "The Web Navigator — this browser's own address." , badge : "Navigator" } ,
{ title : "Sirius.X" , url : "https://sirius.x/" , sub : "Register and manage BCDN names." , badge : "Registrar" } ,
{ title : "Hephaestus.X" , url : "https://hephaestus.x/" , sub : "The forge — Silent Mode's code host." , badge : "Code host" } ,
{ title : "Prometheus.X" , url : "https://prometheus.x/" , sub : "Decentralized App Marketplace." , badge : "App store" } ,
{ title : "Helios.X" , url : "https://helios.x/" , sub : "Search engine for the decentralized web (in design)." , badge : "Search" } ,
{ title : "Hermes.X" , url : "https://hermes.x/" , sub : "Messaging — end-to-end encrypted over Nostr." , badge : "Messaging" } ,
] ;
// User's local edits win over everything else — that's the whole point of
// the edit mode. Remote pull only feeds the "defaults" tier so brand copy
// changes reach installs without a browser release.
const homeCardsFile = ( ) => path . join ( app . getPath ( "userData" ) , "home-cards.json" ) ;
const homeCardsRemoteCache = ( ) => path . join ( app . getPath ( "userData" ) , "home-cards-remote.json" ) ;
// The canonical remote card list is served from silentmode.st (and mirrored
// on silentmode.bch via Sia). Editing that file updates every install on
// its next launch — no reinstall required.
const HOME _CARDS _URL = "https://dl.silentmode.st/home-cards.json" ;
const HOME _CARDS _REFRESH _MS = 6 * 60 * 60 * 1000 ; // every 6h
function loadHomeCards ( ) {
// Priority: user's local edits > cached remote copy > code defaults.
try {
if ( fs . existsSync ( homeCardsFile ( ) ) ) {
const v = JSON . parse ( fs . readFileSync ( homeCardsFile ( ) , "utf8" ) ) ;
if ( Array . isArray ( v ) && v . length ) return v ;
}
} catch ( e ) { console . error ( "home cards (user) load failed:" , e . message ) ; }
try {
if ( fs . existsSync ( homeCardsRemoteCache ( ) ) ) {
const v = JSON . parse ( fs . readFileSync ( homeCardsRemoteCache ( ) , "utf8" ) ) ;
if ( Array . isArray ( v ) && v . length ) return v ;
}
} catch ( e ) { console . error ( "home cards (remote-cache) load failed:" , e . message ) ; }
return DEFAULT _HOME _CARDS . slice ( ) ;
}
function saveHomeCards ( cards ) {
try { fs . writeFileSync ( homeCardsFile ( ) , JSON . stringify ( cards , null , 2 ) ) ; }
catch ( e ) { console . error ( "home cards save failed:" , e . message ) ; }
}
// Fetch the canonical home-cards.json into the remote cache. Silent on any
// error (no network, 404, bad JSON, etc.) — the cache stays as-is and the
// user sees either their last cached set or the built-in defaults.
async function refreshRemoteHomeCards ( ) {
try {
const controller = new AbortController ( ) ;
const to = setTimeout ( ( ) => controller . abort ( ) , 6000 ) ;
const r = await fetch ( HOME _CARDS _URL , { signal : controller . signal , cache : "no-store" } ) ;
clearTimeout ( to ) ;
if ( ! r . ok ) return ;
const list = await r . json ( ) ;
if ( ! Array . isArray ( list ) || list . length === 0 ) return ;
// Basic sanity: every entry must be an object with a string title + url.
const clean = list . filter ( ( c ) => c && typeof c . title === "string" && typeof c . url === "string" ) ;
if ( ! clean . length ) return ;
fs . writeFileSync ( homeCardsRemoteCache ( ) , JSON . stringify ( clean , null , 2 ) ) ;
// Only push into open home tabs if the user hasn't overridden — their
// edits stay put.
if ( ! fs . existsSync ( homeCardsFile ( ) ) ) {
for ( const t of tabs ) {
try { t . view . webContents . send ( "home-cards" , clean ) ; } catch { }
}
}
console . log ( ` [home-cards] refreshed from ${ HOME _CARDS _URL } : ${ clean . length } cards ` ) ;
} catch ( e ) { /* silent */ }
}
// Sender validation — only accept IPC from our own home.html file:// URL.
// Rejects third-party pages that see the API shape via the preload.
function isHomePageSender ( sender ) {
try {
const u = sender . getURL ( ) || "" ;
return u . startsWith ( "file://" ) && /home\.html(?:$|\?|#)/i . test ( u ) ;
} catch { return false ; }
}
// Same origin-gating pattern for the branded error page.
function isErrorPageSender ( sender ) {
try {
const u = sender . getURL ( ) || "" ;
return u . startsWith ( "file://" ) && /error\.html(?:$|\?|#)/i . test ( u ) ;
} catch { return false ; }
}
// ---- address-bar history (userData/history.json) --------------------------
// Suggestions dropdown source. Deduped LRU capped at HISTORY_CAP entries.
// Cleared on quit when settings.clearHistoryOnQuit is on (default).
const HISTORY _CAP = 500 ;
let history = [ ] ; // [{ url, title, ts }]
let historySaveTimer = null ;
const historyFile = ( ) => path . join ( app . getPath ( "userData" ) , "history.json" ) ;
function loadHistory ( ) { try { if ( fs . existsSync ( historyFile ( ) ) ) history = JSON . parse ( fs . readFileSync ( historyFile ( ) , "utf8" ) ) ; } catch ( e ) { console . error ( "history load failed:" , e . message ) ; history = [ ] ; } }
function saveHistoryDebounced ( ) {
clearTimeout ( historySaveTimer ) ;
historySaveTimer = setTimeout ( ( ) => {
try { fs . writeFileSync ( historyFile ( ) , JSON . stringify ( history ) ) ; } catch ( e ) { console . error ( "history save failed:" , e . message ) ; }
} , 800 ) ;
}
function historyAdd ( url , title ) {
if ( ! url ) return ;
const clean = String ( url ) . trim ( ) ;
// Skip internal / non-http(s) URLs — never useful in address suggestions.
if ( ! /^https?:\/\//i . test ( clean ) && ! /^bns:\/\//i . test ( clean ) ) return ;
// LRU: remove any existing entry for this URL, unshift a fresh one to the top.
const i = history . findIndex ( ( h ) => h . url === clean ) ;
if ( i >= 0 ) history . splice ( i , 1 ) ;
history . unshift ( { url : clean , title : String ( title || "" ) . slice ( 0 , 200 ) , ts : Date . now ( ) } ) ;
if ( history . length > HISTORY _CAP ) history . length = HISTORY _CAP ;
saveHistoryDebounced ( ) ;
}
// Rank matches: prefix-of-host wins, then prefix-of-URL, then contains,
// then recency. Cap results — the dropdown wants at most ~8 entries.
function historySearch ( query , cap = 8 ) {
const q = String ( query || "" ) . trim ( ) . toLowerCase ( ) ;
if ( ! q ) return history . slice ( 0 , cap ) ;
const scored = [ ] ;
for ( const h of history ) {
const u = h . url . toLowerCase ( ) ;
const host = u . replace ( /^https?:\/\// , "" ) . split ( /[/?#]/ ) [ 0 ] ;
let score ;
if ( host . startsWith ( q ) ) score = 100 ;
else if ( u . startsWith ( q ) ) score = 80 ;
else if ( host . includes ( q ) ) score = 60 ;
else if ( u . includes ( q ) ) score = 40 ;
else if ( ( h . title || "" ) . toLowerCase ( ) . includes ( q ) ) score = 20 ;
else continue ;
scored . push ( { h , score } ) ;
}
scored . sort ( ( a , b ) => ( b . score - a . score ) || ( b . h . ts - a . h . ts ) ) ;
return scored . slice ( 0 , cap ) . map ( ( s ) => s . h ) ;
}
// ---- BCNR/ICANN collisions (userData/collisions.json) --------------------
// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode.
// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model.
let collisions = { byName : { } , byTld : { } } ;
const collisionsFile = ( ) => path . join ( app . getPath ( "userData" ) , "collisions.json" ) ;
function loadCollisions ( ) {
try { if ( fs . existsSync ( collisionsFile ( ) ) ) collisions = { byName : { } , byTld : { } , ... JSON . parse ( fs . readFileSync ( collisionsFile ( ) , "utf8" ) ) } ; }
catch ( e ) { console . error ( "collisions load failed:" , e . message ) ; }
}
function saveCollisions ( ) {
try { fs . writeFileSync ( collisionsFile ( ) , JSON . stringify ( collisions , null , 2 ) ) ; } catch ( e ) { console . error ( "collisions save failed:" , e . message ) ; }
}
// per-name > per-TLD > hard policy. Returns "bcnr" | "icann" | null (null = ask in soft).
function overrideFor ( host , tld ) {
const n = String ( host ) . toLowerCase ( ) ;
const t = String ( tld || "" ) . toLowerCase ( ) ;
if ( collisions . byName [ n ] ) return collisions . byName [ n ] ;
if ( collisions . byTld [ t ] ) return collisions . byTld [ t ] ;
return null ;
}
// Cached BCNR-native TLD list from tlds.bch. Registered names under a native TLD
// are NOT collision candidates (whole TLD belongs to BCNR); non-native = might collide.
let bcnrTlds = [ "bch" ] ;
function isBcnrNativeTld ( tld ) { return bcnrTlds . includes ( String ( tld || "" ) . toLowerCase ( ) ) ; }
function refreshBcnrTlds ( index ) {
try {
const raw = index ? . get ? . ( "tlds.bch" ) ? . records ? . tlds ;
if ( typeof raw === "string" ) {
const list = raw . split ( /\s+/ ) . filter ( Boolean ) . map ( ( s ) => s . toLowerCase ( ) ) ;
if ( list . length ) bcnrTlds = list ;
}
} catch { }
}
// (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the
// prompt is now an in-tab full-page interstitial loaded from collision.html,
// wired via the bns://collision-choose/ handler in serveBns().)
function rememberCollision ( host , tld , choice , remember ) {
if ( choice !== "bcnr" && choice !== "icann" ) return ;
if ( remember === "name" ) collisions . byName [ String ( host ) . toLowerCase ( ) ] = choice ;
else if ( remember === "tld" ) collisions . byTld [ String ( tld || "" ) . toLowerCase ( ) ] = choice ;
if ( remember !== "no" ) saveCollisions ( ) ;
}
function emitEngines ( ) {
try { chrome ? . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine } ) ; } catch { }
if ( epVisible ) try { enginePicker ? . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine , detected : activeTab ( ) ? . detected || null } ) ; } catch { }
}
// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter"
// Chrome extension provides, done natively (that extension's chrome.privacy API
// isn't available in Electron, and this is more reliable). Tor forces the strongest.
const WEBRTC _POLICIES = {
default : "default" , // allow all (may expose local IP)
public _only : "default_public_interface_only" , // only the default public interface
public _private : "default_public_and_private_interfaces" ,
disable _udp : "disable_non_proxied_udp" , // strongest (only proxied UDP)
} ;
function webrtcPolicy ( ) {
if ( torState === "on" ) return "disable_non_proxied_udp" ;
return WEBRTC _POLICIES [ settings . webrtcMode ] || "default_public_interface_only" ;
}
// ---- anti-fingerprinting: timezone + language + location ----
// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value).
function effTimezone ( ) {
switch ( settings . timezoneMode ) {
case "hide" : return "UTC" ;
case "spoof" : return SPOOF . tz ;
case "manual" : return settings . timezoneValue || "UTC" ;
default : return null ;
}
}
function effLocale ( ) {
switch ( settings . languageMode ) {
case "hide" : return "en-US" ;
case "spoof" : return settings . languageSpoof || SPOOF . lang ; // chosen from the top-languages list
case "manual" : return settings . languageValue || "en-US" ;
default : return null ;
}
}
// Representative coordinates per world region — used when the spoofed location is
// set to a region rather than exact coordinates (a major city stands in for each).
const REGIONS = {
europe : { lat : 52.5200 , lon : 13.4050 } , // Berlin
asia : { lat : 35.6762 , lon : 139.6503 } , // Tokyo
north _america : { lat : 40.7128 , lon : - 74.0060 } , // New York
south _america : { lat : - 23.5505 , lon : - 46.6333 } , // São Paulo
africa : { lat : - 1.2921 , lon : 36.8219 } , // Nairobi
middle _east : { lat : 25.2048 , lon : 55.2708 } , // Dubai
australia : { lat : - 33.8688 , lon : 151.2093 } , // Sydney
} ;
// Geolocation: null = show (real, allowed); "deny" = hide (blocked);
// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page.
function effLocation ( ) {
const m = settings . locationMode ;
if ( m === "hide" ) return "deny" ;
if ( m === "spoof" ) { const r = REGIONS [ settings . locationRegion ] || REGIONS . europe ; return { lat : r . lat , lon : r . lon } ; }
if ( m === "manual" ) return { lat : Number ( settings . locationLat ) || 0 , lon : Number ( settings . locationLon ) || 0 } ;
return null ; // show
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint ( wc ) {
try {
if ( ! wc . debugger . isAttached ( ) ) wc . debugger . attach ( "1.3" ) ;
const tz = effTimezone ( ) ;
await wc . debugger . sendCommand ( "Emulation.setTimezoneOverride" , { timezoneId : tz || "" } ) ;
const loc = effLocale ( ) ;
// Identity: stock-Chrome UA plus matching client hints on BOTH sides —
// the request headers and navigator.userAgentData — see chromeBrandMetadata.
try {
const meta = chromeBrandMetadata ( ) ;
const lang = loc || app . getLocale ( ) || "en-US" ;
await wc . debugger . sendCommand ( "Emulation.setUserAgentOverride" , {
userAgent : stockChromeUA ( ) || session . defaultSession . getUserAgent ( ) ,
acceptLanguage : acceptLanguageList ( lang ) ,
platform : meta . navigatorPlatform ,
userAgentMetadata : {
brands : meta . brands , fullVersionList : meta . fullVersionList ,
platform : meta . platform , platformVersion : meta . platformVersion ,
architecture : meta . architecture , model : meta . model , mobile : meta . mobile ,
bitness : meta . bitness , wow64 : meta . wow64 ,
} ,
} ) ;
} catch ( e ) { console . warn ( "userAgent override failed:" , e ? . message ) ; }
await wc . debugger . sendCommand ( "Emulation.setLocaleOverride" , loc ? { locale : loc } : { } ) ;
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc . debugger . sendCommand ( "Page.enable" ) ;
if ( wc . _langScript ) {
try { await wc . debugger . sendCommand ( "Page.removeScriptToEvaluateOnNewDocument" , { identifier : wc . _langScript } ) ; } catch { }
wc . _langScript = null ;
}
// Build one injected script covering navigator.language(s) and geolocation.
let src = "" ;
if ( loc ) {
const langs = JSON . stringify ( [ loc , loc . split ( "-" ) [ 0 ] ] ) ;
src += ` Object.defineProperty(navigator,'language',{get:()=> ${ JSON . stringify ( loc ) } ,configurable:true}); ` +
` Object.defineProperty(navigator,'languages',{get:()=> ${ langs } ,configurable:true}); ` ;
}
const geo = effLocation ( ) ;
if ( geo && geo !== "deny" ) { // spoof/manual: override the reported coordinates
const pos = ` {coords:{latitude: ${ geo . lat } ,longitude: ${ geo . lon } ,accuracy:100,altitude:null,altitudeAccuracy:null,heading:null,speed:null},timestamp:Date.now()} ` ;
src += ` try{const p=()=>( ${ pos } );if(navigator.geolocation){navigator.geolocation.getCurrentPosition=(ok)=>{try{ok(p())}catch(e){}};navigator.geolocation.watchPosition=(ok)=>{try{ok(p())}catch(e){}return 0};}}catch(e){} ` ;
}
// Media-device privacy: Chromium leaks audiooutput (speaker) labels + deviceIds
// via enumerateDevices even when camera/mic are blocked. Like Firefox, blank
// every device's label/deviceId/groupId and collapse to one entry per kind.
if ( settings . hideMediaDevices ) {
src += ` try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){} ` ;
}
// Always on: Chrome-shaped window.chrome (see CHROME_SHIM_SRC).
src += CHROME _SHIM _SRC ;
if ( src ) {
const res = await wc . debugger . sendCommand ( "Page.addScriptToEvaluateOnNewDocument" , { source : src } ) ;
wc . _langScript = res . identifier ;
try { await wc . executeJavaScript ( src ) ; } catch { } // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll ( ) { for ( const t of tabs ) applyFingerprint ( t . view . webContents ) ; }
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
// cache, localStorage, IndexedDB, service workers, cache API by default.
// This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
async function clearBrowsingData ( { cookies = false , cache = false , storage = false } = { } ) {
const ses = session . defaultSession ;
if ( cache ) { try { await ses . clearCache ( ) ; } catch ( e ) { console . warn ( "clearCache:" , e . message ) ; } }
const storages = [ ] ;
if ( cookies ) storages . push ( "cookies" ) ;
if ( storage ) storages . push ( "localstorage" , "indexdb" , "serviceworkers" , "cachestorage" , "shadercache" ) ;
if ( storages . length ) {
try { await ses . clearStorageData ( { storages } ) ; }
catch ( e ) { console . warn ( "clearStorageData:" , e . message ) ; }
}
// navigation history lives in each webContents; drop it too when history-clear was asked.
// (called separately by the before-quit hook, since history-clear also deletes session.json)
}
async function clearHistoryNow ( ) {
for ( const t of tabs ) {
try { t . view . webContents . navigationHistory . clear ( ) ; } catch { }
}
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
// Address-bar suggestions history — wipe both in-memory + on-disk.
history = [ ] ;
clearTimeout ( historySaveTimer ) ; historySaveTimer = null ;
try { fs . unlinkSync ( historyFile ( ) ) ; } catch { }
}
// Strip Electron + Theseus tokens from the User-Agent so Cloudflare's WAF
// (and other bot heuristics) don't flag every request. Verified: sending
// Mozilla/5.0 (…) theseus-navigator/0.3.22 Chrome/… Electron/33.4.11 Safari/537.36
// to whybitcoincash.com got HTTP 503 from Cloudflare; the same request
// without the theseus + Electron tokens returns 200. Brave / Vivaldi / Slack
// (Electron) all do the same strip — a Chromium browser identifying itself
// as vanilla Chrome is standard practice in the Electron ecosystem.
// The Chrome version we claim. Normally the embedded Chromium's own; the
// env override exists to test how sites react to a different version
// (a year-old Chromium build is itself a bot signal to some filters).
const CHROME _VERSION _CLAIMED = /^\d+(\.\d+){3}$/ . test ( process . env . THESEUS _CHROME _VERSION || "" )
? process . env . THESEUS _CHROME _VERSION : String ( process . versions . chrome || "130.0.0.0" ) ;
function stockChromeUA ( ) {
try {
return session . defaultSession . getUserAgent ( )
. replace ( / *theseus-navigator\/\S+/i , "" )
. replace ( / *Electron\/\S+/i , "" )
. replace ( /Chrome\/\d+(\.\d+){3}/ , "Chrome/" + CHROME _VERSION _CLAIMED ) ;
} catch { return null ; }
}
// Client-hint identity of stock Google Chrome for the Chromium we embed —
// computed the way Chromium itself does it, so it is indistinguishable from
// the real thing:
// - the GREASE brand ("Not?A_Brand";v="99" for 130) is derived from the
// major version with Chromium's character/version tables;
// - the order of the three brands is Chromium's per-major permutation
// (130 → Chromium, Google Chrome, Not?A_Brand).
// The previous hand-written list put "Google Chrome" first — a permutation
// real Chrome 130 never sends — and the page-side navigator.userAgentData
// still said "Chromium" only. DataDome ("AI Threats Detection") blocked on
// exactly that header/JS mismatch (estore.asus.com, 2026-09-20), so the same
// metadata is now also installed in every tab via Emulation.setUserAgentOverride.
// Electron hands every page an EMPTY window.chrome. Real Chrome's carries
// app / csi / loadTimes / runtime, and bot checks — Google sign-in's among
// them — look for exactly those to tell Chrome from an embedded Chromium.
// Same shapes and return types as Chrome; nothing here is reachable by the
// site beyond what a stock Chrome would also give it.
const CHROME _SHIM _SRC = ` try{(()=>{const c=window.chrome;if(!c||typeof c!=="object"||Object.keys(c).length)return;
const t = ( ) => performance . timing ;
c . app = { isInstalled : false , InstallState : { DISABLED : "disabled" , INSTALLED : "installed" , NOT _INSTALLED : "not_installed" } , RunningState : { CANNOT _RUN : "cannot_run" , READY _TO _RUN : "ready_to_run" , RUNNING : "running" } , getDetails ( ) { return null } , getIsInstalled ( ) { return false } , runningState ( ) { return "cannot_run" } } ;
c . csi = function ( ) { const p = t ( ) ; return { startE : p . navigationStart , onloadT : p . domContentLoadedEventEnd , pageT : performance . now ( ) , tran : 15 } } ;
c . loadTimes = function ( ) { const p = t ( ) ; return { requestTime : p . navigationStart / 1000 , startLoadTime : p . navigationStart / 1000 , commitLoadTime : p . responseStart / 1000 , finishDocumentLoadTime : p . domContentLoadedEventEnd / 1000 , finishLoadTime : p . loadEventEnd / 1000 , firstPaintTime : p . responseEnd / 1000 , firstPaintAfterLoadTime : 0 , navigationType : "Other" , wasFetchedViaSpdy : true , wasNpnNegotiated : true , npnNegotiatedProtocol : "h2" , wasAlternateProtocolAvailable : false , connectionInfo : "h2" } } ;
c . runtime = { OnInstalledReason : { CHROME _UPDATE : "chrome_update" , INSTALL : "install" , SHARED _MODULE _UPDATE : "shared_module_update" , UPDATE : "update" } , OnRestartRequiredReason : { APP _UPDATE : "app_update" , OS _UPDATE : "os_update" , PERIODIC : "periodic" } , PlatformArch : { ARM : "arm" , ARM64 : "arm64" , MIPS : "mips" , MIPS64 : "mips64" , X86 _32 : "x86-32" , X86 _64 : "x86-64" } , PlatformNaclArch : { ARM : "arm" , MIPS : "mips" , MIPS64 : "mips64" , X86 _32 : "x86-32" , X86 _64 : "x86-64" } , PlatformOs : { ANDROID : "android" , CROS : "cros" , LINUX : "linux" , MAC : "mac" , OPENBSD : "openbsd" , WIN : "win" } , RequestUpdateCheckStatus : { NO _UPDATE : "no_update" , THROTTLED : "throttled" , UPDATE _AVAILABLE : "update_available" } , id : undefined , connect ( ) { throw new TypeError ( "chrome.runtime.connect() called from a webpage must specify an Extension ID (string) for its first argument." ) } , sendMessage ( ) { throw new TypeError ( "chrome.runtime.sendMessage() called from a webpage must specify an Extension ID (string) for its first argument." ) } } ;
} ) ( ) } catch ( e ) { } ` ;
function chromeBrandMetadata ( ) {
const full = CHROME _VERSION _CLAIMED ;
const major = parseInt ( full . split ( "." ) [ 0 ] , 10 ) || 130 ;
const chars = [ " " , "(" , ":" , "-" , "." , "/" , ")" , ";" , "=" , "?" , "_" ] ;
const greaseBrand = "Not" + chars [ major % chars . length ] + "A" + chars [ ( major + 1 ) % chars . length ] + "Brand" ;
const greaseVer = [ "8" , "99" , "24" ] [ major % 3 ] ;
const base = [ [ greaseBrand , greaseVer , greaseVer + ".0.0.0" ] , [ "Chromium" , String ( major ) , full ] , [ "Google Chrome" , String ( major ) , full ] ] ;
const orders = [ [ 0 , 1 , 2 ] , [ 0 , 2 , 1 ] , [ 1 , 0 , 2 ] , [ 1 , 2 , 0 ] , [ 2 , 0 , 1 ] , [ 2 , 1 , 0 ] ] ;
const order = orders [ major % 6 ] ;
const out = [ ] ;
base . forEach ( ( b , i ) => { out [ order [ i ] ] = b ; } ) ;
const platform = process . platform === "darwin" ? "macOS" : process . platform === "win32" ? "Windows" : "Linux" ;
return {
brands : out . map ( ( [ brand , version ] ) => ( { brand , version } ) ) ,
fullVersionList : out . map ( ( [ brand , , version ] ) => ( { brand , version } ) ) ,
headerBrands : out . map ( ( [ b , v ] ) => ` " ${ b } ";v=" ${ v } " ` ) . join ( ", " ) ,
headerFullList : out . map ( ( [ b , , v ] ) => ` " ${ b } ";v=" ${ v } " ` ) . join ( ", " ) ,
platform ,
platformVersion : process . platform === "win32" ? "10.0.0" : process . platform === "darwin" ? "14.0.0" : "6.0.0" ,
navigatorPlatform : process . platform === "darwin" ? "MacIntel" : process . platform === "win32" ? "Win32" : "Linux x86_64" ,
architecture : "x86" , bitness : "64" , model : "" , mobile : false , wow64 : false ,
} ;
}
// "en-US" → "en-US,en"; "de" → "de". Chromium turns the list into the header
// with its own q-values (en-US,en;q=0.9), exactly like stock Chrome.
function acceptLanguageList ( loc ) {
const l = String ( loc || "en-US" ) ;
const base = l . split ( "-" ) [ 0 ] ;
return base && base !== l ? ` ${ l } , ${ base } ` : l ;
}
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage ( ) {
const loc = effLocale ( ) || app . getLocale ( ) || "en-US" ;
try {
const ua = stockChromeUA ( ) || session . defaultSession . getUserAgent ( ) ;
// A plain comma list: Chromium adds the q-values itself. Passing our own
// ";q=0.8" produced "en-US,en;q=0.8;q=0.9" on the wire — malformed, and
// one more thing that reads as "not a browser" to bot filters.
session . defaultSession . setUserAgent ( ua , acceptLanguageList ( loc ) ) ;
} catch { }
}
// Client-hint headers (sec-ch-ua family) rewritten to look like stock Chrome.
//
// Why: Cloudflare Bot Fight Mode / Turnstile flag "UA claims Chrome but client
// hints don't confirm it" as bot. Electron's default sec-ch-ua reads
// "Chromium";v="130", "Not(A:Brand";v="99"
// — no "Google Chrome" brand (that's the closed-source Google branding
// Chromium doesn't carry). Combined with a UA already stripped of the
// Electron token, the mismatch is the fingerprint. Brave, Vivaldi and Opera
// solved this by shipping their own sec-ch-ua that INCLUDES Chrome-family
// brands so Cloudflare's allow-list catches them; whybitcoincash.com and
// other CF-fronted sites are what we run into without this.
//
// Approach: onBeforeSendHeaders across every session request. Overwrite
// sec-ch-ua and sec-ch-ua-full-version-list to a canonical stock-Chrome
// pair using Chromium's REAL major version from process.versions.chrome
// (so the story stays consistent — no version straddling to fingerprint).
// sec-ch-ua-mobile is pinned to "?0" (desktop) and sec-ch-ua-platform to
// the actual OS name so a Linux user still looks like a Linux user.
function applyClientHintsSpoof ( ) {
try {
const meta = chromeBrandMetadata ( ) ;
const brands = meta . headerBrands ;
const fullList = meta . headerFullList ;
const platform = ` " ${ meta . platform } " ` ;
session . defaultSession . webRequest . onBeforeSendHeaders ( ( details , callback ) => {
const h = details . requestHeaders || { } ;
// Header names as Chromium sends them are typically kebab-case-lowercase;
// rewrite lowercase and also strip any Case-variant keys Electron
// may have set so we don't double up.
for ( const k of Object . keys ( h ) ) {
const kl = k . toLowerCase ( ) ;
if ( kl === "sec-ch-ua" || kl === "sec-ch-ua-full-version-list" ||
kl === "sec-ch-ua-mobile" || kl === "sec-ch-ua-platform" ) {
delete h [ k ] ;
}
}
h [ "sec-ch-ua" ] = brands ;
h [ "sec-ch-ua-full-version-list" ] = fullList ;
h [ "sec-ch-ua-mobile" ] = "?0" ;
h [ "sec-ch-ua-platform" ] = platform ;
callback ( { requestHeaders : h } ) ;
} ) ;
} catch ( e ) { console . warn ( "client-hints spoof setup failed:" , e ? . message ) ; }
}
// ---- session restore + background throttling ----
const sessionFile = ( ) => path . join ( app . getPath ( "userData" ) , "session.json" ) ;
// v2 format: { v: 2, urls, active }. v1 was a bare array of URLs; reading one
// maps to active = last tab, which is what the old restore loop ended up
// showing (each createTab activated itself, so the rightmost tab won).
let sessionSavedAtClose = false ;
function saveSession ( ) {
if ( sessionSavedAtClose && ! winAlive ( ) ) return ; // already captured when the window closed
try {
const live = tabs . filter ( ( t ) => ! t . settings && t . url ) ;
const active = Math . max ( 0 , live . findIndex ( ( t ) => t . id === activeId ) ) ;
fs . writeFileSync ( sessionFile ( ) , JSON . stringify ( { v : 2 , urls : live . map ( ( t ) => t . url ) , active } ) ) ;
} catch ( e ) { console . error ( "session save failed:" , e . message ) ; }
}
function loadSession ( ) {
try {
if ( ! fs . existsSync ( sessionFile ( ) ) ) return { urls : [ ] , active : 0 } ;
const raw = JSON . parse ( fs . readFileSync ( sessionFile ( ) , "utf8" ) ) ;
const list = Array . isArray ( raw ) ? raw : ( raw && Array . isArray ( raw . urls ) ? raw . urls : [ ] ) ;
const urls = list . filter ( ( u ) => typeof u === "string" && u ) ;
const last = Math . max ( urls . length - 1 , 0 ) ;
const active = Array . isArray ( raw ) ? last : Math . min ( Math . max ( Number ( raw . active ) || 0 , 0 ) , last ) ;
return { urls , active } ;
} catch { return { urls : [ ] , active : 0 } ; }
}
// Session restore, staggered. Creating every saved tab in one synchronous
// burst meant N renderer processes launching on top of chrome.html's first
// paint — on a slow or busy machine the window sat blank for seconds. The
// active tab is created first (so the user's page is on screen right away)
// and the rest arrive one per RESTORE_STAGGER_MS as background tabs, slotted
// into their saved strip positions.
const RESTORE _STAGGER _MS = 150 ;
function restoreTabs ( ) {
const saved = settings . restoreSession ? loadSession ( ) : { urls : [ ] , active : 0 } ;
if ( ! saved . urls . length ) { createTab ( ) ; return ; }
createTab ( saved . urls [ saved . active ] ) ;
const rest = saved . urls . map ( ( url , i ) => ( { url , i } ) ) . filter ( ( x ) => x . i !== saved . active ) ;
const step = ( ) => {
if ( ! rest . length || ! win || win . isDestroyed ( ) ) return ;
const { url , i } = rest . shift ( ) ;
try {
createTab ( url , { background : true } ) ;
// createTab appends; move the new tab to its saved slot. Lower indices
// are created first, so `i` is the right position on both sides of
// the active tab.
const t = tabs . pop ( ) ;
tabs . splice ( Math . min ( i , tabs . length ) , 0 , t ) ;
emitTabs ( ) ;
} catch ( e ) { console . warn ( "session restore: tab failed:" , e ? . message ) ; }
setTimeout ( step , RESTORE _STAGGER _MS ) ;
} ;
setTimeout ( step , RESTORE _STAGGER _MS ) ;
}
// ---- deferred overlay loads ----
// The floating overlays (site-info popover, engine picker, downloads,
// address suggestions, password fill, link-status pill, add-on approval)
// are WebContentsViews created with the window, but their HTML used to be
// loaded in the same tick as chrome.html — seven extra renderers racing the
// toolbar for the first paint. They now load shortly after chrome reports
// did-finish-load, or on first use, whichever comes first.
const overlayLoads = [ ] ; // [{ view, file }] waiting for loadOverlays()
let overlaysLoaded = false ;
function deferOverlayLoad ( view , file ) { overlayLoads . push ( { view , file } ) ; }
function loadOverlays ( ) {
if ( overlaysLoaded ) return ;
overlaysLoaded = true ;
for ( const { view , file } of overlayLoads . splice ( 0 ) ) {
try { view . webContents . loadFile ( file ) ; } catch ( e ) { console . warn ( ` overlay load failed ( ${ file } ): ` , e ? . message ) ; }
}
}
// Resolves once `view` has finished loading its page, loading the overlays
// first if that hasn't happened yet. Bounded so a wedged renderer can't
// hang the caller forever.
function overlayReady ( view , timeoutMs = 4000 ) {
loadOverlays ( ) ;
const wc = view . webContents ;
try { if ( wc . getURL ( ) && ! wc . isLoading ( ) ) return Promise . resolve ( ) ; } catch { }
return new Promise ( ( resolve ) => {
const timer = setTimeout ( done , timeoutMs ) ;
function done ( ) { clearTimeout ( timer ) ; wc . removeListener ( "did-finish-load" , done ) ; resolve ( ) ; }
wc . on ( "did-finish-load" , done ) ;
} ) ;
}
// Post-paint boot. Runs once chrome.html has loaded (createWindow arms a
// fallback timer in case it never does). Everything here used to start in
// whenReady, before the toolbar had painted — the BNS index build, three
// network fetches, every restored tab and seven overlay renderers all piled
// onto the main thread in the same ~300 ms, and the window sat blank with
// a white toolbar strip until they drained.
let chromeReadyDone = false ;
let bnsWarm = null ; // promise from the warmFromSnapshot() kicked off in whenReady
function onChromeReady ( ) {
if ( chromeReadyDone ) return ;
chromeReadyDone = true ;
if ( addonHost ) addonHost . signalUiReady ( ) ;
// Tabs wait for the BNS warm-up so sharedIndex is set before the first
// restored tab navigates; the rest of the multi-source refresh follows
// (see the comment block above startBnsPolling for how the sources
// cooperate).
( bnsWarm || warmFromSnapshot ( ) . catch ( ( ) => null ) ) . then ( ( ) => {
try { restoreTabs ( ) ; }
catch ( e ) { console . error ( "restoreTabs failed:" , e ? . message ) ; try { createTab ( ) ; } catch { } }
for ( const u of pendingTabUrls . splice ( 0 ) ) { try { createTab ( u ) ; } catch { } }
startBnsPolling ( ) ;
ensureIndex ( ) . catch ( ( ) => { } ) ; // fallback for first launch without a bundled snapshot
} ) ;
// Re-emit any pending update notice — harmless if nothing is pending.
emitUpdateAvailable ( ) ;
setTimeout ( loadOverlays , 250 ) ;
refreshSnapshotFromSia ( ) . catch ( ( ) => { } ) ;
checkForUpdate ( ) . catch ( ( ) => { } ) ;
refreshRemoteHomeCards ( ) . catch ( ( ) => { } ) ;
}
function applyThrottle ( ) {
for ( const t of tabs ) { try { t . view . webContents . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { } }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE _DEVICE = new Set ( [ "hid" , "serial" , "usb" , "bluetooth" , "midi" , "midiSysex" ] ) ;
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed ( kinds ) {
if ( kinds . includes ( "video" ) && settings . blockCamera ) return false ;
if ( kinds . includes ( "audio" ) && settings . blockMicrophone ) return false ;
return true ;
}
function applyPermissions ( ) {
const ses = session . defaultSession ;
ses . setPermissionRequestHandler ( ( _wc , permission , callback , details ) => {
if ( permission === "media" ) return callback ( mediaAllowed ( details ? . mediaTypes || [ ] ) ) ;
if ( permission === "geolocation" ) return callback ( effLocation ( ) !== "deny" ) ; // allow unless "hide"
if ( SENSITIVE _DEVICE . has ( permission ) ) return callback ( false ) ;
callback ( true ) ; // benign UX permissions (fullscreen, pointerLock, …)
} ) ;
ses . setPermissionCheckHandler ( ( _wc , permission , _origin , details ) => {
if ( permission === "media" ) {
if ( details ? . mediaType === "video" ) return ! settings . blockCamera ;
if ( details ? . mediaType === "audio" ) return ! settings . blockMicrophone ;
return ! ( settings . blockCamera && settings . blockMicrophone ) ;
}
if ( permission === "geolocation" ) return effLocation ( ) !== "deny" ;
if ( SENSITIVE _DEVICE . has ( permission ) ) return false ;
return true ;
} ) ;
}
// Cookie shim for cross-site embeds. Sites like the faucet hub's captcha-gated testnet
// faucets set session cookies with no SameSite attribute; Chromium defaults those to
// Lax and withholds them inside cross-site iframes, so cookie-bound captcha endpoints
// fail (tbch.googol.cash /captcha 500s without its session cookie). Rewriting their
// Set-Cookie to SameSite=None; Secure makes the cookie frame-eligible. Allowlist only —
// SameSite is CSRF protection, never relax it globally. NOTE: Electron keeps a single
// onHeadersReceived listener per session; if another is ever added, merge them.
const EMBED _COOKIE _SITES = [ "https://tbch.googol.cash/*" , "https://signetfaucet.com/*" ] ;
function applyEmbedCookieShim ( ) {
session . defaultSession . webRequest . onHeadersReceived ( { urls : EMBED _COOKIE _SITES } , ( details , callback ) => {
const headers = details . responseHeaders || { } ;
for ( const key of Object . keys ( headers ) ) {
if ( key . toLowerCase ( ) !== "set-cookie" ) continue ;
headers [ key ] = headers [ key ] . map ( ( c ) => ( /;\s*samesite=/i . test ( c ) ? c : c + "; SameSite=None; Secure" ) ) ;
}
callback ( { responseHeaders : headers } ) ;
} ) ;
}
protocol . registerSchemesAsPrivileged ( [
{ scheme : "bns" , privileges : { standard : true , secure : true , supportFetchAPI : true , stream : true } } ,
] ) ;
let resolver ;
async function getResolver ( ) {
if ( ! resolver ) resolver = await import ( ` file:// ${ RESOLVER . replace ( /\\/g , "/" ) } ` ) ;
return resolver ;
}
// ---- Tor (optional onion routing, toggled from the UI) ----
// IP privacy, not full anonymity: this browser can still be fingerprinted.
const TOR _PORT = 9152 ;
const TOR _BIN = path . join ( RES _DIR , "tor" , "tor" , "tor.exe" ) ;
const TOR _GEOIP = path . join ( RES _DIR , "tor" , "data" , "geoip" ) ;
const TOR _GEOIP6 = path . join ( RES _DIR , "tor" , "data" , "geoip6" ) ;
let torProc = null , torState = "off" ;
let torWsAgent = null ;
let SocksProxyAgent ;
async function loadSocks ( ) { if ( ! SocksProxyAgent ) ( { SocksProxyAgent } = await import ( "socks-proxy-agent" ) ) ; }
function sendTor ( ) { try { chrome ? . webContents . send ( "tor" , { state : torState } ) ; } catch { } }
async function startTor ( ) {
if ( torProc ) return ;
torState = "connecting" ; sendTor ( ) ;
await loadSocks ( ) ;
const dataDir = path . join ( app . getPath ( "userData" ) , "tor-data" ) ;
torProc = spawn ( TOR _BIN , [ "--SocksPort" , String ( TOR _PORT ) , "--ControlPort" , "0" ,
"--DataDirectory" , dataDir , "--GeoIPFile" , TOR _GEOIP , "--GeoIPv6File" , TOR _GEOIP6 ] , { windowsHide : true } ) ;
torProc . stdout . on ( "data" , ( d ) => { if ( /Bootstrapped 100%/ . test ( d . toString ( ) ) ) torReady ( ) ; } ) ;
torProc . stderr . on ( "data" , ( ) => { } ) ;
torProc . on ( "exit" , ( ) => { torProc = null ; if ( torState !== "off" ) torOff ( ) ; } ) ;
}
function torReady ( ) {
torState = "on" ;
torWsAgent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
session . defaultSession . setProxy ( { proxyRules : ` socks5://127.0.0.1: ${ TOR _PORT } ` } ) ;
applyWebRTCPolicy ( ) ;
sendTor ( ) ;
}
function torOff ( ) {
torState = "off" ; torWsAgent = null ;
session . defaultSession . setProxy ( { proxyRules : "" } ) ;
applyWebRTCPolicy ( ) ;
sendTor ( ) ;
}
function stopTor ( ) { torOff ( ) ; if ( torProc ) { try { torProc . kill ( ) ; } catch { } torProc = null ; } }
// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy
// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the
// way the Tor Browser does). This is the usual reason a site still sees your IP.
function applyWebRTCPolicy ( ) {
const policy = webrtcPolicy ( ) ;
for ( const t of tabs ) { try { t . view . webContents . setWebRTCIPHandlingPolicy ( policy ) ; } catch { } }
}
class TorWebSocket extends WebSocket { constructor ( url , opts ) { super ( url , { agent : torWsAgent , ... opts } ) ; } }
const currentWS = ( ) => ( torState === "on" ? TorWebSocket : WebSocket ) ;
function nodeRequest ( urlStr , { method = "GET" , headers = { } , agent } = { } ) {
return new Promise ( ( resolve , reject ) => {
const u = new URL ( urlStr ) ;
const lib = u . protocol === "https:" ? https : http ;
const req = lib . request ( u , { method , headers , agent } , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ; req . end ( ) ;
} ) ;
}
async function contentFetch ( url , init = { } ) {
if ( torState === "on" ) { await loadSocks ( ) ; return nodeRequest ( url , { ... init , agent : new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) } ) ; }
const r = await fetch ( url , init ) ;
return { status : r . status , contentType : r . headers . get ( "content-type" ) , buffer : Buffer . from ( await r . arrayBuffer ( ) ) } ;
}
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
// isn't in ICANN DNS → "fetch failed".
// 2. It validates TLS against the public CA store, but BNS certs are signed
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
// record's SHA-256 fingerprint, which we pin against here. See
// Argus/src/lib/ca.js for the underlying trust model, and the parallel
// implementation in Argus/src/gateway/public-gateway.mjs — keep both in
// step. This code path also runs through Tor when Tor is on.
function certFp ( cert ) {
const fp = cert && cert . fingerprint256 ;
return fp ? fp . toLowerCase ( ) . replace ( /:/g , "" ) : "" ;
}
async function pinnedHttpsGet ( ip , port , servername , reqPath , expectedFp ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port , servername , method : "GET" , path : reqPath ,
headers : { host : servername , "user-agent" : "theseus/1" } ,
} ;
opts [ "rejectUnauthorized" ] = false ; // fingerprint pin below is the gate.
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = https . request ( opts , ( res ) => {
const gotFp = certFp ( res . socket . getPeerCertificate ( false ) ) ;
if ( gotFp !== expectedFp ) {
res . socket . destroy ( ) ;
return reject ( new Error ( ` tls fingerprint mismatch for ${ servername } : got ${ gotFp } , expected ${ expectedFp } ` ) ) ;
}
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . setTimeout ( 15000 , ( ) => { req . destroy ( new Error ( "tls request timeout" ) ) ; } ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
async function httpGetByIp ( ip , reqPath , hostHeader ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port : 80 , method : "GET" , path : reqPath ,
headers : { host : hostHeader , "user-agent" : "theseus/1" } ,
} ;
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = http . request ( opts , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ; req . end ( ) ;
} ) ;
}
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin.
async function ipRequest ( ip , reqPath , hostHeader , tlsFingerprint ) {
if ( tlsFingerprint ) return await pinnedHttpsGet ( ip , 443 , hostHeader , reqPath , String ( tlsFingerprint ) . toLowerCase ( ) ) ;
return await httpGetByIp ( ip , reqPath , hostHeader ) ;
}
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
async function fetchOpenSearch ( href ) {
try {
const r = await contentFetch ( href , { } ) ;
const xml = r . buffer . toString ( "utf8" ) ;
const nameM = xml . match ( /<ShortName>([^<]+)<\/ShortName>/i ) ;
const urlM = xml . match ( /<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i )
|| xml . match ( /<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i ) ;
if ( ! urlM ) return null ;
const template = urlM [ 1 ] . replace ( /\{searchTerms\??\}/gi , "%s" ) . replace ( /\{[^}]*\}/g , "" ) ; // drop other {params}
if ( ! template . includes ( "%s" ) || ! /^https?:\/\//i . test ( template ) ) return null ;
return { name : ( nameM ? nameM [ 1 ] : new URL ( href ) . hostname ) . trim ( ) . slice ( 0 , 40 ) , url : template } ;
} catch { return null ; }
}
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
// build. The last discovered list is cached to disk and tried first next launch.
let electrumPool = null ;
let lastElectrumRefresh = 0 ;
const electrumFile = ( ) => path . join ( app . getPath ( "userData" ) , "electrum-servers.json" ) ;
const serverKey = ( s ) => ( typeof s === "string" ? s : s && s . url ) ;
function mergeServers ( preferred , rest ) {
const seen = new Set ( ) , out = [ ] ;
for ( const s of [ ... ( preferred || [ ] ) , ... ( rest || [ ] ) ] ) {
const k = serverKey ( s ) ;
if ( k && ! seen . has ( k ) ) { seen . add ( k ) ; out . push ( s ) ; }
}
return out ;
}
async function initElectrumPool ( ) {
const { CHIPNET _ELECTRUM } = await getResolver ( ) ;
let saved = [ ] ;
try { if ( fs . existsSync ( electrumFile ( ) ) ) saved = JSON . parse ( fs . readFileSync ( electrumFile ( ) , "utf8" ) ) ; } catch { }
electrumPool = mergeServers ( saved , CHIPNET _ELECTRUM ) ; // discovered first, seed always kept
}
async function refreshElectrumPool ( ) {
try {
const { fetchElectrumServers } = await getResolver ( ) ;
const found = await fetchElectrumServers ( { WebSocket : currentWS ( ) , directIP : true , electrum : electrumPool } ) ;
if ( found && found . length ) {
electrumPool = mergeServers ( found , electrumPool ) ;
try { fs . writeFileSync ( electrumFile ( ) , JSON . stringify ( found , null , 2 ) ) ; } catch { }
}
} catch { /* list unpublished or unreachable — keep the current pool */ }
}
function maybeRefreshElectrum ( ) {
if ( Date . now ( ) - lastElectrumRefresh < 30 * 60 * 1000 ) return ;
lastElectrumRefresh = Date . now ( ) ;
refreshElectrumPool ( ) ; // fire-and-forget
}
const entries = new Map ( ) ;
// Cached chain index. Building it (connect + fetch every beacon tx) is the slow
// part, and it was happening on EVERY navigation. Build once, reuse for lookups,
// and refresh in the background — so .bch pages open near-instantly after the first.
let sharedIndex = null , indexBuiltAt = 0 , indexBuilding = null ;
const INDEX _TTL = 45_000 ;
// ---- warm-start from a pre-fetched beacon snapshot ------------------------
//
// The first ensureIndex() call walks the whole beacon over electrum — that's
// the "empty tab spinner" a user sees on cold start. The snapshot pipeline
// (Argus/src/publish-name-mirror.mjs) makes that walk skippable: an operator
// publishes the raw history+txs to Sia; every Theseus install carries a
// starter snapshot bundled at build time, then GETs a fresher one on boot.
// The warm sharedIndex is served immediately; the live buildIndex runs in the
// background to catch any events past the snapshot's asOfHeight.
//
// Sources tried in order:
// 1. app.getPath("userData")/bns-name-snapshot.json — the fresher copy
// written on our last successful Sia refresh (persisted across launches)
// 2. RES_DIR/bns-name-snapshot.json — the copy bundled with the build (stale
// by definition, but strictly better than "no index at all")
// Both are optional; if neither exists, ensureIndex() does what it always did
// and the user sees the same cold-start experience as before this change.
const SNAPSHOT _BUNDLED = path . join ( RES _DIR , "bns-name-snapshot.json" ) ;
const SIA _SNAPSHOT _URL = "https://s3.silentmode.st:8600/bns/name-list.json" ;
function snapshotUserPath ( ) { return path . join ( app . getPath ( "userData" ) , "bns-name-snapshot.json" ) ; }
function readSnapshotFrom ( p ) {
try {
if ( ! fs . existsSync ( p ) ) return null ;
const parsed = JSON . parse ( fs . readFileSync ( p , "utf8" ) ) ;
// A minimal shape check — buildIndexFromSnapshot will throw with a
// clear message on anything else, but we want to log which source
// was chosen for diagnostics.
if ( ! parsed || ! Array . isArray ( parsed . history ) ) return null ;
return parsed ;
} catch { return null ; }
}
// In-memory copy of the raw snapshot state (`{beacon, history, txs, ...}`)
// that drives the sharedIndex. Kept alongside sharedIndex so the delta poll
// can merge new beacon events into it without re-reading from disk on every
// refresh. Written to disk after each successful merge — the user cache is
// always the most up-to-date snapshot this process knows about, so a restart
// resumes from where we left off instead of from the stale bundled copy.
let currentSnapshotState = null ;
async function warmFromSnapshot ( ) {
if ( sharedIndex ) return sharedIndex ; // already warm — nothing to do
const { buildIndexFromSnapshot } = await getResolver ( ) ;
if ( ! buildIndexFromSnapshot ) return null ; // running against an older resolver-web.js
const snap = readSnapshotFrom ( snapshotUserPath ( ) ) || readSnapshotFrom ( SNAPSHOT _BUNDLED ) ;
if ( ! snap ) return null ;
try {
const idx = buildIndexFromSnapshot ( { snapshot : snap } ) ;
sharedIndex = idx ;
currentSnapshotState = snap ;
// Deliberately set indexBuiltAt to 0 so the first real navigation still
// triggers a live refresh — the snapshot is a floor, not a ceiling.
indexBuiltAt = 0 ;
refreshBcnrTlds ( idx ) ;
console . log ( ` [bns] warm-started from snapshot: ${ idx . size } names @ height= ${ snap . asOfHeight ? ? "?" } root= ${ snap . root ? ? "?" } ` ) ;
return idx ;
} catch ( e ) {
console . warn ( "[bns] snapshot warm-start failed:" , e . message ) ;
return null ;
}
}
// ---- continuous background delta refresh --------------------------------
//
// Every POLL_INTERVAL_MS the browser opens ONE electrum connection, fetches
// the beacon's current history (a single fast call), diffs it against the
// snapshot we already hold in memory, and only fetches the verbose tx bodies
// for the txids we don't have yet. Then we rebuild the index locally and
// persist the enlarged snapshot to disk.
//
// This turns "index refresh" from ~60 s of round-trips (fetch every verbose
// tx for the whole beacon) into ~1 s of round-trips per new event. And
// because it runs while the browser is idle, by the time the user actually
// types a name into the URL bar there is nothing to wait for.
//
// Sources conspiring for freshness:
// * warmFromSnapshot on boot — sharedIndex is warm before nav
// * this poll loop, every 30 s — keeps sharedIndex live and current
// * refreshSnapshotFromSia on boot — pulls the operator's published
// snapshot from Sia for the NEXT
// boot; if this browser was closed
// for a week, next launch skips
// days of catch-up
// * ensureIndex still exists — full-walk fallback for the case
// where the poll cannot connect
// (offline first launch, etc.)
const POLL _INTERVAL _MS = 30_000 ;
let pollInFlight = null ;
let pollTimer = null ;
let pollAttempts = 0 , pollLastError = null ;
async function pollAndMerge ( ) {
if ( pollInFlight ) return pollInFlight ;
pollInFlight = ( async ( ) => {
pollAttempts ++ ;
try {
const R = await getResolver ( ) ;
if ( ! R . connectElectrum || ! R . BEACON _SCRIPTHASH || ! R . buildIndexFromSnapshot ) {
// Older resolver-web without the delta primitives — nothing to do.
return null ;
}
if ( ! electrumPool ) await initElectrumPool ( ) ;
// Base state: memory > user cache > bundled > empty. The "empty" branch
// is what turns the very first cold start (no bundled snapshot present
// because we shipped a build that predates snapshotting) into a full
// rebuild — mergeFreshHistory will fetch every tx.
let snap = currentSnapshotState
|| readSnapshotFrom ( snapshotUserPath ( ) )
|| readSnapshotFrom ( SNAPSHOT _BUNDLED )
|| { beacon : R . BEACON _SCRIPTHASH , history : [ ] , txs : { } } ;
const el = await R . connectElectrum ( {
electrum : electrumPool , WebSocket : currentWS ( ) , directIP : true ,
} ) ;
try {
const freshHistory = await el . call ( "blockchain.scripthash.get_history" , [ R . BEACON _SCRIPTHASH ] ) ;
const known = new Set ( snap . history . map ( ( h ) => h . tx _hash ) ) ;
// Merge fresh into snapshot history (dedup by tx_hash, keep fresh height —
// an event that was mempool at snapshot time now has a real height).
const merged = new Map ( snap . history . map ( ( h ) => [ h . tx _hash , h ] ) ) ;
const txs = { ... ( snap . txs || { } ) } ;
let added = 0 ;
for ( const h of freshHistory ) {
if ( ! known . has ( h . tx _hash ) ) {
try {
txs [ h . tx _hash ] = await el . call ( "blockchain.transaction.get" , [ h . tx _hash , true ] ) ;
added ++ ;
} catch { /* unreadable — the reduction rules ignore missing txs */ }
}
merged . set ( h . tx _hash , { tx _hash : h . tx _hash , height : h . height } ) ;
}
const history = [ ... merged . values ( ) ] ;
currentSnapshotState = { ... snap , beacon : R . BEACON _SCRIPTHASH , history , txs } ;
const idx = R . buildIndexFromSnapshot ( { snapshot : currentSnapshotState } ) ;
sharedIndex = idx ;
indexBuiltAt = Date . now ( ) ;
pollLastError = null ;
refreshBcnrTlds ( idx ) ;
// Persist for the next launch. Failure here is not fatal — worst case
// we redo this merge on the next start.
try {
fs . mkdirSync ( path . dirname ( snapshotUserPath ( ) ) , { recursive : true } ) ;
fs . writeFileSync ( snapshotUserPath ( ) , JSON . stringify ( currentSnapshotState ) ) ;
} catch { /* readonly userdata / disk full — skip */ }
if ( added > 0 ) {
console . log ( ` [bns] delta-refresh: + ${ added } new tx ${ added === 1 ? "" : "s" } (total ${ history . length } , index has ${ idx . size } names) ` ) ;
}
} finally { try { el . close ( ) ; } catch { } }
} catch ( e ) {
pollLastError = e && e . message || String ( e ) ;
// Silent — the browser stays usable via sharedIndex (last-known-good) or
// the ensureIndex fallback on the next navigation.
} finally { pollInFlight = null ; }
} ) ( ) ;
return pollInFlight ;
}
function startBnsPolling ( ) {
if ( pollTimer ) return ;
// Fire immediately so the boot warm-start gets a delta pass right away, in
// parallel with the Sia refresh and the ensureIndex fallback below. Then
// every POLL_INTERVAL_MS while the browser is running.
pollAndMerge ( ) . catch ( ( ) => { } ) ;
pollTimer = setInterval ( ( ) => pollAndMerge ( ) . catch ( ( ) => { } ) , POLL _INTERVAL _MS ) ;
}
function stopBnsPolling ( ) { if ( pollTimer ) { clearInterval ( pollTimer ) ; pollTimer = null ; } }
// Fetch the latest published snapshot from Sia and persist it as the user
// copy — the next launch (or the next warmFromSnapshot call) picks it up.
// Fire-and-forget: failures are silent; the live buildIndex path is the
// authoritative catch-up.
async function refreshSnapshotFromSia ( ) {
try {
const res = await fetch ( SIA _SNAPSHOT _URL , { redirect : "follow" } ) ;
if ( ! res . ok ) return ;
const body = await res . text ( ) ;
const parsed = JSON . parse ( body ) ;
if ( ! parsed || ! Array . isArray ( parsed . history ) ) return ;
try { fs . mkdirSync ( path . dirname ( snapshotUserPath ( ) ) , { recursive : true } ) ; } catch { }
fs . writeFileSync ( snapshotUserPath ( ) , body ) ;
console . log ( ` [bns] snapshot refreshed from Sia: ${ parsed . history . length } beacon txs root= ${ parsed . root ? ? "?" } ` ) ;
} catch { /* offline / Sia unreachable / bad JSON — the live path still works */ }
}
async function ensureIndex ( force = false ) {
const { buildIndex } = await getResolver ( ) ;
if ( ! electrumPool ) await initElectrumPool ( ) ;
if ( ! force && sharedIndex && Date . now ( ) - indexBuiltAt < INDEX _TTL ) return sharedIndex ;
if ( indexBuilding ) return indexBuilding ; // dedupe concurrent builds
indexBuilding = buildIndex ( { WebSocket : currentWS ( ) , directIP : true , electrum : electrumPool } )
. then ( ( idx ) => { sharedIndex = idx ; indexBuiltAt = Date . now ( ) ; refreshBcnrTlds ( idx ) ; return idx ; } )
. finally ( ( ) => { indexBuilding = null ; } ) ;
// If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return ( sharedIndex && ! force ) ? sharedIndex : indexBuilding ;
}
async function resolveHost ( host ) {
const { normalizeName } = await getResolver ( ) ;
let key ; try { key = normalizeName ( host ) ; } catch { return null ; }
// Prefer the warm sharedIndex — the poll loop keeps it live. If we don't
// have one yet (very cold start, snapshot missing AND poll hasn't landed
// yet), fall through to a full ensureIndex build.
let idx = sharedIndex || ( await ensureIndex ( ) ) ;
let entry = idx . get ( key ) ? ? null ;
// Miss on a possibly-stale index → try a fast delta refresh (1 history +
// only-new-tx bodies), not a full walk. Only if we've had time for at least
// one poll to land (indexBuiltAt updated by both ensureIndex and the delta
// poll). If the delta path is unavailable (older resolver-web), fall back
// to a full rebuild — same behavior as before this change.
if ( ! entry && Date . now ( ) - indexBuiltAt > 8_000 ) {
const R = await getResolver ( ) ;
if ( R . connectElectrum && R . buildIndexFromSnapshot ) {
await pollAndMerge ( ) ;
entry = sharedIndex ? . get ( key ) ? ? null ;
} else {
idx = await ensureIndex ( true ) ;
entry = idx . get ( key ) ? ? null ;
}
}
if ( entry ) entries . set ( host . toLowerCase ( ) , { entry , host : host . toLowerCase ( ) } ) ;
// Signed DNS records ride alongside the on-chain answer — started here,
// never awaited (see attachDnsRecords).
if ( entry ) attachDnsRecords ( entry ) ;
maybeRefreshElectrum ( ) ;
return entry ;
}
const MIME = { html : "text/html; charset=utf-8" , htm : "text/html; charset=utf-8" , css : "text/css" , js : "text/javascript" ,
json : "application/json" , png : "image/png" , jpg : "image/jpeg" , jpeg : "image/jpeg" , gif : "image/gif" , svg : "image/svg+xml" ,
ico : "image/x-icon" , webp : "image/webp" , woff2 : "font/woff2" , woff : "font/woff" , txt : "text/plain" , wasm : "application/wasm" } ;
const guessType = ( p ) => MIME [ p . split ( "." ) . pop ( ) ? . toLowerCase ( ) ] || "application/octet-stream" ;
async function serveBns ( request ) {
const url = new URL ( request . url ) ;
const host = url . hostname . toLowerCase ( ) ;
const reqPath = decodeURIComponent ( url . pathname ) || "/" ;
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)
let rec = entries . get ( host ) ;
if ( ! rec ) { try { await resolveHost ( host ) ; } catch { } rec = entries . get ( host ) ; }
if ( ! rec ) return new Response ( "NXDOMAIN: " + host , { status : 404 , headers : { "content-type" : "text/plain" } } ) ;
const r = rec . entry . records ;
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
// whole namespace via Host routing; `s3` semantics are exact-key per name.
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
// apex (host === entry.name), current priority stands. See public-gateway.mjs
// for the full argument; keep this in step with that file.
const isSubdomain = host !== rec . entry . name ;
const serveIp = async ( ) => {
// See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls`
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS).
const up = await ipRequest ( r . ip , reqPath + url . search , host , r . tls ) ;
return new Response ( up . buffer , { status : up . status , headers : { "content-type" : up . contentType || guessType ( reqPath ) } } ) ;
} ;
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays
// on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and
// sends `Host:` of the upstream so vhost-based origins answer correctly.
// Not applied under subdomain inheritance — see public-gateway.mjs and the
// matching test in record-picker.test.mjs for why. Preserve any path prefix
// in r.p (e.g. `{p:"https://api.host/v1"}` + request "/x" → ".../v1/x").
const serveP = async ( ) => {
const base = new URL ( r . p ) ;
const prefix = base . pathname === "/" ? "" : base . pathname . replace ( /\/$/ , "" ) ;
const target = base . origin + prefix + reqPath + url . search ;
const up = await fetch ( target , { redirect : "manual" } ) ;
const body = Buffer . from ( await up . arrayBuffer ( ) ) ;
const ct = up . headers . get ( "content-type" ) || guessType ( reqPath ) ;
return new Response ( body , { status : up . status , headers : { "content-type" : ct } } ) ;
} ;
try {
if ( isSubdomain && r . ip ) return await serveIp ( ) ;
if ( r . h ) { if ( reqPath === "/" ) return new Response ( r . h , { headers : { "content-type" : "text/html; charset=utf-8" } } ) ; return new Response ( "not found" , { status : 404 } ) ; }
if ( r . s3 ) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch ( ` ${ GATEWAY } /bns/ ${ host } ${ reqPath } ${ url . search } ` , { } ) ;
const ct = up . contentType && up . contentType !== "application/octet-stream"
? up . contentType : guessType ( reqPath === "/" ? "index.html" : reqPath ) ;
let body = up . buffer ;
if ( ct . includes ( "text/html" ) ) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer . from ( body . toString ( "utf8" ) . replace ( /<base\s+href="\/bns\/[^"]*">/i , "" ) , "utf8" ) ;
}
return new Response ( body , { status : up . status , headers : { "content-type" : ct } } ) ;
}
if ( r . ip ) return await serveIp ( ) ;
if ( ! isSubdomain && r . p ) return await serveP ( ) ;
if ( r . u ) return Response . redirect ( r . u , 302 ) ;
// No on-chain content record. If the owner published a signed DNS A
// record, that server is the only way to reach the name — same Host-header
// semantics as an `ip` record (and the on-chain `tls` pin still applies).
const dnsIp = await dnsAddressFor ( rec . entry ) ;
if ( dnsIp ) {
const up = await ipRequest ( dnsIp , reqPath + url . search , host , r . tls ) ;
return new Response ( up . buffer , { status : up . status , headers : { "content-type" : up . contentType || guessType ( reqPath ) } } ) ;
}
return new Response ( JSON . stringify ( rec . entry , null , 2 ) , { headers : { "content-type" : "application/json" } } ) ;
} catch ( e ) {
// The upstream fetch failed — relay unreachable, DNS stalling, site's
// own server down. A bare "fetch failed" reads as "Theseus is broken";
// name the upstream and the cause code so it reads as what it is,
// and give a retry.
const cause = e ? . cause ? . code || e ? . cause ? . message || "" ;
const upstream = r . s3 ? new URL ( GATEWAY ) . host : r . p ? ( ( ) => { try { return new URL ( r . p ) . host ; } catch { return r . p ; } } ) ( ) : r . ip ? String ( r . ip ) : "" ;
return new Response ( bnsErrorHtml ( { host , message : e ? . message || String ( e ) , cause , upstream } ) ,
{ status : 502 , headers : { "content-type" : "text/html; charset=utf-8" } } ) ;
}
}
// Error surface for a bns:// fetch that failed after the name resolved.
// Self-contained HTML (this is a protocol handler response, not a tab
// navigation, so error.html's loadFile path doesn't apply).
function bnsErrorHtml ( { host , message , cause , upstream } ) {
const esc = ( s ) => String ( s || "" ) . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) . replace ( /"/g , """ ) ;
const hint = /ETIMEDOUT|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/ . test ( cause )
? ` Theseus resolved <b> ${ esc ( host ) } </b> but could not reach <b> ${ esc ( upstream || "its server" ) } </b> from this network. Check your connection or VPN and try again. `
: /ENOTFOUND|EAI_AGAIN/ . test ( cause )
? ` Your system DNS could not look up <b> ${ esc ( upstream || "the server" ) } </b>. A stale or unreachable DNS server on one of your network adapters is the usual cause. `
: ` Theseus resolved <b> ${ esc ( host ) } </b> but the content fetch from <b> ${ esc ( upstream || "its server" ) } </b> failed. ` ;
return ` <!doctype html><html><head><meta charset="utf-8"><title>Can’ t reach ${ esc ( host ) } </title>
< style > : root { color - scheme : dark } body { margin : 0 ; background : # 0 f1420 ; color : # e8ecf3 ; font : 15 px / 1.5 system - ui , sans - serif ; display : grid ; place - items : center ; min - height : 100 vh }
. card { max - width : 560 px ; padding : 32 px 36 px ; background : # 1 b2330 ; border : 1 px solid # ffffff1f ; border - radius : 14 px } h1 { font - size : 20 px ; margin : 0 0 10 px } p { margin : 8 px 0 ; color : # b9c2d0 }
code { font : 12.5 px ui - monospace , monospace ; color : # D6FF3D ; background : # 0 f1420 ; padding : 2 px 6 px ; border - radius : 5 px }
a . btn { display : inline - block ; margin - top : 16 px ; padding : 9 px 16 px ; border - radius : 999 px ; background : # D6FF3D ; color : # 0 f1420 ; font - weight : 600 ; text - decoration : none } < / s t y l e > < / h e a d >
< body > < div class = "card" > < h1 > Can ’ t reach $ { esc ( host ) } < / h 1 > < p > $ { h i n t } < / p >
< p > < code > $ { esc ( message ) } $ { cause ? " · " + esc ( cause ) : "" } < / c o d e > < / p >
< a class = "btn" href = "javascript:location.reload()" > Try again < / a > < / d i v > < / b o d y > < / h t m l > ` ;
}
// ---- window + tabs ----
let win , chrome ;
// The window can be gone while tabs and app windows still fire events.
const winAlive = ( ) => ! ! win && ! win . isDestroyed ( ) ;
let CHROME _H = 84 ; // grows when an extra bar (Tor notice / BCNR offer) is shown
// Site-info popover: a floating overlay VIEW on top of the page content, so it
// never pushes the page down. Positioned under the address-bar badge on demand.
let popover , popVisible = false , popPos = { x : 8 , y : 90 } ;
const POP _W = 360 ; let popH = 210 ; // popH is updated to fit the popover's content
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker , epVisible = false , epPos = { x : 8 , y : 90 } ;
const EP _W = 250 ; let epH = 320 ;
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop , dlVisible = false , dlPos = { x : 8 , y : 90 } ;
const DL _W = 340 ; let dlH = 240 ;
// Address-bar suggestions dropdown. Floating overlay under the address bar.
let addressPicker , apVisible = false , apPos = { x : 60 , y : 70 } ;
let apW = 520 ; let apH = 60 ;
// Password-fill picker — floating dropdown under a small key chip in the
// toolbar that appears only when the vault is unlocked AND the current
// site has matching credentials.
let pwFillPop , pwfVisible = false , pwfPos = { x : 8 , y : 90 } ;
const PWF _W = 280 ; let pwfH = 80 ;
// Link-hover status bar — small pill at the bottom-left of the window
// showing the href when the mouse hovers a link (Chrome / Firefox style).
// Hidden when hover leaves. Fed by webContents.update-target-url on every
// tab; the pill auto-sizes to its text.
let linkStatus , linkStatusVisible = false ;
let linkStatusW = 100 , linkStatusH = 22 ;
// Add-on sidebar — one right-anchored WebContentsView that hosts an add-on's
// registered panel HTML. First registered panel wins for the MVP; a tab
// strip / picker for multiple panels lands in a later rev. Sidebar loads
// nothing until the user actively opens it, so the perf cost of an unused
// add-on is nil.
let sidebar , sidebarVisible = false , sidebarActivePanelId = null ;
// Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user.
const SIDEBAR _W _MIN = 200 , SIDEBAR _W _MAX = 800 , SIDEBAR _W _DEFAULT = 340 ;
let sidebarW = SIDEBAR _W _DEFAULT ;
// When a panel asks for "maximize" (screenshot editor wanting the full canvas
// area), we widen the sidebar to fill the window and remember the previous
// width so restore drops us back exactly. Non-persisted: closing/reopening
// Theseus always starts un-maximized.
let sidebarMaximized = false ;
let sidebarPreMaxW = SIDEBAR _W _DEFAULT ;
function sidebarMaxWidth ( ) {
if ( ! win ) return SIDEBAR _W _MAX ;
const { width } = win . getContentBounds ( ) ;
return Math . max ( SIDEBAR _W _MIN , width ) ;
}
// The add-on host is the single point of truth for what's installed and
// active. Populated by initAddons() at app-ready time.
let addonHost = null ;
// One-shot proxy-login handler installed by setSessionProxy when the
// extension provided credentials. Removed and re-installed on every
// setSessionProxy call so the current credentials always match the
// current proxy.
let proxyLoginHandler = null ;
const { AddonHost } = require ( "./addons-host.js" ) ;
const addonUpdater = require ( "./addon-updater.js" ) ;
const { PUBKEYS _HEX : ADDON _UPDATE _PUBKEYS } = require ( "./addon-update-pubkeys.js" ) ;
// Extensions live under <userData>\extensions (installed copies), with their
// per-extension storage, backups of replaced copies and staged updates in
// sibling folders. These were addons / addons-data / addons-backups /
// addons-updates-staged; migrateExtensionDirs() renames a profile's old
// folders once, before the host first reads them.
function addonsUserDir ( ) { return path . join ( app . getPath ( "userData" ) , "extensions" ) ; }
function addonsDataDir ( ) { return path . join ( app . getPath ( "userData" ) , "extensions-data" ) ; }
function addonsBackupDir ( ) { return path . join ( app . getPath ( "userData" ) , "extensions-backups" ) ; }
function addonsStagedDir ( ) { return path . join ( app . getPath ( "userData" ) , "extensions-staged" ) ; }
function migrateExtensionDirs ( ) {
const ud = app . getPath ( "userData" ) ;
for ( const [ from , to ] of [ [ "addons" , "extensions" ] , [ "addons-data" , "extensions-data" ] , [ "addons-backups" , "extensions-backups" ] , [ "addons-updates-staged" , "extensions-staged" ] ] ) {
const src = path . join ( ud , from ) , dst = path . join ( ud , to ) ;
if ( ! fs . existsSync ( src ) || fs . existsSync ( dst ) ) continue ;
try { fs . renameSync ( src , dst ) ; console . log ( ` [addons] moved ${ from } -> ${ to } ` ) ; }
catch ( e ) { console . warn ( ` [addons] could not move ${ from } -> ${ to } : ` , e ? . message ) ; }
}
}
function bundledAddonsDir ( ) { return path . join ( RES _DIR , "bundled-addons" ) ; }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
// addons directory. Users can then edit, disable, or delete them — the
// framework treats bundled and user add-ons identically, no special path
// handling.
//
// Update rule: reseed when the bundled addon.json version differs from the
// user's on-disk addon.json version. Before reseeding, rename the user copy
// to <id>-<oldver>-<stamp>/ under <userData>/addons-backups/ so any local
// edits survive. If the two versions match we leave the folder alone —
// users who fork by bumping their own version stay pinned; users who edit
// files without bumping accept upstream updates.
function readAddonVersion ( dir ) {
try { return JSON . parse ( fs . readFileSync ( path . join ( dir , "addon.json" ) , "utf8" ) ) ? . version ? ? null ; }
catch { return null ; }
}
// One-time migration for the bchwallet → aegis rename + the siawallet
// retirement. Idempotent: after the first run the sources are gone and
// subsequent runs are no-ops.
//
// - addons/bchwallet/ → addons-backups/bchwallet-migrated-<stamp>/
// (bundle folder is now aegis/; leaving the old dir active would load
// the pre-rename copy as a second addon under the same id and clash).
// - addons-data/bchwallet.json → addons-data/aegis.json (COPY, so any
// downgrade to a 0.3.x build can still read its own storage).
// - addons/siawallet/ → addons-backups/siawallet-migrated-<stamp>/
// (folded into Aegis via absorbs: ["siawallet"]; keeping it running
// would show duplicate Sia UI). Its data file stays under
// addons-data/ untouched — Aegis derives its own SC walletdUrl
// per-sub-wallet, so users re-paste their URL in Aegis Settings.
function migrateAegisRename ( ) {
const stamp = new Date ( ) . toISOString ( ) . replace ( /[:.]/g , "-" ) ;
const backups = addonsBackupDir ( ) ;
try { fs . mkdirSync ( backups , { recursive : true } ) ; } catch { }
const addonsRoot = addonsUserDir ( ) ;
const dataRoot = addonsDataDir ( ) ;
const bchDir = path . join ( addonsRoot , "bchwallet" ) ;
const aegisDir = path . join ( addonsRoot , "aegis" ) ;
const bchJson = path . join ( dataRoot , "bchwallet.json" ) ;
const aegisJson = path . join ( dataRoot , "aegis.json" ) ;
// Copy legacy storage into the new file exactly once, only when the new
// file doesn't exist yet (a downgrade to 0.3.x would otherwise clobber
// fresh state written by 0.4+).
if ( fs . existsSync ( bchJson ) && ! fs . existsSync ( aegisJson ) ) {
try { fs . copyFileSync ( bchJson , aegisJson ) ; console . log ( "[addons] migrated bchwallet.json -> aegis.json" ) ; }
catch ( err ) { console . warn ( "[addons] migrate storage failed:" , err ? . message ) ; }
}
// Retire the bchwallet folder EVERY LAUNCH it exists. The signed OTA
// update endpoint may still advertise `id=bchwallet` updates, and
// promoteStagedUpdates (which runs before us) could reinstall it. Left
// active it would load as a second Aegis under a stale id, doubling
// every wallet in the sidebar.
if ( fs . existsSync ( bchDir ) ) {
const backup = path . join ( backups , ` bchwallet-migrated- ${ stamp } ` ) ;
try { fs . renameSync ( bchDir , backup ) ; console . log ( ` [addons] retired addons/bchwallet -> addons-backups/ ${ path . basename ( backup ) } ${ fs . existsSync ( aegisDir ) ? " (aegis already present)" : " (folder renamed to aegis/)" } ` ) ; }
catch ( err ) { console . warn ( "[addons] retire bchwallet failed:" , err ? . message ) ; }
}
const siaDir = path . join ( addonsRoot , "siawallet" ) ;
if ( fs . existsSync ( siaDir ) ) {
const backup = path . join ( backups , ` siawallet-migrated- ${ stamp } ` ) ;
try { fs . renameSync ( siaDir , backup ) ; console . log ( ` [addons] retired addons/siawallet -> addons-backups/ ${ path . basename ( backup ) } (absorbed by aegis) ` ) ; }
catch ( err ) { console . warn ( "[addons] retire siawallet failed:" , err ? . message ) ; }
}
}
function seedBundledAddons ( ) {
const dst = addonsUserDir ( ) ;
try { fs . mkdirSync ( dst , { recursive : true } ) ; } catch { }
const src = bundledAddonsDir ( ) ;
if ( ! fs . existsSync ( src ) ) return ;
let entries = [ ] ;
try { entries = fs . readdirSync ( src , { withFileTypes : true } ) ; } catch { return ; }
for ( const e of entries ) {
if ( ! e . isDirectory ( ) ) continue ;
const from = path . join ( src , e . name ) ;
const target = path . join ( dst , e . name ) ;
const bundleVer = readAddonVersion ( from ) ;
if ( ! bundleVer ) continue ; // broken bundle — skip rather than corrupt user state
if ( fs . existsSync ( target ) ) {
const userVer = readAddonVersion ( target ) ;
// Only reseed when the bundle is STRICTLY NEWER than what's in
// userData. The old check `userVer === bundleVer ? continue` would
// reseed whenever the versions differed — including the OTA case
// where promoteStagedUpdates just promoted a newer addon than the
// one baked into the installer, silently downgrading it on the same
// boot. Version-compare with the same cmpVer helper the promoter
// uses so both sides agree on ordering.
if ( userVer && cmpVersions ( userVer , bundleVer ) >= 0 ) continue ;
// Backups go in a sibling folder so AddonHost's directory scan doesn't
// pick them up as duplicate addons with the same manifest id.
const backupsRoot = addonsBackupDir ( ) ;
try { fs . mkdirSync ( backupsRoot , { recursive : true } ) ; } catch { }
const stamp = new Date ( ) . toISOString ( ) . replace ( /[:.]/g , "-" ) ;
const backup = path . join ( backupsRoot , ` ${ e . name } - ${ userVer ? ? "unknown" } - ${ stamp } ` ) ;
try { fs . renameSync ( target , backup ) ; }
catch ( err ) { console . warn ( ` [addons] backup ${ e . name } failed, skipping reseed: ` , err ? . message ) ; continue ; }
console . log ( ` [addons] reseed ${ e . name } : ${ userVer ? ? "unknown" } -> ${ bundleVer } (previous copy at addons-backups/ ${ path . basename ( backup ) } ) ` ) ;
}
try { fs . cpSync ( from , target , { recursive : true } ) ; }
catch ( err ) { console . warn ( ` [addons] seed ${ e . name } failed: ` , err ? . message ) ; }
}
}
function initAddons ( ) {
// Promote any signed add-on update staged by a previous run BEFORE we
// reseed from the bundle — a fresh install of a newer version from
// updateURL should win over the older bundled copy shipped inside the
// Theseus installer.
addonUpdater . promoteStagedUpdates ( {
addonsDir : addonsUserDir ( ) ,
backupsDir : addonsBackupDir ( ) ,
stagedDir : addonsStagedDir ( ) ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) ;
// Retire the pre-rename bundle layouts before reseeding so the fresh
// aegis/ + siawallet-less state is what AddonHost enumerates.
migrateAegisRename ( ) ;
seedBundledAddons ( ) ;
addonHost = new AddonHost ( {
addonsDir : addonsUserDir ( ) ,
dataDir : addonsDataDir ( ) ,
isDisabled : ( id ) => Array . isArray ( settings . disabledAddons ) && settings . disabledAddons . includes ( id ) ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
// Session-proxy capability. Add-ons that declare "session-proxy" in
// their manifest can call api.setSessionProxy(rules) to swap
// Chromium's outbound network path. Same primitive Tor uses.
//
// Authentication: Chromium's setProxy does NOT parse credentials from
// `socks5://user:pass@host:port` — it rejects it as ERR_NO_SUPPORTED_
// PROXIES. Add-ons pass auth separately either as an object:
// api.setSessionProxy({ proxyRules, auth: { username, password } })
// or inline URL — this hook strips the user:pass@ and installs a
// one-shot login handler on the default session that answers with
// the extracted credentials next time Chromium asks the proxy for auth.
setSessionProxy : async ( rules , addonId ) => {
const ses = session . defaultSession ;
// Always clear any prior proxy-login handler before swapping.
if ( proxyLoginHandler ) { ses . off ( "login" , proxyLoginHandler ) ; proxyLoginHandler = null ; }
if ( rules == null || rules === "" ) {
console . log ( ` [addons] [ ${ addonId } ] clearing session proxy ` ) ;
try { await ses . setProxy ( { proxyRules : "" } ) ; } catch ( e ) { console . warn ( "proxy clear failed:" , e ? . message ) ; }
return ;
}
let opts ;
let auth = null ;
if ( typeof rules === "string" ) {
// Parse inline creds: "scheme://user:pass@host:port".
const m = rules . match ( /^([a-z0-9+.-]+:\/\/)([^:@\/]+):([^@\/]+)@(.+)$/i ) ;
if ( m ) { opts = { proxyRules : m [ 1 ] + m [ 4 ] } ; auth = { username : m [ 2 ] , password : decodeURIComponent ( m [ 3 ] ) } ; }
else opts = { proxyRules : rules } ;
} else {
opts = { proxyRules : rules . proxyRules } ;
if ( rules . auth && rules . auth . username != null ) auth = { username : String ( rules . auth . username ) , password : String ( rules . auth . password || "" ) } ;
}
const publicRules = opts . proxyRules ; // never log the password
console . log ( ` [addons] [ ${ addonId } ] setting session proxy: ` , publicRules , auth ? "(auth pending)" : "" ) ;
if ( auth ) {
// Chromium fires session#login with `authenticationResponseDetails.isProxy === true`
// when the proxy asks for creds. Answer once per session.
proxyLoginHandler = ( event , _details , authInfo , callback ) => {
if ( ! authInfo || ! authInfo . isProxy ) return ;
event . preventDefault ( ) ;
callback ( auth . username , auth . password ) ;
} ;
ses . on ( "login" , proxyLoginHandler ) ;
}
try { await ses . setProxy ( opts ) ; } catch ( e ) { console . warn ( "proxy set failed:" , e ? . message ) ; }
} ,
// vault-derive capability. Resolves once the vault is unlocked (the
// user types the master password at boot or later in Settings) with a
// 32-byte HKDF child of the vault's root. The vault never persists the
// BIP-39 seed — only per-purpose roots — so add-on material hangs off
// the passwords root under an "addons/" info label: recoverable from
// the same mnemonic on any device, and a derived password can't be
// walked back to it (HKDF is one-way).
vaultDerive : async ( purposePath , addonId ) => {
if ( ! fs . existsSync ( vaultFile ( ) ) ) throw new Error ( "password vault is not set up" ) ;
while ( ! vaultState ) await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
const v = await loadVaultLib ( ) ;
const wc = require ( "node:crypto" ) . webcrypto ;
const key = await wc . subtle . importKey ( "raw" , v . hexToBytes ( vaultState . purposeRoot ) , "HKDF" , false , [ "deriveBits" ] ) ;
const info = new TextEncoder ( ) . encode ( ` silentmode/addons/ ${ purposePath } ` ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.derive ${ purposePath } ` ) ;
return new Uint8Array ( await wc . subtle . deriveBits (
{ name : "HKDF" , hash : "SHA-256" , salt : new Uint8Array ( 0 ) , info } , key , 256 ) ) ;
} ,
vaultLifecycle : {
status : async ( ) => ( { setup : fs . existsSync ( vaultFile ( ) ) , unlocked : ! ! vaultState } ) ,
unlock : async ( masterPassword , addonId ) => {
if ( ! fs . existsSync ( vaultFile ( ) ) ) throw new Error ( "no vault" ) ;
const v = await loadVaultLib ( ) ;
vaultState = await v . unlockVault ( vaultFile ( ) , masterPassword ) ;
importsState = null ;
if ( fs . existsSync ( importsFile ( ) ) ) {
try { importsState = await v . unlockImports ( importsFile ( ) , masterPassword ) ; }
catch ( ie ) { console . error ( "[imports] unlock via addon failed:" , ie ? . message ) ; }
}
importsUnlockPw = masterPassword ;
emitPwAvailability ( ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.unlock ` ) ;
return { ok : true } ;
} ,
setup : async ( masterPassword , seedSource , addonId ) => {
if ( ! masterPassword || String ( masterPassword ) . length < 4 ) throw new Error ( "master password too short" ) ;
if ( fs . existsSync ( vaultFile ( ) ) ) throw new Error ( "vault already exists" ) ;
const v = await loadVaultLib ( ) ;
let purposeRootHex , messengerRootHex ;
if ( seedSource && seedSource . kind === "mnemonic" && seedSource . mnemonic ) {
const seed = await v . bip39ToSeed ( String ( seedSource . mnemonic ) ) ;
purposeRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "passwords/0" ) ) ;
messengerRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "messenger/0" ) ) ;
} else {
const root = require ( "node:crypto" ) . webcrypto . getRandomValues ( new Uint8Array ( 32 ) ) ;
purposeRootHex = v . bytesToHex ( root ) ;
}
vaultState = await v . createVault ( vaultFile ( ) , masterPassword , purposeRootHex ,
messengerRootHex ? { messengerRootHex } : { } ) ;
importsUnlockPw = masterPassword ;
emitPwAvailability ( ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.setup ` ) ;
return { ok : true } ;
} ,
lock : async ( addonId ) => {
vaultState = null ; importsState = null ; importsUnlockPw = null ;
emitPwAvailability ( ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.lock ` ) ;
return { ok : true } ;
} ,
} ,
vaultImports : {
list : async ( ) => {
if ( ! vaultState ) throw new Error ( "password vault is locked" ) ;
const v = await loadVaultLib ( ) ;
return importsState ? v . listImportsMetadata ( importsState ) : [ ] ;
} ,
add : async ( spec , addonId ) => {
if ( ! vaultState ) throw new Error ( "password vault is locked" ) ;
if ( ! importsUnlockPw ) throw new Error ( "imports session credential missing (relock and unlock)" ) ;
if ( ! spec || typeof spec !== "object" ) throw new Error ( "spec required" ) ;
const kind = String ( spec . kind || "" ) ;
if ( kind !== "seed" && kind !== "wif" ) throw new Error ( ` unknown kind: ${ kind } ` ) ;
const cashaddr = String ( spec . cashaddr || "" ) . trim ( ) ;
if ( ! cashaddr ) throw new Error ( "cashaddr required (caller derives)" ) ;
const label = String ( spec . label || "" ) . trim ( ) . slice ( 0 , 120 ) ;
if ( ! label ) throw new Error ( "label required" ) ;
const category = String ( spec . category || "" ) . trim ( ) . slice ( 0 , 40 ) || "operational" ;
const source = String ( spec . source || "" ) . trim ( ) . slice ( 0 , 500 ) ;
const v = await loadVaultLib ( ) ;
if ( ! importsState ) importsState = await v . createImports ( importsFile ( ) , importsUnlockPw ) ;
const rawId = String ( spec . id || label ) . toLowerCase ( ) . replace ( /[^a-z0-9]+/g , "-" ) . replace ( /^-+|-+$/g , "" ) . slice ( 0 , 60 ) || "wallet" ;
let id = rawId , n = 1 ;
while ( importsState . accounts [ id ] ) { n ++ ; id = ` ${ rawId } - ${ n } ` ; }
const rec = { kind , cashaddr , label , category , source , createdAt : Date . now ( ) } ;
if ( kind === "seed" ) {
if ( ! spec . seed || ! spec . path ) throw new Error ( "seed and path required for kind=seed" ) ;
rec . seed = String ( spec . seed ) ; rec . path = String ( spec . path ) ;
} else {
if ( ! spec . wif ) throw new Error ( "wif required for kind=wif" ) ;
rec . wif = String ( spec . wif ) ;
}
importsState . accounts [ id ] = rec ;
await v . saveImports ( importsFile ( ) , importsState ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.imports.add ${ kind } → ${ id } ` ) ;
return { id , entries : v . listImportsMetadata ( importsState ) } ;
} ,
remove : async ( id , addonId ) => {
if ( ! vaultState || ! importsState ) throw new Error ( "password vault is locked" ) ;
if ( ! importsState . accounts [ id ] ) throw new Error ( "no such import" ) ;
delete importsState . accounts [ id ] ;
const v = await loadVaultLib ( ) ;
await v . saveImports ( importsFile ( ) , importsState ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.imports.remove ${ id } ` ) ;
return { entries : v . listImportsMetadata ( importsState ) } ;
} ,
signer : async ( id , addonId ) => {
if ( ! vaultState || ! importsState ) throw new Error ( "password vault is locked" ) ;
const v = await loadVaultLib ( ) ;
console . log ( ` [addons] [ ${ addonId } ] vault.imports.signer ${ id } ` ) ;
return v . getImportSigner ( importsState , id ) ;
} ,
} ,
approvalModal : ( opts , addonId ) => showApprovalModal ( opts , addonId ) ,
emitToPanel : ( addonId , msg , payload ) => {
// Full-tab pages of the same add-on hear it too. addon-tab-preload has
// always exposed silentmode.on(), but nothing ever delivered to a tab,
// so an add-on had no way to tell its own open editor anything — which
// is what a second document needs in order to land in the editor that
// is already up instead of a new tab.
for ( const t of tabs ) {
if ( t . addonId !== addonId ) continue ;
try { t . view ? . webContents ? . send ( "addon-event" , msg , payload ) ; } catch { }
}
if ( ! sidebar || ! sidebarActivePanelId || ! sidebarActivePanelId . startsWith ( addonId + ":" ) ) return ;
try { sidebar . webContents . send ( "addon-event" , msg , payload ) ; } catch { }
} ,
hostRequire : ( name ) => require ( name ) ,
hostImport : ( name ) => import ( require ( "node:url" ) . pathToFileURL ( require . resolve ( name ) ) . href ) ,
openTab : ( url ) => { if ( win ) createTab ( url ) ; } ,
// openSettings: routes through the existing "open-settings" IPC handler
// so the same section-hint validation applies. Add-ons hit this when they
// want to point users at Passwords, Extensions, etc.
openSettings : ( section ) => {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i . test ( section ) ? section . toLowerCase ( ) : "" ;
const ex = tabs . find ( ( t ) => t . settings ) ;
if ( ex ) {
setActive ( ex . id ) ;
if ( slug ) { try { ex . view . webContents . send ( "focus-section" , slug ) ; } catch { } }
return ;
}
createTab ( null , { settings : true , settingsSection : slug } ) ;
} ,
// Panel-driven update flow. Runs the same signed-payload verify + stage
// path used by Settings › Extensions › Check-for-updates and the boot
// timer, but on demand from an add-on's own UI so a plug-in card can
// offer "Update now" in one click. checkAndStageUpdates itself iterates
// every installed add-on; the API wrapper filters the report down to
// the caller. restartApp mirrors the "app-restart" IPC so the plug-in
// can apply a freshly-staged build without asking the user to hunt
// for the OS menu.
checkAndStageUpdates : async ( ) => {
const stagedDir = addonsStagedDir ( ) ;
try {
const result = await addonUpdater . checkAndStageUpdates ( {
addonsDir : addonsUserDir ( ) ,
stagedDir ,
pubkeysHex : ADDON _UPDATE _PUBKEYS ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) ;
const staged = listStagedAddons ( stagedDir ) ;
notifyStagedAddons ( staged ) ;
return { report : result ? . report || [ ] , skipped : result ? . skipped || null , staged } ;
} catch ( e ) {
console . warn ( "[addons] panel-driven check-updates failed:" , e ? . message || e ) ;
return { report : [ ] , skipped : "unexpected-error" , staged : [ ] } ;
}
} ,
// An add-on may ask for a relaunch (Aegis does after staging its own
// update) but never gets to perform one: the user is asked first, in a
// native dialog the panel cannot draw over. Declining costs nothing —
// a staged update applies on the next normal launch anyway.
restartApp : async ( addonName ) => {
const who = String ( addonName || "An add-on" ) . slice ( 0 , 60 ) ;
console . log ( ` [restart] ${ who } asked to relaunch Theseus ` ) ;
const parent = win && ! win . isDestroyed ( ) ? win : undefined ;
const { response } = await dialog . showMessageBox ( parent , {
type : "question" , title : "Restart Theseus?" ,
message : ` ${ who } wants to restart Theseus. ` ,
detail : "Usually to finish installing its own update. Open tabs are restored after the restart. If you choose Later, the update still applies the next time Theseus starts." ,
buttons : [ "Restart now" , "Later" ] , defaultId : 1 , cancelId : 1 , noLink : true ,
} ) ;
if ( response !== 0 ) { console . log ( ` [restart] ${ who } : user chose Later ` ) ; return { restarted : false , deferred : true } ; }
try { app . relaunch ( ) ; } catch { }
app . quit ( ) ;
return { restarted : true } ;
} ,
// open-tab (addon-file variant): open one of the add-on's OWN files in a
// full tab. The path is joined against the resolved add-on folder and
// rejected if the result escapes it — belt-and-braces with the sanity
// check the api wrapper already does. The tab uses addon-tab-preload so
// window.silentmode.invoke() reaches the same handler surface as a
// sidebar panel; the sender-URL gate on addon-msg then confines the
// page to its own add-on's storage/handlers.
openAddonTab : ( addonId , relPath , queryString ) => {
if ( ! win ) return ;
const folder = addonHost && addonHost . folderOf ( addonId ) ;
if ( ! folder ) throw new Error ( ` openAddonTab: no such active add-on " ${ addonId } " ` ) ;
const base = path . resolve ( folder ) ;
const abs = path . resolve ( base , relPath ) ;
const norm = abs . replace ( /\\/g , "/" ) . toLowerCase ( ) ;
const baseNorm = base . replace ( /\\/g , "/" ) . toLowerCase ( ) ;
if ( norm !== baseNorm && ! norm . startsWith ( baseNorm + "/" ) ) {
throw new Error ( ` openAddonTab: path " ${ relPath } " escapes add-on folder ` ) ;
}
if ( ! fs . existsSync ( abs ) ) throw new Error ( ` openAddonTab: file not found: ${ abs } ` ) ;
console . log ( ` [addons] [ ${ addonId } ] openAddonTab -> ${ path . basename ( abs ) } ${ queryString ? "?" + queryString . slice ( 0 , 80 ) + ( queryString . length > 80 ? "…" : "" ) : "" } ` ) ;
createTab ( null , { addonFile : { absPath : abs , query : queryString || "" , addonId } } ) ;
} ,
// capture-tab: three modes.
// visible — one WebContents.capturePage() of the current viewport.
// full — temporarily grow the tab's WebContentsView to the page's
// scrollHeight, capture, restore. Cheap and works for most
// pages; fixed-position headers/footers will repeat because
// they anchor to the viewport, which is a known trade-off
// (documented in the panel). Alternative would be a scroll-
// and-stitch pass; kept for a later revision.
// region — run the caller-supplied overlay source in the tab, wait
// for a rect (or null = cancel), then capturePage(rect).
// Back scan-page. The extraction runs IN the page and returns only the
// matched URIs — the add-on never sees the DOM. We look at anchor hrefs,
// visible text, and the handful of attributes a dapp realistically
// stashes a pairing code in (data-uri, value, title), then dedupe.
//
// WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which
// is often the ONLY thing in the DOM when a dapp renders a QR, so the
// matcher accepts the percent-encoded spelling too and decodes it.
scanTabForUris : async ( { scheme , limit } , addonId ) => {
const t = activeTab ( ) ;
if ( ! t ) throw new Error ( "no active tab" ) ;
if ( t . addonId || t . settings ) throw new Error ( "open the dapp's tab first, then scan" ) ;
const wc = t . view . webContents ;
const origin = pageOriginOf ( wc . getURL ( ) ) ;
// The regex SOURCES are built here and shipped as JSON. Assembling
// them inside the injected string instead means hand-escaping
// backslashes and quotes through two levels of literal, which is both
// easy to get wrong and unreviewable. JSON.stringify does it exactly.
// `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so
// it cannot carry regex metacharacters.
const plainSrc = ` \\ b ${ scheme } ://[^ \\ s"'<>]{4,2048} ` ;
// Percent-encoded QR spelling: WIZ://%3FP%3D…
const qrSrc = ` \\ b ${ scheme } ://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~ $ +-])+ ` ;
const arg = JSON . stringify ( { plainSrc , qrSrc , limit } ) ;
const found = await wc . executeJavaScript ( ` (() => {
const { plainSrc , qrSrc , limit } = $ { arg } ;
const out = new Set ( ) ;
const plain = new RegExp ( plainSrc , "gi" ) ;
const qr = new RegExp ( qrSrc , "gi" ) ;
const push = ( s ) => {
if ( ! s || out . size >= limit ) return ;
let v = String ( s ) . trim ( ) ;
if ( v . includes ( "%3F" ) || v . includes ( "%3f" ) ) { try { v = decodeURIComponent ( v ) ; } catch { } }
if ( v . length <= 2048 ) out . add ( v ) ;
} ;
const scan = ( s ) => {
if ( ! s ) return ;
for ( const m of String ( s ) . matchAll ( plain ) ) push ( m [ 0 ] ) ;
for ( const m of String ( s ) . matchAll ( qr ) ) push ( m [ 0 ] ) ;
} ;
for ( const a of document . querySelectorAll ( "a[href]" ) ) scan ( a . getAttribute ( "href" ) ) ;
for ( const el of document . querySelectorAll ( "[data-uri],[data-wc-uri],[value],[title]" ) ) {
scan ( el . getAttribute ( "data-uri" ) ) ; scan ( el . getAttribute ( "data-wc-uri" ) ) ;
scan ( el . getAttribute ( "value" ) ) ; scan ( el . getAttribute ( "title" ) ) ;
}
for ( const el of document . querySelectorAll ( "input,textarea" ) ) scan ( el . value ) ;
scan ( document . body ? document . body . innerText : "" ) ;
return [ ... out ] . slice ( 0 , limit ) ;
} ) ( ) ` , true);
const uris = Array . isArray ( found ) ? found . filter ( ( s ) => typeof s === "string" ) : [ ] ;
console . log ( ` [addons] ${ addonId } scanned ${ origin || "tab" } for ${ scheme } :// — ${ uris . length } match(es) ` ) ;
return { origin , uris } ;
} ,
captureTab : async ( opts , addonId ) => {
// Prefer the currently-active tab, BUT if that's an add-on-owned page
// (e.g. the screenshot editor is already up when the user re-picks a
// mode from the dropdown), fall back to the most-recently-active real
// tab. Otherwise a second capture snapshots the editor's still-blank
// canvas and every follow-up produces a white PNG.
let t = activeTab ( ) ;
if ( t && ( t . addonId || t . settings ) ) {
const fallback = tabById ( lastCapturableTabId ) ;
if ( fallback && ! fallback . addonId && ! fallback . settings ) t = fallback ;
else {
// Last resort: any non-addon non-settings tab in the list.
t = tabs . find ( ( x ) => ! x . addonId && ! x . settings ) || t ;
}
}
if ( ! t ) throw new Error ( "no active tab" ) ;
if ( t . addonId || t . settings ) {
throw new Error ( "no capturable tab — open a page you'd like to shoot first" ) ;
}
const wc = t . view . webContents ;
const host = t ? . prov ? . host || ( ( ) => { try { return new URL ( wc . getURL ( ) ) . host ; } catch { return "" ; } } ) ( ) ;
const mode = String ( opts ? . mode || "visible" ) ;
const format = opts ? . format === "jpeg" ? "jpeg" : "png" ;
const quality = Math . max ( 1 , Math . min ( 100 , Number ( opts ? . quality ) || 90 ) ) ;
// CDP-based capture. Page.captureScreenshot forces a fresh composite
// regardless of occlusion state, so it doesn't blank out when the tab
// view is marked hidden (which happened right after a native menu
// popup closed — the compositor stays throttled for a few frames and
// WebContents.capturePage() would snapshot a stale/transparent frame
// at the correct dimensions, which no size-based retry could catch).
// Reads PNG width/height from the IHDR chunk so we don't need a
// NativeImage roundtrip.
function pngDims ( b64 ) {
const buf = Buffer . from ( b64 , "base64" ) ;
return { width : buf . readUInt32BE ( 16 ) , height : buf . readUInt32BE ( 20 ) } ;
}
async function cdpCapture ( { full = false , rect = null } = { } ) {
const wasAttached = wc . debugger . isAttached ( ) ;
if ( ! wasAttached ) {
try { wc . debugger . attach ( "1.3" ) ; }
catch ( e ) {
if ( ! /already attached/i . test ( String ( e ? . message ) ) ) throw e ;
}
}
try {
const p = { format : format === "jpeg" ? "jpeg" : "png" } ;
if ( format === "jpeg" ) p . quality = quality ;
if ( rect ) p . clip = { x : rect . x , y : rect . y , width : rect . width , height : rect . height , scale : 1 } ;
if ( full ) p . captureBeyondViewport = true ;
const { data } = await wc . debugger . sendCommand ( "Page.captureScreenshot" , p ) ;
const dataUrl = ` data:image/ ${ p . format } ;base64, ${ data } ` ;
const dims = p . format === "png" ? pngDims ( data ) : ( rect ? { width : rect . width , height : rect . height } : null ) ;
return { dataUrl , ... ( dims || { } ) } ;
} finally {
// Only detach if WE attached; leave a pre-existing DevTools/other
// consumer's attachment alone.
if ( ! wasAttached ) { try { wc . debugger . detach ( ) ; } catch { } }
}
}
if ( mode === "visible" ) {
const r = await cdpCapture ( ) ;
console . log ( ` [addons] [ ${ addonId } ] captureTab visible ${ r . width } x ${ r . height } ` ) ;
return { dataUrl : r . dataUrl , width : r . width , height : r . height , host , format } ;
}
if ( mode === "full" ) {
// Page.captureScreenshot with captureBeyondViewport does the whole
// scrollable page. Before we shoot, force the layout viewport to the
// window's full content width via Emulation.setDeviceMetricsOverride
// so an open sidebar (or any other on-screen chrome that narrowed
// the tab view) doesn't clip the capture — the shot always comes
// back at the page's natural full width, not the visible width.
const wasAttached = wc . debugger . isAttached ( ) ;
if ( ! wasAttached ) {
try { wc . debugger . attach ( "1.3" ) ; }
catch ( e ) { if ( ! /already attached/i . test ( String ( e ? . message ) ) ) throw e ; }
}
let overrode = false ;
try {
const winW = ( win ? . getContentBounds ( ) ? . width ) || 0 ;
const tabB = t . view . getBounds ( ) ;
const need = winW > tabB . width + 24 ? winW : 0 ;
if ( need > 0 ) {
// dsf 0 = "let Chromium keep the real device scale factor".
// mobile false, deviceScaleFactor 0 keeps typography sane;
// height 0 tells CDP "use the current viewport height".
await wc . debugger . sendCommand ( "Emulation.setDeviceMetricsOverride" , {
width : need , height : 0 , deviceScaleFactor : 0 , mobile : false ,
} ) ;
overrode = true ;
// A frame or two so the reflow settles before we snapshot.
await new Promise ( ( r ) => setTimeout ( r , 250 ) ) ;
}
const r = await cdpCapture ( { full : true } ) ;
console . log ( ` [addons] [ ${ addonId } ] captureTab full ${ r . width } x ${ r . height } ${ overrode ? ` (viewport widened to ${ need } px) ` : "" } ` ) ;
return { dataUrl : r . dataUrl , width : r . width , height : r . height , host , format } ;
} finally {
if ( overrode ) {
try { await wc . debugger . sendCommand ( "Emulation.clearDeviceMetricsOverride" ) ; } catch { }
}
if ( ! wasAttached ) { try { wc . debugger . detach ( ) ; } catch { } }
}
}
if ( mode === "region" ) {
const src = String ( opts ? . overlaySource || "" ) ;
if ( ! src ) throw new Error ( "region capture needs opts.overlaySource" ) ;
// Overlay script runs in the target tab's world. It's expected to
// resolve (as the executeJavaScript result) with {x,y,w,h} in CSS
// pixels, or null when the user hits Escape / right-clicks.
const rectRaw = await wc . executeJavaScript ( src , true ) ;
if ( ! rectRaw || typeof rectRaw !== "object" ) {
console . log ( ` [addons] [ ${ addonId } ] captureTab region cancelled ` ) ;
return { dataUrl : "" , width : 0 , height : 0 , host , format , cancelled : true } ;
}
const rect = {
x : Math . max ( 0 , Math . floor ( rectRaw . x ) ) ,
y : Math . max ( 0 , Math . floor ( rectRaw . y ) ) ,
width : Math . max ( 1 , Math . floor ( rectRaw . w ) ) ,
height : Math . max ( 1 , Math . floor ( rectRaw . h ) ) ,
} ;
const r = await cdpCapture ( { rect } ) ;
const s = { width : r . width || rect . width , height : r . height || rect . height } ;
console . log ( ` [addons] [ ${ addonId } ] captureTab region ${ s . width } x ${ s . height } @ ${ rect . x } , ${ rect . y } ` ) ;
return { dataUrl : r . dataUrl , width : s . width , height : s . height , host , format } ;
}
throw new Error ( ` unknown capture mode: ${ mode } ` ) ;
} ,
// saveCapture writes the bytes to Downloads and synthesizes a completed
// download record so the chip shows the file with a Show-in-folder link,
// just like an HTTP save. session.downloadURL(dataUrl) would go through
// will-download, but data URLs come across with a synthetic filename that
// Electron won't let us override in-flight without gymnastics — writing
// directly is deterministic and produces the same user-facing artifact.
saveCapture : async ( opts , addonId ) => {
const dataUrl = String ( opts ? . dataUrl || "" ) ;
const m = /^data:([^;,]+);base64,(.+)$/ . exec ( dataUrl ) ;
if ( ! m ) throw new Error ( "saveCapture: dataUrl must be base64-encoded" ) ;
const mime = m [ 1 ] ;
const bytes = Buffer . from ( m [ 2 ] , "base64" ) ;
const raw = String ( opts ? . filename || "screenshot.png" ) ;
// Strip path separators — add-on-provided filename must not escape the
// downloads folder.
const safe = raw . replace ( /[\\/:*?"<>|]+/g , "_" ) . slice ( 0 , 200 ) || "screenshot.png" ;
const dlDir = app . getPath ( "downloads" ) ;
let target = path . join ( dlDir , safe ) ;
// Uniquify: append " (n)" before the extension if the name is taken.
if ( fs . existsSync ( target ) ) {
const ext = path . extname ( safe ) ;
const stem = safe . slice ( 0 , safe . length - ext . length ) ;
for ( let i = 2 ; i < 10000 ; i ++ ) {
const cand = path . join ( dlDir , ` ${ stem } ( ${ i } ) ${ ext } ` ) ;
if ( ! fs . existsSync ( cand ) ) { target = cand ; break ; }
}
}
try { fs . writeFileSync ( target , bytes ) ; }
catch ( e ) { throw new Error ( ` saveCapture: write failed: ${ e ? . message || e } ` ) ; }
const id = nextDlId ++ ;
const rec = {
id ,
filename : path . basename ( target ) ,
url : ` internal://addons/ ${ addonId } / ${ path . basename ( target ) } ` ,
mime ,
total : bytes . length ,
received : bytes . length ,
state : "completed" ,
savePath : target ,
startedAt : Date . now ( ) ,
} ;
downloads . unshift ( rec ) ;
emitDownloads ( ) ;
console . log ( ` [addons] [ ${ addonId } ] saveCapture wrote ${ bytes . length } bytes → ${ target } ` ) ;
return { savePath : target } ;
} ,
// revealSidebar hook — used by add-ons declaring "context-menu-item" so
// a right-click handler can pull the sidebar open to its own panel.
// AddonHost has already gated by ownership before calling us; here we
// just route through the existing setSidebar path.
revealSidebar : ( addonId , panelId ) => {
try { setSidebar ( true , panelId ) ; }
catch ( e ) { console . warn ( ` [addons] [ ${ addonId } ] revealSidebar failed: ` , e ? . message ) ; }
} ,
} ) ;
addonHost . discoverAndActivate ( ) ;
const snap = addonHost . snapshot ( ) ;
console . log ( ` [addons] ${ snap . installed . length } installed, ${ snap . installed . filter ( ( x ) => x . enabled ) . length } enabled, ${ snap . sidebarPanels . length } sidebar panels ` ) ;
}
// Given a webContents sender URL, work out which add-on folder it lives in.
// Used to gate storage IPC — a page hosted inside addons/<id>/ can only touch
// its own store.
function addonIdForSender ( sender ) {
try {
const u = new URL ( sender . getURL ( ) ) ;
if ( u . protocol !== "file:" ) return null ;
const filePath = decodeURIComponent ( u . pathname ) . replace ( /^\/+/ , "" ) ;
const norm = filePath . replace ( /\\/g , "/" ) ;
const dirNorm = addonsUserDir ( ) . replace ( /\\/g , "/" ) . replace ( /\/+$/ , "" ) ;
if ( ! norm . toLowerCase ( ) . startsWith ( dirNorm . toLowerCase ( ) + "/" ) ) return null ;
const rest = norm . slice ( dirNorm . length + 1 ) ;
const first = rest . split ( "/" ) [ 0 ] ;
return first || null ;
} catch { return null ; }
}
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = [ ] ; let nextDlId = 1 ; const dlItems = new Map ( ) ; // id -> DownloadItem
const tabs = [ ] ; // { id, view, title, url, prov }
let activeId = null , tabSeq = 0 ;
const tabById = ( id ) => tabs . find ( ( t ) => t . id === id ) ;
const activeTab = ( ) => tabById ( activeId ) ;
// The most-recently-active non-addon tab. captureTab falls back to this when
// the currently-active tab is an add-on-owned page (e.g. the screenshot
// editor itself) — otherwise a re-triggered capture snapshots the editor's
// still-blank canvas instead of the page the user actually wants to shoot.
let lastCapturableTabId = null ;
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
// Enrich prov with a "collision candidate?" flag so the popover switcher can
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
function decorate ( prov ) {
if ( ! prov || ! prov . tld ) return prov ;
return { ... prov , bcnrNativeTld : isBcnrNativeTld ( prov . tld ) } ;
}
function pushNav ( prov ) {
const p = decorate ( prov ) ;
chrome ? . webContents . send ( "nav" , p ) ;
if ( popVisible ) popover ? . webContents . send ( "site-info" , p ) ;
emitPwAvailability ( ) ;
}
function layout ( ) {
if ( ! winAlive ( ) ) return ;
const { width , height } = win . getContentBounds ( ) ;
chrome . setBounds ( { x : 0 , y : 0 , width , height : CHROME _H } ) ;
const bodyH = Math . max ( 0 , height - CHROME _H ) ;
// Sidebar (when visible) claims a fixed slice on the right; the tab views
// shrink to fit alongside it. When hidden, tabs get the full width.
const sideW = sidebarVisible ? sidebarW : 0 ;
const tabW = Math . max ( 0 , width - sideW ) ;
for ( const t of tabs ) t . view . setBounds ( { x : 0 , y : CHROME _H , width : tabW , height : bodyH } ) ;
if ( sidebar ) sidebar . setBounds ( { x : tabW , y : CHROME _H , width : sideW , height : bodyH } ) ;
// Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted.
if ( approvalPop ) approvalPop . setBounds ( { x : 0 , y : CHROME _H , width : tabW , height : bodyH } ) ;
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
if ( jsDialogPop ) jsDialogPop . setBounds ( { x : 0 , y : CHROME _H , width : tabW , height : bodyH } ) ;
2026-09-27 11:24:36 +02:00
positionPopover ( ) ;
positionEnginePicker ( ) ;
positionDownloads ( ) ;
positionAddressPicker ( ) ;
positionPwFill ( ) ;
if ( linkStatusVisible ) positionLinkStatus ( ) ;
}
function positionPopover ( ) {
if ( ! popover ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( popPos . x , width - POP _W - 6 ) ) ;
popover . setBounds ( { x , y : popPos . y , width : POP _W , height : popH } ) ;
}
function showPopover ( show ) {
if ( ! popover ) return ;
if ( show ) {
loadOverlays ( ) ;
positionPopover ( ) ;
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
win . contentView . removeChildView ( popover ) ;
win . contentView . addChildView ( popover ) ;
popover . setVisible ( true ) ; popVisible = true ;
popover . webContents . send ( "site-info" , decorate ( activeTab ( ) ? . prov ) || { kind : "home" } ) ;
} else { popover . setVisible ( false ) ; popVisible = false ; }
}
function positionEnginePicker ( ) {
if ( ! enginePicker ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( epPos . x , width - EP _W - 6 ) ) ;
enginePicker . setBounds ( { x , y : epPos . y , width : EP _W , height : epH } ) ;
}
function showEnginePicker ( show ) {
if ( ! enginePicker ) return ;
if ( show ) {
loadOverlays ( ) ;
positionEnginePicker ( ) ;
win . contentView . removeChildView ( enginePicker ) ;
win . contentView . addChildView ( enginePicker ) ;
enginePicker . setVisible ( true ) ; epVisible = true ;
enginePicker . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine , detected : activeTab ( ) ? . detected || null } ) ;
} else { enginePicker . setVisible ( false ) ; epVisible = false ; }
}
function positionDownloads ( ) {
if ( ! downloadsPop ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( dlPos . x , width - DL _W - 6 ) ) ;
downloadsPop . setBounds ( { x , y : dlPos . y , width : DL _W , height : dlH } ) ;
}
function showDownloads ( show ) {
if ( ! downloadsPop ) return ;
if ( show ) {
loadOverlays ( ) ;
positionDownloads ( ) ;
win . contentView . removeChildView ( downloadsPop ) ;
win . contentView . addChildView ( downloadsPop ) ;
downloadsPop . setVisible ( true ) ; dlVisible = true ;
downloadsPop . webContents . send ( "downloads" , downloadsPublic ( ) ) ;
} else { downloadsPop . setVisible ( false ) ; dlVisible = false ; }
}
function positionAddressPicker ( ) {
if ( ! addressPicker || ! winAlive ( ) ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( apPos . x , width - apW - 6 ) ) ;
addressPicker . setBounds ( { x , y : apPos . y , width : apW , height : apH } ) ;
}
function positionPwFill ( ) {
if ( ! pwFillPop || ! winAlive ( ) ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( pwfPos . x , width - PWF _W - 6 ) ) ;
pwFillPop . setBounds ( { x , y : pwfPos . y , width : PWF _W , height : pwfH } ) ;
}
function positionLinkStatus ( ) {
if ( ! linkStatus || ! winAlive ( ) ) return ;
const { width , height } = win . getContentBounds ( ) ;
// The pill may grow to (almost) the full width of the tab area — long
// URLs stay readable — and ellipsises past that. It never runs under
// the sidebar.
const tabW = Math . max ( 0 , width - ( sidebarVisible ? sidebarW : 0 ) ) ;
const w = Math . min ( Math . max ( 120 , linkStatusW ) , Math . max ( 200 , tabW - 16 ) ) ;
const h = Math . max ( 20 , linkStatusH ) ;
linkStatus . setBounds ( { x : 0 , y : Math . max ( 0 , height - h ) , width : w , height : h } ) ;
}
function showLinkStatus ( url ) {
if ( ! linkStatus || ! winAlive ( ) ) return ;
const s = String ( url || "" ) ;
if ( ! s ) {
if ( linkStatusVisible ) { linkStatus . setVisible ( false ) ; linkStatusVisible = false ; }
return ;
}
positionLinkStatus ( ) ;
// Raise the pill above any tab view that was added after it.
try { win . contentView . removeChildView ( linkStatus ) ; win . contentView . addChildView ( linkStatus ) ; } catch { }
linkStatus . setVisible ( true ) ; linkStatusVisible = true ;
try { linkStatus . webContents . send ( "link-status-url" , s ) ; } catch { }
}
// Open (or close) the sidebar. Loading the panel HTML is lazy — the first
// open triggers loadFile; subsequent opens just flip visibility.
function toggleSidebar ( ) { setSidebar ( ! sidebarVisible ) ; }
// Everything the chrome needs to draw the extension dock + plug-in row:
// panels, toolbar menus, which panel is open, and the user's dock prefs.
function sidebarStatePayload ( ) {
return {
visible : sidebarVisible ,
active : sidebarActivePanelId ,
panels : addonHost ? addonHost . getSidebarPanels ( ) : [ ] ,
toolbarMenus : addonHost ? addonHost . getToolbarMenus ( ) : [ ] ,
dock : {
order : Array . isArray ( settings . dockOrder ) ? settings . dockOrder : [ ] ,
hidden : Array . isArray ( settings . dockHidden ) ? settings . dockHidden : [ ] ,
} ,
} ;
}
function emitSidebarState ( ) {
try { chrome ? . webContents . send ( "sidebar-state" , sidebarStatePayload ( ) ) ; } catch { }
}
function setSidebar ( show , panelId ) {
if ( ! sidebar ) return ;
const panels = addonHost ? addonHost . getSidebarPanels ( ) : [ ] ;
if ( show && panels . length === 0 ) {
// No add-on offers a sidebar panel — silently ignore. Settings surfaces
// the "install one" path.
return ;
}
if ( show ) {
const wantId = panelId || sidebarActivePanelId || panels [ 0 ] . panelId ;
const panel = panels . find ( ( p ) => p . panelId === wantId ) || panels [ 0 ] ;
if ( sidebarActivePanelId !== panel . panelId ) {
sidebarActivePanelId = panel . panelId ;
try { sidebar . webContents . loadFile ( panel . pageFile ) ; } catch ( e ) { console . warn ( "sidebar loadFile failed:" , e ? . message ) ; }
}
sidebarVisible = true ;
sidebar . setVisible ( true ) ;
try { win . contentView . removeChildView ( sidebar ) ; win . contentView . addChildView ( sidebar ) ; } catch { }
layout ( ) ;
try { sidebar . webContents . send ( "sidebar-visibility" , true ) ; } catch { }
emitSidebarState ( ) ;
} else {
sidebarVisible = false ;
sidebar . setVisible ( false ) ;
layout ( ) ;
try { sidebar . webContents . send ( "sidebar-visibility" , false ) ; } catch { }
emitSidebarState ( ) ;
}
}
function showPwFill ( show , matches ) {
if ( ! pwFillPop ) return ;
if ( show ) {
loadOverlays ( ) ;
positionPwFill ( ) ;
win . contentView . removeChildView ( pwFillPop ) ;
win . contentView . addChildView ( pwFillPop ) ;
pwFillPop . setVisible ( true ) ; pwfVisible = true ;
pwFillPop . webContents . send ( "pw-matches" , { matches : matches || [ ] } ) ;
} else { pwFillPop . setVisible ( false ) ; pwfVisible = false ; }
}
// Compute credential matches for a host. Exact hostname match in phase-1;
// eTLD+1 upgrade queued for A.2.5 (needs the public-suffix-list snapshot).
function pwMatchesForHost ( host ) {
if ( ! vaultState || ! host ) return [ ] ;
const h = String ( host ) . toLowerCase ( ) ;
return ( vaultState . entries || [ ] )
. filter ( ( e ) => e . domain === h )
. map ( ( e ) => ( { id : e . id , domain : e . domain , username : e . username || "" } ) ) ;
}
// Emit the current tab's match count to chrome so the toolbar chip can
// show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability ( ) {
const t = activeTab ( ) ;
const host = t ? . prov ? . host || "" ;
const count = pwMatchesForHost ( host ) . length ;
try { chrome ? . webContents . send ( "pw-availability" , { host , count } ) ; } catch { }
}
// Inject a small script into the active tab that fills the first visible
// password field + tries to fill the adjacent/associated username field.
// Kept intentionally small — the whole autofill affordance is opt-in
// (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab ( entry ) {
const t = activeTab ( ) ; if ( ! t ) return false ;
const wc = t . view . webContents ;
const script = ` (() => {
const visible = ( el ) => { const r = el . getBoundingClientRect ( ) ; return r . width > 4 && r . height > 4 ; } ;
const pwds = [ ... document . querySelectorAll ( 'input[type=password]:not([disabled])' ) ] . filter ( visible ) ;
if ( ! pwds . length ) return { ok : false , why : 'no-password-field' } ;
const pw = pwds [ 0 ] ;
const form = pw . closest ( 'form' ) ;
const scope = form ? form . querySelectorAll ( 'input' ) : document . querySelectorAll ( 'input' ) ;
const users = [ ... scope ] . filter ( ( el ) => el !== pw && visible ( el ) && ! el . disabled &&
/^(?:text|email|tel|url|search|)$/i . test ( el . type || 'text' ) &&
/^(?:username|user|email|login|account|id)$/i . test ( ( el . name || el . id || el . autocomplete || '' ) . replace ( /[-_]/g , '' ) . toLowerCase ( ) ) ) ;
const user = users [ 0 ] || null ;
const fill = ( el , v ) => {
el . focus ( ) ;
const setter = Object . getOwnPropertyDescriptor ( HTMLInputElement . prototype , 'value' ) . set ;
setter . call ( el , v ) ;
el . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
el . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
} ;
if ( user && $ { JSON . stringify ( String ( entry . username || "" ) ) } ) fill ( user , $ { JSON . stringify ( String ( entry . username || "" ) ) } ) ;
fill ( pw , $ { JSON . stringify ( String ( entry . password ) ) } ) ;
pw . blur ( ) ;
return { ok : true , filledUsername : ! ! user } ;
} ) ( ) ` ;
try {
const res = await wc . executeJavaScript ( script , true ) ;
return res ;
} catch ( e ) { console . error ( "pw fill failed:" , e ? . message ) ; return { ok : false , why : "exec-error" } ; }
}
function showAddressPicker ( show , suggestions ) {
if ( ! addressPicker ) return ;
if ( show ) {
if ( ! suggestions || ! suggestions . length ) return showAddressPicker ( false ) ;
loadOverlays ( ) ;
positionAddressPicker ( ) ;
win . contentView . removeChildView ( addressPicker ) ;
win . contentView . addChildView ( addressPicker ) ;
addressPicker . setVisible ( true ) ; apVisible = true ;
addressPicker . webContents . send ( "address-suggest" , { suggestions } ) ;
} else { addressPicker . setVisible ( false ) ; apVisible = false ; }
}
// Public view of a download — no DownloadItem refs leak to the renderer.
const downloadsPublic = ( ) => downloads . map ( ( d ) => ( { ... d } ) ) ;
function emitDownloads ( ) {
const pub = downloadsPublic ( ) ;
try { chrome ? . webContents . send ( "downloads" , pub ) ; } catch { }
if ( dlVisible ) try { downloadsPop ? . webContents . send ( "downloads" , pub ) ; } catch { }
}
// Attach the will-download listener to the SHARED default session. Every tab's
// WebContents inherits it, so we catch downloads regardless of which tab
// initiated them (including anchor clicks with `download`, form posts serving
// attachments, and manual save-as gestures).
function installDownloadTracker ( ) {
session . defaultSession . on ( "will-download" , ( _e , item , wc ) => {
const url = item . getURL ( ) ;
// Downloads initiated from an addon-file tab (the Screenshot editor's
// "Save" hits this via `<a download>` on a blob: URL) go straight to
// Downloads with a uniquified filename — Electron's default is to pop
// a Save As dialog, which the user has no way to answer from inside
// an add-on tab. This matches the ergonomics of the older, sidebar-
// driven saveCapture path.
try {
const addonTab = wc && tabs . find ( ( t ) => t . view && t . view . webContents === wc && t . addonId ) ;
if ( addonTab ) {
const raw = item . getFilename ( ) || "download.bin" ;
const safe = raw . replace ( /[\\/:*?"<>|]+/g , "_" ) . slice ( 0 , 200 ) || "download.bin" ;
const dlDir = app . getPath ( "downloads" ) ;
let target = path . join ( dlDir , safe ) ;
if ( fs . existsSync ( target ) ) {
const ext = path . extname ( safe ) ;
const stem = safe . slice ( 0 , safe . length - ext . length ) ;
for ( let i = 2 ; i < 10000 ; i ++ ) {
const cand = path . join ( dlDir , ` ${ stem } ( ${ i } ) ${ ext } ` ) ;
if ( ! fs . existsSync ( cand ) ) { target = cand ; break ; }
}
}
try { item . setSavePath ( target ) ; } catch { }
}
} catch { }
// Update installer? Route it to a fixed temp path, keep it out of the
// visible downloads list, drive updateDownloadState instead so the chip
// can show "ready to install" and one-click install-and-restart.
const isUpdate = updateAvailable && ( url === updateAvailable . setupUrl || url === updateAvailable . portableUrl ) ;
if ( isUpdate ) {
const dst = path . join ( app . getPath ( "temp" ) , item . getFilename ( ) ) ;
try { item . setSavePath ( dst ) ; } catch { }
updateDownloadTotal = item . getTotalBytes ( ) || 0 ;
updateDownloadReceived = 0 ;
item . on ( "updated" , ( ) => {
updateDownloadReceived = item . getReceivedBytes ( ) ;
updateDownloadTotal = item . getTotalBytes ( ) || updateDownloadTotal ;
emitUpdateAvailable ( ) ;
} ) ;
item . once ( "done" , ( _ev , state ) => {
if ( state !== "completed" ) {
updateDownloadState = "failed" ;
console . warn ( ` [update] silent fetch ${ state } ` ) ;
emitUpdateAvailable ( ) ;
return ;
}
// NEVER mark "ready" without verifying the file hashes to what the
// manifest promised. Electron's DownloadItem has been observed to
// fire done/completed on truncated payloads (bad Content-Length,
// CDN cache truncation, mid-stream TLS reset the runtime swallowed),
// and 0.3.31's in-app updater then spawned a half-file as setup —
// NSIS integrity check failed silently and the browser was gone.
const savedPath = item . getSavePath ( ) || dst ;
const expected = String ( updateAvailable && updateAvailable . setupHash || "" ) . toLowerCase ( ) ;
if ( ! expected ) {
updateDownloadState = "failed" ;
console . warn ( ` [update] no manifest hash for ${ savedPath } — refusing to arm install ` ) ;
try { fs . unlinkSync ( savedPath ) ; } catch { }
emitUpdateAvailable ( ) ;
return ;
}
const crypto = require ( "node:crypto" ) ;
const hash = crypto . createHash ( "sha256" ) ;
const rs = fs . createReadStream ( savedPath ) ;
rs . on ( "data" , ( c ) => hash . update ( c ) ) ;
rs . once ( "error" , ( e ) => {
updateDownloadState = "failed" ;
console . warn ( ` [update] hash read failed: ${ e . message } ` ) ;
try { fs . unlinkSync ( savedPath ) ; } catch { }
emitUpdateAvailable ( ) ;
} ) ;
rs . once ( "end" , ( ) => {
const got = hash . digest ( "hex" ) . toLowerCase ( ) ;
if ( got !== expected ) {
updateDownloadState = "failed" ;
console . warn ( ` [update] SHA-256 mismatch: got ${ got } , want ${ expected } — refusing to arm install ` ) ;
try { fs . unlinkSync ( savedPath ) ; } catch { }
emitUpdateAvailable ( ) ;
return ;
}
updateDownloadPath = savedPath ;
updateDownloadState = "ready" ;
// Drop the mark-of-the-web Chromium stamps on downloads. Our
// hash check above is the trust decision; the zone marker only
// makes Windows raise a security prompt if the file is ever
// started through the shell.
try { fs . unlinkSync ( savedPath + ":Zone.Identifier" ) ; } catch { }
console . log ( ` [update] silent fetch complete + verified: ${ savedPath } ` ) ;
emitUpdateAvailable ( ) ;
} ) ;
} ) ;
return ;
}
const id = nextDlId ++ ;
const rec = {
id ,
filename : item . getFilename ( ) ,
url : item . getURL ( ) ,
mime : item . getMimeType ( ) ,
total : item . getTotalBytes ( ) || 0 ,
received : 0 ,
state : "progressing" , // progressing | paused | completed | cancelled | interrupted
savePath : "" ,
startedAt : Date . now ( ) ,
} ;
downloads . unshift ( rec ) ;
dlItems . set ( id , item ) ;
emitDownloads ( ) ;
item . on ( "updated" , ( _ev , state ) => {
rec . state = state ; // "progressing" | "interrupted"
rec . received = item . getReceivedBytes ( ) ;
rec . total = item . getTotalBytes ( ) || rec . total ;
rec . savePath = item . getSavePath ( ) || rec . savePath ;
emitDownloads ( ) ;
} ) ;
item . once ( "done" , ( _ev , state ) => {
rec . state = state ; // "completed" | "cancelled" | "interrupted"
rec . received = item . getReceivedBytes ( ) ;
rec . savePath = item . getSavePath ( ) || rec . savePath ;
dlItems . delete ( id ) ;
emitDownloads ( ) ;
} ) ;
} ) ;
}
function setActive ( id ) {
const switching = id !== activeId ;
activeId = id ;
if ( popVisible ) showPopover ( false ) ; // don't carry a stale popover across tabs
if ( epVisible ) showEnginePicker ( false ) ;
if ( linkStatusVisible ) showLinkStatus ( "" ) ; // clear any lingering hover pill
// A user action that switches to a different tab (New Tab, Settings,
// address-bar nav that opens elsewhere, tab-strip click) shouldn't leave
// the incoming tab hidden behind a maximized sidebar. Auto-restore the
// sidebar to its pre-max width so the tab is actually visible; the
// user can re-maximize when they're done.
if ( switching && sidebarMaximized ) setSidebarMaximized ( false ) ;
// Show the NEW active tab first, THEN hide the others. Reversing this
// order eliminates the "no tab is visible" frame on switch that made the
// tab strip flash — the compositor always has at least one tab view up.
const target = tabs . find ( ( x ) => x . id === id ) ;
if ( target ) target . view . setVisible ( true ) ;
for ( const t of tabs ) if ( t . id !== id ) t . view . setVisible ( false ) ;
const t = activeTab ( ) ;
// Track the last active tab that isn't an add-on-owned page so captureTab
// has a sensible fallback when the user re-triggers the dropdown from
// inside (say) the screenshot editor.
if ( t && ! t . addonId && ! t . settings ) lastCapturableTabId = t . id ;
if ( t ? . prov ) pushNav ( t . prov ) ;
chrome . webContents . send ( "bcnr-offer" , t ? . bcnrOffer ? { host : t . bcnrOffer . host , tld : t . bcnrOffer . tld , registry : REGISTRY } : null ) ;
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
syncJsDialogVisibility ( ) ;
2026-09-27 11:24:36 +02:00
emitTabs ( ) ;
}
function emitTabs ( ) {
const t = activeTab ( ) ;
const wc = t ? . view . webContents ;
chrome ? . webContents . send ( "tabs" , {
tabs : tabs . map ( ( x ) => ( { id : x . id , title : x . title || "New Tab" , active : x . id === activeId , loading : ! ! x . loading , favicon : x . favicon || null , muted : ! ! x . muted , group : x . group || null , url : x . url || "" } ) ) ,
collapsedGroups : [ ... tabGroupCollapsed ] ,
url : t ? . url || "" ,
loading : ! ! t ? . loading ,
canBack : ( ( ) => { try { return ! ! wc && wc . navigationHistory . canGoBack ( ) ; } catch { return false ; } } ) ( ) ,
canForward : ( ( ) => { try { return ! ! wc && wc . navigationHistory . canGoForward ( ) ; } catch { return false ; } } ) ( ) ,
zoom : zoomPercent ( t ) ,
webapp : webapps . chipState ( t ) ,
} ) ;
}
function setLoading ( tab , on ) { if ( tab && tab . loading !== on ) { tab . loading = on ; emitTabs ( ) ; } }
// ---- page zoom ----
// Chrome's preset ladder. Zoom is applied with setZoomFactor, which
// Chromium keys per host for the session — so every tab on the same site
// shares the level, and navigating back to a site restores it, exactly
// like Chrome. Settings and add-on tabs never zoom.
const ZOOM _STEPS = [ 25 , 33 , 50 , 67 , 75 , 80 , 90 , 100 , 110 , 125 , 150 , 175 , 200 , 250 , 300 , 400 , 500 ] ;
function zoomPercent ( t ) {
if ( ! t || t . settings || t . addonId ) return 100 ;
try { return Math . round ( t . view . webContents . getZoomFactor ( ) * 100 ) ; } catch { return 100 ; }
}
function zoomStep ( t , dir ) {
if ( ! t || t . settings || t . addonId ) return ;
const cur = zoomPercent ( t ) ;
const next = dir > 0
? ( ZOOM _STEPS . find ( ( z ) => z > cur ) ? ? ZOOM _STEPS [ ZOOM _STEPS . length - 1 ] )
: ( [ ... ZOOM _STEPS ] . reverse ( ) . find ( ( z ) => z < cur ) ? ? ZOOM _STEPS [ 0 ] ) ;
zoomSet ( t , next ) ;
}
function zoomSet ( t , percent ) {
if ( ! t || t . settings || t . addonId ) return ;
try { t . view . webContents . setZoomFactor ( Math . max ( 25 , Math . min ( 500 , percent ) ) / 100 ) ; } catch { }
emitTabs ( ) ;
}
ipcMain . handle ( "zoom-step" , ( _e , dir ) => zoomStep ( activeTab ( ) , Number ( dir ) > 0 ? 1 : - 1 ) ) ;
ipcMain . handle ( "zoom-reset" , ( ) => zoomSet ( activeTab ( ) , 100 ) ) ;
// ---- HTTP authentication (401 / 407 challenges) ----
// Without a `login` listener Electron cancels every challenge, so a site
// behind Basic/Digest auth just rendered the server's bare 401 page
// (silentmode.st/guardian/admin, 2026-09-15). One modal prompt at a time;
// concurrent challenges for the same host+realm (a page plus its
// subresources) share the first answer. Successful credentials are cached
// by Chromium's network service for the session, so a page's later
// requests don't re-prompt.
const authPrompts = new Map ( ) ; // key -> Promise<{username,password}|null>
const authPending = new Map ( ) ; // reqId -> resolve
let authSeq = 0 , authQueue = Promise . resolve ( ) ;
function promptHttpAuth ( req ) {
const run = ( ) => new Promise ( ( resolve ) => {
if ( ! win || win . isDestroyed ( ) ) return resolve ( null ) ;
const id = ++ authSeq ;
const pw = new BrowserWindow ( {
parent : win , modal : true , show : false , width : 440 , height : 340 ,
resizable : false , minimizable : false , maximizable : false , fullscreenable : false ,
title : req . isProxy ? "Proxy sign-in" : "Sign in" ,
backgroundColor : nativeTheme . shouldUseDarkColors ? "#1c222c" : "#ffffff" ,
autoHideMenuBar : true ,
webPreferences : { preload : path . join ( _ _dirname , "auth-prompt-preload.js" ) } ,
} ) ;
let settled = false ;
const settle = ( v ) => { if ( settled ) return ; settled = true ; authPending . delete ( id ) ; resolve ( v ) ; if ( ! pw . isDestroyed ( ) ) pw . close ( ) ; } ;
authPending . set ( id , settle ) ;
pw . on ( "closed" , ( ) => settle ( null ) ) ;
pw . webContents . on ( "did-finish-load" , ( ) => { pw . webContents . send ( "auth-show" , { id , ... req } ) ; pw . show ( ) ; } ) ;
pw . loadFile ( path . join ( _ _dirname , "auth-prompt.html" ) ) . catch ( ( ) => settle ( null ) ) ;
} ) ;
const p = authQueue . then ( run , run ) ;
authQueue = p . catch ( ( ) => { } ) ;
return p ;
}
ipcMain . handle ( "auth-answer" , ( e , id , creds ) => {
const settle = authPending . get ( Number ( id ) ) ;
if ( ! settle ) return ;
// Only the prompt window itself may answer.
const isPrompt = [ ... BrowserWindow . getAllWindows ( ) ] . some ( ( w ) => w . webContents === e . sender && w . getParentWindow ( ) === win ) ;
if ( ! isPrompt ) return ;
settle ( creds && typeof creds . username === "string" ? { username : creds . username , password : String ( creds . password || "" ) } : null ) ;
} ) ;
app . on ( "login" , ( event , _wc , details , authInfo , callback ) => {
// Proxy auth configured by an add-on is answered by its own handler.
if ( authInfo ? . isProxy && proxyLoginHandler ) return ;
event . preventDefault ( ) ;
const host = authInfo ? . host || "" ;
const port = authInfo ? . port ;
const origin = ( authInfo ? . isProxy ? "" : ( String ( details ? . url || "" ) . startsWith ( "http:" ) ? "http://" : "https://" ) )
+ host + ( port && port !== 80 && port !== 443 ? ":" + port : "" ) ;
const key = ` ${ authInfo ? . isProxy ? "proxy" : "site" } | ${ host } : ${ port } | ${ authInfo ? . realm || "" } ` ;
let p = authPrompts . get ( key ) ;
if ( ! p ) {
p = promptHttpAuth ( { origin , realm : authInfo ? . realm || "" , scheme : authInfo ? . scheme || "" , isProxy : ! ! authInfo ? . isProxy ,
insecure : ! authInfo ? . isProxy && String ( details ? . url || "" ) . startsWith ( "http:" ) } ) ;
authPrompts . set ( key , p ) ;
// Share only while the prompt is open. Once answered, a fresh challenge
// for the same realm means the server rejected those credentials, and
// the user must be asked again (Chromium caches accepted ones itself).
p . finally ( ( ) => authPrompts . delete ( key ) ) ;
}
p . then ( ( c ) => { if ( c ) callback ( c . username , c . password ) ; else callback ( ) ; } , ( ) => callback ( ) ) ;
} ) ;
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
// https:// is what the user sees regardless of how the bytes were fetched.
function refreshTabUrl ( tab ) {
if ( ! tab || tab . prov ? . kind === "home" ) return ; // home is loadFile → file://; leave t.url = ""
try {
const raw = tab . view . webContents . getURL ( ) ;
// file: is normally our own surface (home/error pages) and never shown;
// a tab the user pointed at a local file is the exception.
if ( raw && ( ! raw . startsWith ( "file:" ) || tab . prov ? . kind === "file" ) ) tab . url = raw . replace ( /^bns:\/\// , "https://" ) ;
} catch { }
}
function loadHome ( id ) {
const t = tabById ( id ) ; if ( ! t ) return ;
t . url = "" ; t . title = "Theseus" ; t . prov = { host : "" , kind : "home" } ;
t . view . webContents . loadFile ( "home.html" ) ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
// Errors we deliberately ignore (Chromium's own reasons that shouldn't show
// a user-facing error page):
// -3 ERR_ABORTED — navigation superseded by another / user pressed Stop
// -20 ERR_BLOCKED_BY_CLIENT — extension/ad-blocker style cancel
const ERROR _CODE _IGNORE = new Set ( [ - 3 , - 20 ] ) ;
// Chromium error-code buckets. Keep the ranges narrow — anything unmapped
// falls through to the generic error page.
// -105 ERR_NAME_NOT_RESOLVED
// -102 ERR_CONNECTION_REFUSED
// -101 ERR_CONNECTION_RESET
// -118 ERR_CONNECTION_TIMED_OUT
// -100 ERR_CONNECTION_CLOSED
// -7 ERR_TIMED_OUT
// -21 ERR_NETWORK_CHANGED
const ERROR _UNREACHABLE = new Set ( [ - 102 , - 101 , - 118 , - 100 , - 7 , - 21 ] ) ;
function pickErrorKind ( code , host ) {
if ( code === - 105 ) {
// Name didn't resolve. If the host is BCNR-eligible (has a real TLD),
// that also means BCNR had no record — otherwise resolveHost/loadBns
// would have served something. Treat as "not registered" to promote
// the register-on-Sirius action.
return isBnsHost ( host ) ? "name-not-registered" : "name-unreachable" ;
}
if ( ERROR _UNREACHABLE . has ( code ) ) return "unreachable" ;
if ( code <= - 200 && code >= - 299 ) return "tls" ; // ERR_CERT_* range
return "generic" ;
}
// Load the branded error surface for a failed navigation. Keeps t.url =
// the attempted URL so the address bar still shows what the user asked
// for and they can edit + retry; refreshTabUrl already skips file:// so
// the error page's own path never leaks back into the bar.
function loadErrorPage ( t , id , { url , code , desc } ) {
if ( ! t ) return ;
const failedUrl = String ( url || t . url || "" ) ;
let host = "" ;
try { host = new URL ( failedUrl ) . hostname ; } catch { }
const kind = pickErrorKind ( code , host ) ;
const q = new URLSearchParams ( {
kind , host , url : failedUrl ,
code : String ( code || "" ) , desc : String ( desc || "" ) ,
} ) . toString ( ) ;
t . internalNav = true ;
t . title = host ? "Error — " + host : "Load error" ;
t . prov = { host , kind : "error" , code , desc , local : failedUrl . startsWith ( "file:" ) } ;
t . view . webContents . loadFile ( path . join ( _ _dirname , "error.html" ) , { search : q } )
. catch ( ( e ) => console . warn ( "error page load failed:" , e ? . message ) )
. finally ( ( ) => { t . internalNav = false ; } ) ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
// Scrollbar theme injected into every webContents we own — tabs, chrome,
// sidebar, all the floating popovers, and every add-on panel host. Track
// picks up a subtle neutral grey (works on both dark and light surfaces
// without hardcoding either); thumb is the BCH primary #0AC18E so every
// scroll surface reads as Silent Mode's. `scrollbar-color` is the modern
// standard (Chromium ≥ 121); the ::-webkit- fallback gives us fine control
// over width, radius and hover state on older engines. `!important` on the
// track / thumb wins over per-page overrides so the branding stays visible
// even on sites that theme their own scrollbars — but we deliberately don't
// force `scrollbar-width` so a page that has hidden its scrollbars entirely
// keeps that behaviour.
const SCROLLBAR _CSS = `
html { scrollbar - color : # 0 AC18E rgba ( 120 , 130 , 150 , 0.18 ) ; }
: : - webkit - scrollbar { width : 12 px ; height : 12 px ; background : rgba ( 120 , 130 , 150 , 0.18 ) ! important ; }
: : - webkit - scrollbar - track { background : rgba ( 120 , 130 , 150 , 0.18 ) ! important ; }
: : - webkit - scrollbar - thumb { background : # 0 AC18E ! important ; border - radius : 6 px ;
border : 2 px solid transparent ; background - clip : padding - box ! important ; }
: : - webkit - scrollbar - thumb : hover { background : # 14e0 a5 ! important ; background - clip : padding - box ! important ; }
: : - webkit - scrollbar - corner { background : transparent ! important ; }
` ;
function styleScrollbars ( wc ) {
if ( ! wc ) return ;
const inject = ( ) => { try { wc . insertCSS ( SCROLLBAR _CSS ) ; } catch { } } ;
wc . on ( "dom-ready" , inject ) ;
// For a wc that's already past dom-ready when we attach (fixed views load
// fast during startup), fire once explicitly.
try { if ( ! wc . isLoading ( ) ) inject ( ) ; } catch { }
}
function createTab ( initial , opts = { } ) {
const id = ++ tabSeq ;
// Non-settings tabs get home-preload so the built-in home page can round-
// trip its editable-cards state via IPC. IPC handlers reject any call
// whose sender URL isn't our own home.html, so a third-party page sees
// the API's shape but can't act through it.
// Preload picker: settings and add-on-file tabs each need their own IPC
// surface; everything else gets home-preload (superset of a plain web
// page's needs, plus the home-page card wiring).
const preloadPath = opts . settings ? path . join ( _ _dirname , "settings-preload.js" )
: opts . addonFile ? path . join ( _ _dirname , "addon-tab-preload.js" )
: path . join ( _ _dirname , "home-preload.js" ) ;
const view = new WebContentsView ( { webPreferences : { preload : preloadPath } } ) ;
// Explicit solid background: transparent (Electron default) makes the tab
// view flash to whatever's underneath (which can be the just-hidden tab or
// black) between setVisible(true) and the first paint on tab switch. A
// solid ground kills that flash. Colour tracks the system theme so light-
// mode users don't get a dark stub while a page paints.
try { view . setBackgroundColor ( nativeTheme . shouldUseDarkColors ? "#0b0e14" : "#ffffff" ) ; } catch { }
const wc = view . webContents ;
styleScrollbars ( wc ) ;
try { wc . setWebRTCIPHandlingPolicy ( webrtcPolicy ( ) ) ; } catch { }
try { wc . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { }
applyFingerprint ( wc ) ;
const tab = { id , view , title : opts . settings ? "Settings" : "New Tab" , url : "" , favicon : null , prov : null , settings : ! ! opts . settings , muted : false , group : null , openerId : opts . after ? ? null } ;
// A tab opened from a link goes right after the tab it came from — and
// after any siblings that tab already opened, so a run of links from one
// page reads left-to-right — instead of at the far end of the strip.
// Everything else (+ button, restore, add-on requests) appends as before.
const openerIdx = opts . after != null ? tabs . findIndex ( ( t ) => t . id === opts . after ) : - 1 ;
if ( openerIdx < 0 ) tabs . push ( tab ) ;
else {
let at = openerIdx + 1 ;
while ( at < tabs . length && tabs [ at ] . openerId === opts . after ) at ++ ;
tabs . splice ( at , 0 , tab ) ;
}
win . contentView . addChildView ( view ) ;
wc . on ( "page-title-updated" , ( _e , title ) => {
tab . title = title ; emitTabs ( ) ;
// Keep the top-of-history title in sync when a page's title loads late.
if ( history [ 0 ] && tab . url && history [ 0 ] . url === tab . url ) { history [ 0 ] . title = title ; saveHistoryDebounced ( ) ; }
} ) ;
// Site favicon → tab icon. Take the first URL Electron emits (usually the
// 32x32 or 16x16 <link rel="icon">). We don't proactively clear on nav —
// mainstream browsers keep the old icon until the new one arrives, which
// avoids a flash on every subpage click.
wc . on ( "page-favicon-updated" , ( _e , urls ) => {
const next = ( urls && urls [ 0 ] ) || null ;
if ( tab . favicon !== next ) { tab . favicon = next ; emitTabs ( ) ; }
} ) ;
// Chromium fires found-in-page on every findInPage call + on subsequent
// match walks. Forward to chrome so the find bar shows "N of M".
wc . on ( "found-in-page" , ( _e , r ) => {
if ( tab . id !== activeId ) return ;
try { chrome ? . webContents . send ( "find-result" , { activeMatchOrdinal : r . activeMatchOrdinal , matches : r . matches , finalUpdate : r . finalUpdate } ) ; } catch { }
} ) ;
// A new document means a new (or no) web-app manifest; the chip follows.
wc . on ( "did-navigate" , ( ) => { tab . webapp = null ; } ) ;
wc . on ( "did-navigate" , ( ) => { refreshTabUrl ( tab ) ; emitTabs ( ) ; historyAdd ( tab . url , tab . title ) ; } ) ;
wc . on ( "did-navigate-in-page" , ( ) => { refreshTabUrl ( tab ) ; emitTabs ( ) ; historyAdd ( tab . url , tab . title ) ; } ) ;
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
// Linux, the zoom itself is up to us.
wc . on ( "zoom-changed" , ( _e , dir ) => zoomStep ( tab , dir === "in" ? 1 : - 1 ) ) ;
wc . on ( "did-start-loading" , ( ) => setLoading ( tab , true ) ) ;
wc . on ( "did-stop-loading" , ( ) => setLoading ( tab , false ) ) ;
// Installable site? Read its manifest once the document is in; the chip
// and the page's own beforeinstallprompt follow from tab.webapp.
wc . on ( "did-finish-load" , ( ) => { if ( ! tab . settings && ! tab . addonId ) webapps . probeTab ( tab ) . then ( ( ) => { if ( tab . id === activeId ) emitTabs ( ) ; } ) . catch ( ( ) => { } ) ; } ) ;
// Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc.
// Show the branded error page instead of Chromium's default "This site
// can't be reached". Skip subframe errors, our own programmatic loads,
// and the couple of Chromium codes that fire on normal user actions
// (Stop / superseded nav / extension cancel).
wc . on ( "did-fail-load" , ( _e , code , desc , validatedURL , isMainFrame ) => {
if ( ! isMainFrame ) return ;
if ( tab . internalNav ) return ;
if ( ERROR _CODE _IGNORE . has ( code ) ) return ;
loadErrorPage ( tab , tab . id , { url : validatedURL || tab . url , code , desc } ) ;
} ) ;
// Firefox / Chrome-style bottom-left link preview: fires with the href
// when the pointer enters/leaves an anchor. Empty string = no hover.
wc . on ( "update-target-url" , ( _e , url ) => { if ( tab . id === activeId ) showLinkStatus ( url ) ; } ) ;
wc . on ( "will-navigate" , ( e , u ) => {
try {
// Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/")
// and that host is often a BCNR-registered dotted name — without this guard,
// isBnsHost() would send us right back into navigateTab, canceling the
// fallback (blank-page bug 2026-08-02).
if ( tab . internalNav ) return ;
const installId = installLinkId ( u ) ;
if ( installId ) {
e . preventDefault ( ) ;
let requester = null ; try { requester = new URL ( wc . getURL ( ) ) . host || null ; } catch { }
installExtensionWithConsent ( installId , requester ) ;
return ;
}
const parsed = new URL ( u ) ;
// Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns
// doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync.
if ( parsed . protocol === "bns:" && parsed . hostname === "collision-choose" ) {
e . preventDefault ( ) ;
const p = parsed . searchParams ;
const target = String ( p . get ( "host" ) || "" ) . toLowerCase ( ) ;
const cTld = String ( p . get ( "tld" ) || "" ) . toLowerCase ( ) ;
const cChoice = p . get ( "choice" ) ;
const cRem = p . get ( "remember" ) || "no" ;
const rest = p . get ( "resturl" ) || "/" ;
if ( ! target ) return ;
if ( cChoice === "bcnr" || cChoice === "icann" ) {
rememberCollision ( target , cTld , cChoice , cRem ) ;
tab . collisionOverride = cChoice ; // one-shot, consumed by loadBns
}
return navigateTab ( id , target + rest ) ;
}
// A wiz:// click never navigates — it hands the pairing URI to the
// wallet and leaves the dapp exactly where it is.
if ( parsed . protocol === "wiz:" ) {
e . preventDefault ( ) ;
routeWizUri ( u , pageOriginOf ( wc . getURL ( ) ) , tab . id ) ;
return ;
}
if ( parsed . protocol === "bns:" ) return ;
if ( isBnsHost ( parsed . hostname ) ) {
// Only intercept cross-origin navigations. Same-origin (a form submit
// or a subpage link on the site we're currently on) must go through
// Chromium natively — our navigateTab path calls loadURL(url), which
// is always a GET and drops any POST body. That silently broke
// Startpage (whose in-page search form POSTs to /do/search), and any
// other site that POSTs (logins, comment submits, checkouts, ...).
// The site is already loaded from clearnet, so its subsequent
// navigation belongs to clearnet too — no BCNR re-lookup needed.
let currentHost = "" ;
try { currentHost = new URL ( wc . getURL ( ) ) . hostname ; } catch { }
if ( currentHost === parsed . hostname ) return ;
// Preserve query + fragment. Dropping them broke every search engine
// that submits via a classic form GET (Google's /search?q=foo lost
// the ?q=, so the results page opened blank).
e . preventDefault ( ) ;
navigateTab ( id , u . replace ( /^[a-z]+:\/\//i , "" ) ) ;
}
} catch { }
} ) ;
// "You have unsaved changes" confirmation: fires when the page's beforeunload
// handler is trying to keep the user on the page (e.g. an unsent form draft,
// an editor with a dirty document). Show a native confirm; on "Leave", call
// preventDefault to override the block. Applies to both link clicks AND our
// programmatic loads (chip switcher, address-bar navigation).
wc . on ( "will-prevent-unload" , ( e ) => {
const parent = BrowserWindow . getFocusedWindow ( ) || win ;
const choice = dialog . showMessageBoxSync ( parent , {
type : "question" ,
buttons : [ "Stay on page" , "Leave anyway" ] ,
defaultId : 0 ,
cancelId : 0 ,
title : "Unsaved changes" ,
message : "This page is asking you to stay." ,
detail : "You may have unsaved changes that will be lost if you leave." ,
} ) ;
if ( choice === 1 ) e . preventDefault ( ) ; // Leave anyway -> override the beforeunload
} ) ;
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc . setWindowOpenHandler ( ( { url , disposition } ) => {
// target="_blank" on a wiz:// link lands here rather than will-navigate.
// Route it to the wallet instead of opening a tab on an unloadable URL.
if ( url && /^wiz:/i . test ( url ) ) {
routeWizUri ( url , pageOriginOf ( wc . getURL ( ) ) , tab . id ) ;
return { action : "deny" } ;
}
const installId = installLinkId ( url ) ;
if ( installId ) {
let requester = null ; try { requester = new URL ( wc . getURL ( ) ) . host || null ; } catch { }
installExtensionWithConsent ( installId , requester ) ;
} else if ( url && url !== "about:blank" ) createTab ( url , { background : disposition === "background-tab" , after : tab . id } ) ;
return { action : "deny" } ;
} ) ;
// Right-click context menu.
wc . on ( "context-menu" , ( _e , p ) => {
const items = [ ] ;
if ( p . linkURL ) {
items . push (
{ label : "Open link in new tab" , click : ( ) => createTab ( p . linkURL , { after : tab . id } ) } ,
{ label : "Open link in new background tab" , click : ( ) => createTab ( p . linkURL , { background : true , after : tab . id } ) } ,
{ label : "Open link in new window" , click : ( ) => openLinkWindow ( p . linkURL ) } ,
{ label : "Copy link address" , click : ( ) => clipboard . writeText ( p . linkURL ) } ,
{ type : "separator" } ,
) ;
}
// Image context menu: only when the pointer is actually on an image, and
// we have a src to act on. Save-image-as triggers will-download with no
// preset savePath, so Electron shows the native Save As dialog.
if ( p . mediaType === "image" && p . srcURL ) {
items . push (
{ label : "Open image in new tab" , click : ( ) => createTab ( p . srcURL , { after : tab . id } ) } ,
{ label : "Save image as…" , click : ( ) => wc . downloadURL ( p . srcURL ) } ,
{ label : "Copy image" , click : ( ) => { try { wc . copyImageAt ( p . x , p . y ) ; } catch { } } } ,
{ label : "Copy image address" , click : ( ) => clipboard . writeText ( p . srcURL ) } ,
{ type : "separator" } ,
) ;
}
if ( p . isEditable ) items . push ( { role : "cut" } , { role : "copy" } , { role : "paste" } , { type : "separator" } ) ;
else if ( p . selectionText ) items . push ( { role : "copy" } , { type : "separator" } ) ;
// "Search for …" when text is selected. Label uses a short excerpt so
// a long selection doesn't stretch the menu. Opens in a new foreground
// tab so the current page isn't lost — matches Chrome / Firefox UX.
if ( p . selectionText ) {
const raw = p . selectionText . replace ( /\s+/g , " " ) . trim ( ) ;
if ( raw ) {
const excerpt = raw . length > 40 ? raw . slice ( 0 , 40 ) + "…" : raw ;
items . push (
{ label : ` Search for " ${ excerpt . replace ( /&/g , "&&" ) } " ` , click : ( ) => createTab ( SEARCH ( raw ) , { after : tab . id } ) } ,
{ type : "separator" } ,
) ;
}
}
// Add-on context-menu items. Add-ons that declare "context-menu-item"
// filter by `when` (selectionText / linkURL / editable / image /
// always) matched against Electron's params. Click dispatches the
// "context-menu" message to the add-on with the full context.
try {
const addonItems = addonHost ? addonHost . getContextMenuItems ( {
selectionText : p . selectionText , linkURL : p . linkURL ,
mediaType : p . mediaType , srcURL : p . srcURL , isEditable : p . isEditable ,
pageURL : p . pageURL ,
} ) : [ ] ;
if ( addonItems . length ) {
for ( const it of addonItems ) {
const label = ( it . icon ? String ( it . icon ) + " " : "" ) + String ( it . label || it . id ) ;
items . push ( { label , click : ( ) => {
const payload = {
itemId : it . id ,
selectionText : p . selectionText || "" ,
linkURL : p . linkURL || "" ,
mediaType : p . mediaType || "" ,
srcURL : p . srcURL || "" ,
pageURL : p . pageURL || ( wc && wc . getURL ( ) ) || "" ,
host : ( ( ) => { try { return new URL ( p . pageURL || wc . getURL ( ) ) . host ; } catch { return "" ; } } ) ( ) ,
} ;
addonHost . dispatch ( it . addonId , "context-menu" , payload , { from : "context-menu" } )
. catch ( ( err ) => console . warn ( ` [addons] context-menu ${ it . addonId } . ${ it . id } failed: ` , err ? . message || err ) ) ;
} } ) ;
}
items . push ( { type : "separator" } ) ;
}
} catch ( err ) { console . warn ( "context-menu addon merge failed:" , err ? . message || err ) ; }
items . push (
{ label : "Back" , enabled : wc . navigationHistory . canGoBack ( ) , click : ( ) => wc . navigationHistory . goBack ( ) } ,
{ label : "Forward" , enabled : wc . navigationHistory . canGoForward ( ) , click : ( ) => wc . navigationHistory . goForward ( ) } ,
{ label : "Reload" , click : ( ) => wc . reload ( ) } ,
) ;
if ( tab . webapp ) {
const inst = webapps . find ( tab . webapp . key ) ;
items . push ( { type : "separator" } , inst
? { label : ` Open ${ tab . webapp . name } in its window ` , click : ( ) => webapps . open ( inst ) }
: { label : ` Install ${ tab . webapp . name } … ` , click : ( ) => installWebAppFromTab ( tab ) } ) ;
}
Menu . buildFromTemplate ( items ) . popup ( ) ;
} ) ;
layout ( ) ;
if ( opts . background ) { view . setVisible ( false ) ; emitTabs ( ) ; }
else setActive ( id ) ;
if ( opts . settings ) {
tab . prov = { host : "" , kind : "home" } ;
const settingsOpts = opts . settingsSection ? { hash : opts . settingsSection } : undefined ;
wc . loadFile ( "settings.html" , settingsOpts ) ;
if ( id === activeId ) pushNav ( tab . prov ) ;
emitTabs ( ) ;
} else if ( opts . addonFile ) {
// Same treatment as settings: leave the address bar empty (refreshTabUrl
// skips file:// anyway), title arrives via page-title-updated. loadFile
// takes the query as `search` (Node's url.format shape) without the ?.
tab . prov = { host : "" , kind : "home" } ;
tab . addonId = opts . addonFile . addonId ;
wc . loadFile ( opts . addonFile . absPath , opts . addonFile . query ? { search : opts . addonFile . query } : undefined ) ;
if ( id === activeId ) pushNav ( tab . prov ) ;
emitTabs ( ) ;
} else if ( initial ) navigateTab ( id , initial ) ;
else loadHome ( id ) ;
return id ;
}
function closeTab ( id ) {
const i = tabs . findIndex ( ( t ) => t . id === id ) ;
if ( i < 0 ) return ;
const [ t ] = tabs . splice ( i , 1 ) ;
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
dismissJsDialogFor ( id ) ;
2026-09-27 11:24:36 +02:00
win . contentView . removeChildView ( t . view ) ;
t . view . webContents . destroy ? . ( ) ;
if ( tabs . length === 0 ) { createTab ( ) ; return ; }
if ( activeId === id ) setActive ( tabs [ Math . max ( 0 , i - 1 ) ] . id ) ;
else emitTabs ( ) ;
}
function createWindow ( ) {
chromeReadyDone = false ;
overlaysLoaded = false ;
sessionSavedAtClose = false ;
if ( tabs . length ) { // leftovers from a window that closed while app windows kept the process alive
for ( const t of tabs . splice ( 0 ) ) { try { t . view . webContents . destroy ? . ( ) ; } catch { } }
activeId = null ;
}
// Window ground + chrome view ground both match chrome.html's --bg for the
// active theme. The chrome view used to sit on Chromium's default white
// until chrome.html painted, which is the "white strip over a dark
// window" users saw on a slow launch.
const uiBg = nativeTheme . shouldUseDarkColors ? "#0f1420" : "#e6e8ec" ;
// On Windows the tab strip lives in the title bar: the frame is hidden and
// the native minimise/maximise/close buttons are drawn as an overlay over
// our chrome, whose tab row is a drag region (chrome.html). That returns
// the OS title bar's height to the page. Kept native elsewhere.
const titleBarOverlay = { color : uiBg , symbolColor : nativeTheme . shouldUseDarkColors ? "#e7eaf1" : "#1a1f28" , height : 40 } ;
const frameOpts = process . platform === "win32" ? { titleBarStyle : "hidden" , titleBarOverlay } : { } ;
win = new BrowserWindow ( {
width : 1220 , height : 840 , title : "Theseus Navigator" , backgroundColor : uiBg , ... frameOpts ,
// Taskbar / titlebar icon. Packaged builds ship build/icon.ico as
// extraResource; dev reads the source file directly.
icon : app . isPackaged
? path . join ( process . resourcesPath , "icon.ico" )
: path . join ( _ _dirname , "build" , "icon.ico" ) ,
} ) ;
// Keep the overlay buttons on the theme when it flips at runtime.
if ( process . platform === "win32" ) {
nativeTheme . on ( "updated" , ( ) => {
if ( ! win || win . isDestroyed ( ) ) return ;
const dark = nativeTheme . shouldUseDarkColors ;
try { win . setTitleBarOverlay ( { color : dark ? "#0f1420" : "#e6e8ec" , symbolColor : dark ? "#e7eaf1" : "#1a1f28" , height : 40 } ) ; } catch { }
} ) ;
}
chrome = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "preload.js" ) } } ) ;
try { chrome . setBackgroundColor ( uiBg ) ; } catch { }
win . contentView . addChildView ( chrome ) ;
styleScrollbars ( chrome . webContents ) ;
chrome . webContents . loadFile ( "chrome.html" ) ;
// The overlays below are created now (cheap) but their pages load via
// deferOverlayLoad → loadOverlays() after chrome has painted.
// Floating site-info overlay (hidden until the address-bar badge is clicked).
popover = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "popover-preload.js" ) } } ) ;
try { popover . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( popover ) ;
styleScrollbars ( popover . webContents ) ;
deferOverlayLoad ( popover , "popover.html" ) ;
popover . setVisible ( false ) ;
// Floating engine-picker overlay (custom dropdown with real favicons).
enginePicker = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "engine-picker-preload.js" ) } } ) ;
try { enginePicker . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( enginePicker ) ;
styleScrollbars ( enginePicker . webContents ) ;
deferOverlayLoad ( enginePicker , "engine-picker.html" ) ;
enginePicker . setVisible ( false ) ;
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "downloads-preload.js" ) } } ) ;
try { downloadsPop . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( downloadsPop ) ;
styleScrollbars ( downloadsPop . webContents ) ;
deferOverlayLoad ( downloadsPop , "downloads.html" ) ;
downloadsPop . setVisible ( false ) ;
// Floating address-bar suggestions dropdown.
addressPicker = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "address-picker-preload.js" ) } } ) ;
try { addressPicker . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( addressPicker ) ;
styleScrollbars ( addressPicker . webContents ) ;
deferOverlayLoad ( addressPicker , "address-picker.html" ) ;
addressPicker . setVisible ( false ) ;
// Floating password-fill picker.
pwFillPop = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "pw-fill-preload.js" ) } } ) ;
try { pwFillPop . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( pwFillPop ) ;
styleScrollbars ( pwFillPop . webContents ) ;
deferOverlayLoad ( pwFillPop , "pw-fill.html" ) ;
pwFillPop . setVisible ( false ) ;
// Link-hover status pill (bottom-left of window).
linkStatus = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "link-status-preload.js" ) } } ) ;
try { linkStatus . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( linkStatus ) ;
styleScrollbars ( linkStatus . webContents ) ;
deferOverlayLoad ( linkStatus , "link-status.html" ) ;
linkStatus . setVisible ( false ) ;
// Add-on sidebar host. Doesn't loadFile until an add-on panel is opened —
// styleScrollbars hooks dom-ready, which fires per navigation, so every
// panel loaded into this view (Aegis, Screenshot, etc.) picks up the
// brand scrollbar the moment its DOM is ready.
sidebar = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "sidebar-preload.js" ) } } ) ;
win . contentView . addChildView ( sidebar ) ;
styleScrollbars ( sidebar . webContents ) ;
sidebar . setVisible ( false ) ;
// Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "approval-preload.js" ) } } ) ;
try { approvalPop . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( approvalPop ) ;
styleScrollbars ( approvalPop . webContents ) ;
deferOverlayLoad ( approvalPop , "approval.html" ) ;
approvalPop . setVisible ( false ) ;
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
// Page dialogs (alert / confirm / prompt) — see the js-dialog block.
jsDialogPop = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "js-dialog-preload.js" ) } } ) ;
try { jsDialogPop . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( jsDialogPop ) ;
styleScrollbars ( jsDialogPop . webContents ) ;
deferOverlayLoad ( jsDialogPop , "js-dialog.html" ) ;
jsDialogPop . setVisible ( false ) ;
2026-09-27 11:24:36 +02:00
// Everything that isn't needed to paint the toolbar waits for chrome.html.
// dom-ready, NOT did-finish-load: the load event also waits for every
// subresource, and the bookmarks bar pulls its favicons over bns:// —
// a BNS lookup plus a network fetch each. On a slow link that held the
// load event (and with it every restored tab) for seconds while the
// toolbar sat blank. By dom-ready the toolbar's scripts have run and its
// IPC listeners exist, which is all the rest of boot needs. The fallback
// timer covers a chrome.html that fails to load at all.
chrome . webContents . once ( "dom-ready" , onChromeReady ) ;
setTimeout ( onChromeReady , 8000 ) ;
win . on ( "resize" , layout ) ;
// The browser window can close while app windows (webapps.js) keep the
// process alive. Capture the session while the tabs are still here, then
// let go of the window's views: tab events (hover → update-target-url,
// title updates) keep arriving during teardown and used to reach the
// destroyed window through positionLinkStatus (2026-09-27).
win . on ( "close" , ( ) => { saveSession ( ) ; sessionSavedAtClose = true ; } ) ;
win . on ( "closed" , ( ) => {
win = null ; chrome = null ; popover = null ; enginePicker = null ; downloadsPop = null ;
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
dismissJsDialogFor ( null ) ;
addressPicker = null ; pwFillPop = null ; linkStatus = null ; sidebar = null ; approvalPop = null ; jsDialogPop = null ;
2026-09-27 11:24:36 +02:00
linkStatusVisible = false ;
} ) ;
layout ( ) ;
}
async function navigateTab ( id , input ) {
const t = tabById ( id ) ; if ( ! t ) return ;
let q = String ( input ) . trim ( ) ;
if ( ! q ) return ;
// theseus://extensions/install/<id> typed or pasted into the address bar.
const installId = installLinkId ( q ) ;
if ( installId ) { installExtensionWithConsent ( installId , null ) ; return ; }
// Local paths open as files — never BCNR, never a search.
const fileUrl = localFileUrl ( q ) ;
if ( fileUrl ) return loadLocalFile ( t , id , fileUrl ) ;
// Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search.
if ( ! looksLikeUrl ( q ) ) q = SEARCH ( q ) ;
const raw = q . replace ( /^[a-z]+:\/\//i , "" ) ;
const host = raw . split ( "/" ) [ 0 ] . toLowerCase ( ) ;
const rest = raw . slice ( host . length ) || "/" ;
// Reflect the target URL immediately so the address bar doesn't keep
// showing the previous page's URL for the whole load duration. Without
// this, emitTabs below (triggered by setLoading) carries the old t.url
// and the chrome renderer paints it, since we blurred the input on Enter.
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
setLoading ( t , true ) ; // show the loading indicator immediately (covers BNS resolution)
t . nav = ( t . nav || 0 ) + 1 ;
// Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
t . bcnrOffer = null ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , null ) ;
// BCNR-first for every dotted host. loadBns falls through to https://<host>
// on NXDOMAIN or resolver failure, so clearnet still works.
if ( isBnsHost ( host ) ) return loadBns ( t , id , host , rest , tldOf ( host ) ) ;
// Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
t . url = q . includes ( "://" ) ? q : "https://" + q ;
await t . view . webContents . loadURL ( t . url ) ;
t . prov = { host , kind : "web" } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
// Open a local file (file:// URL) in a tab. A missing file surfaces through
// did-fail-load → loadErrorPage like any other failed navigation.
async function loadLocalFile ( t , id , fileUrl ) {
t . url = fileUrl ;
t . prov = { host : "" , kind : "file" } ;
t . bcnrOffer = null ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , null ) ;
setLoading ( t , true ) ;
t . nav = ( t . nav || 0 ) + 1 ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
try { await t . view . webContents . loadURL ( fileUrl ) ; }
catch ( e ) { console . warn ( "local file load failed:" , e ? . message ) ; }
}
// Load a name from BCNR into a tab. Called for every dotted host — BCNR is
// tried first; on NXDOMAIN or resolver failure we always fall through to the
// clearnet (https://<host><rest>) so the user isn't stranded when the chain
// is down or the name isn't registered.
async function loadBns ( t , id , host , rest , tld ) {
const registry = registryOf ( tld ) ;
if ( id === activeId ) pushNav ( { host , kind : "resolving" , tld , registry } ) ;
const fallbackToWeb = async ( reason ) => {
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
t . internalNav = true ;
try { await t . view . webContents . loadURL ( t . url ) ; }
catch ( e ) { console . warn ( "fallback loadURL failed:" , e ? . message ) ; }
finally { t . internalNav = false ; }
t . prov = { host , kind : "web" , note : ` fallback: ${ reason } ` , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
} ;
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
// (harmless) if absent.
let urlChoice = null ;
try {
const u = new URL ( rest , ` bns:// ${ host } / ` ) ;
if ( u . searchParams . has ( "_collision" ) ) {
urlChoice = u . searchParams . get ( "_collision" ) ;
u . searchParams . delete ( "_collision" ) ;
rest = u . pathname + ( u . search ? u . search : "" ) ;
}
} catch { }
let entry ;
try { entry = await resolveHost ( host ) ; }
catch { setLoading ( t , false ) ; return fallbackToWeb ( "BCNR unreachable" ) ; }
// Address-bar display: show https:// even for BCDN sites. Rationale — BCNR
// replaces DNS (name resolution), NOT HTTP (transport). For s3/ip/p records
// the actual delivery IS HTTPS under the hood; for h records the content is
// on-chain (no HTTP at all, but https:// is the least surprising display).
// The BCDN/ICANN badge is the source-of-truth for which registry served us;
// the URL scheme is a convention, kept consistent so users' muscle memory
// holds. (bns:// is an internal Electron protocol implementation detail.)
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
if ( ! entry ) { setLoading ( t , false ) ; return fallbackToWeb ( "no BCNR record" ) ; }
// ---- Collision policy (BCNR ↔ ICANN) --------------------------------------
// BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's,
// no collision possible). Otherwise it's a collision candidate — the same name
// *might* also exist on ICANN; the policy decides which to load.
// Full model: SilentMode/Argus/DESIGN-collision-modes.md.
if ( ! isBcnrNativeTld ( tld ) ) {
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
const transient = t . collisionOverride ; t . collisionOverride = null ;
const policy = settings . collisionPolicy || "bcnr-first" ;
// Precedence: urlChoice (one-shot from collision-choose) > transient (chip
// switcher) > persistent per-name/per-TLD > global policy.
let choice = urlChoice || transient || overrideFor ( host , tld ) ;
if ( ! choice ) {
if ( policy === "icann-first" ) choice = "icann" ;
else if ( policy === "soft" ) {
// In-tab full-page "Open with…" prompt (loaded from disk; buttons post
// back through bns://collision-choose/ which serveBns handles above).
setLoading ( t , false ) ;
const q = new URLSearchParams ( { host , tld , resturl : rest } ) . toString ( ) ;
await t . view . webContents . loadFile ( path . join ( _ _dirname , "collision.html" ) , { search : q } ) ;
t . prov = { host , kind : "resolving" , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
return ;
} else choice = "bcnr" ; // bcnr-first — the default
}
if ( choice === "icann" ) { setLoading ( t , false ) ; return fallbackToWeb ( "collision → ICANN" ) ; }
}
await t . view . webContents . loadURL ( ` bns:// ${ host } ${ rest } ` ) ;
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
// routes via the parent's server, not via Sia. See serveBns for the rule.
const _isSub = host !== entry . name ;
const src = ( _isSub && entry . records . ip ) ? "direct server"
: entry . records . h ? "on-chain (chain)"
: entry . records . s3 ? "Sia network"
: entry . records . ip ? "direct server"
: ( ! _isSub && entry . records . p ) ? "mirror"
: entry . records . u ? "redirect"
: "record" ;
t . prov = { host , kind : "ok" , source : src , category : entry . category , records : Object . keys ( entry . records ) , dns : dnsRecordKinds ( entry ) , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
ipcMain . handle ( "navigate" , ( _e , input ) => navigateTab ( activeId , input ) ) ;
ipcMain . handle ( "search" , ( _e , q ) => navigateTab ( activeId , SEARCH ( q ) ) ) ;
ipcMain . handle ( "new-tab" , ( ) => createTab ( ) ) ;
ipcMain . handle ( "close-tab" , ( _e , id ) => closeTab ( id ) ) ;
ipcMain . handle ( "switch-tab" , ( _e , id ) => setActive ( id ) ) ;
// Tab context menu backing IPCs. All scoped to a specific tab id so the
// active tab doesn't have to be the one the user right-clicked.
ipcMain . handle ( "tab-reload" , ( _e , id ) => { const t = tabById ( id ) ; if ( t ) try { t . view . webContents . reload ( ) ; } catch { } } ) ;
ipcMain . handle ( "tab-duplicate" , ( _e , id ) => {
const t = tabById ( id ) ; if ( ! t ) return ;
const target = t . url || "" ;
if ( target ) createTab ( target ) ; else createTab ( ) ;
} ) ;
ipcMain . handle ( "tab-mute" , ( _e , id , on ) => {
const t = tabById ( id ) ; if ( ! t ) return false ;
const want = typeof on === "boolean" ? on : ! t . muted ;
try { t . view . webContents . setAudioMuted ( want ) ; t . muted = want ; emitTabs ( ) ; return want ; }
catch { return t . muted ; }
} ) ;
ipcMain . handle ( "tab-group" , ( _e , id , color ) => {
const t = tabById ( id ) ; if ( ! t ) return null ;
// color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const allowed = new Set ( [ "red" , "orange" , "yellow" , "green" , "cyan" , "blue" , "purple" ] ) ;
const next = allowed . has ( color ) ? color : null ;
t . group = next ;
// Cluster: move this tab so all same-group tabs sit contiguously. Place
// it right after the LAST existing tab of that group; if there are no
// other members yet, leave it in place. When a tab is removed from a
// group (color === null) we don't reorder — the visual break is enough.
if ( next ) {
const idx = tabs . indexOf ( t ) ;
let insertAfter = - 1 ;
for ( let i = 0 ; i < tabs . length ; i ++ ) {
if ( i !== idx && tabs [ i ] . group === next ) insertAfter = i ;
}
if ( insertAfter !== - 1 ) {
tabs . splice ( idx , 1 ) ;
const dst = insertAfter > idx ? insertAfter : insertAfter + 1 ;
tabs . splice ( dst , 0 , t ) ;
}
}
emitTabs ( ) ;
return t . group ;
} ) ;
// Groups get a collapsed/expanded state, per-color, in-memory only (resets
// on relaunch). Flipping this doesn't touch tabs — the renderer just hides
// tabs whose group is collapsed and shows the group chip in their place.
const tabGroupCollapsed = new Set ( ) ; // colors currently collapsed
ipcMain . handle ( "tab-group-toggle" , ( _e , color ) => {
if ( ! color ) return false ;
if ( tabGroupCollapsed . has ( color ) ) tabGroupCollapsed . delete ( color ) ; else tabGroupCollapsed . add ( color ) ;
// Piggy-back on emitTabs so the chrome renderer receives the change.
emitTabs ( ) ;
return tabGroupCollapsed . has ( color ) ;
} ) ;
ipcMain . handle ( "tab-bookmark" , ( _e , id ) => {
const t = tabById ( id ) ; if ( ! t ) return false ;
const url = t . url ; const title = t . title || url ;
if ( ! url ) return false ;
if ( bookmarks . some ( ( b ) => b . url === url ) ) return true ; // already saved
bookmarks . unshift ( { url , title , addedAt : Date . now ( ) } ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return true ;
} ) ;
// Native tab context-menu popup. Anchored at the (chrome-view-relative)
// point the renderer sends. Prevents the visible "chrome view expands to
// hold a DOM menu" gap between the tabs and the tab content — the OS-owned
// popup floats above every WebContentsView and doesn't affect layout at
// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) /
// Add to Bookmarks / Mute / Close.
ipcMain . handle ( "tab-context-menu-popup" , ( e , tabId , rect ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "tab-context-menu-popup: untrusted sender" ) ;
const t = tabById ( tabId ) ;
if ( ! t ) return false ;
const colorLabels = [
{ id : "red" , label : "Red" } , { id : "orange" , label : "Orange" } ,
{ id : "yellow" , label : "Yellow" } , { id : "green" , label : "Green" } ,
{ id : "cyan" , label : "Cyan" } , { id : "blue" , label : "Blue" } ,
{ id : "purple" , label : "Purple" } ,
] ;
const groupSubmenu = [
{ label : "None" + ( t . group ? "" : " ✓" ) , click : ( ) => { t . group = null ; emitTabs ( ) ; } } ,
{ type : "separator" } ,
... colorLabels . map ( ( c ) => ( {
label : c . label + ( t . group === c . id ? " ✓" : "" ) ,
click : ( ) => {
t . group = c . id ;
// Cluster tabs of the same colour, matching the "tab-group" IPC.
const idx = tabs . indexOf ( t ) ;
let insertAfter = - 1 ;
for ( let i = 0 ; i < tabs . length ; i ++ ) {
if ( i !== idx && tabs [ i ] . group === c . id ) insertAfter = i ;
}
if ( insertAfter !== - 1 ) {
tabs . splice ( idx , 1 ) ;
const dst = insertAfter > idx ? insertAfter : insertAfter + 1 ;
tabs . splice ( dst , 0 , t ) ;
}
emitTabs ( ) ;
} ,
} ) ) ,
] ;
const bmDisabled = ! t . url ;
const template = [
{ label : "Reload" , click : ( ) => { try { t . view . webContents . reload ( ) ; } catch { } } } ,
{ label : "Duplicate" , click : ( ) => { if ( t . url ) createTab ( t . url , { after : t . id } ) ; else createTab ( null , { after : t . id } ) ; } } ,
{ label : "Group" , submenu : groupSubmenu } ,
{ label : "Add to Bookmarks" , enabled : ! bmDisabled , click : ( ) => {
if ( bmDisabled ) return ;
const url = t . url , title = t . title || url ;
if ( ! bookmarks . some ( ( b ) => b . url === url ) ) {
bookmarks . unshift ( { url , title , addedAt : Date . now ( ) } ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
}
} } ,
{ label : t . muted ? "Unmute" : "Mute" , click : ( ) => {
try { t . view . webContents . setAudioMuted ( ! t . muted ) ; t . muted = ! t . muted ; emitTabs ( ) ; } catch { }
} } ,
{ type : "separator" } ,
{ label : "Close" , click : ( ) => closeTab ( t . id ) } ,
] ;
const popup = Menu . buildFromTemplate ( template ) ;
const chromeBounds = chrome ? chrome . getBounds ( ) : { x : 0 , y : 0 } ;
const x = Math . max ( 0 , Math . round ( chromeBounds . x + ( rect ? . x || 0 ) ) ) ;
const y = Math . max ( 0 , Math . round ( chromeBounds . y + ( rect ? . y || 0 ) ) ) ;
popup . popup ( { window : win , x , y } ) ;
return true ;
} ) ;
ipcMain . handle ( "move-tab" , ( _e , id , targetId , place ) => {
const src = tabs . findIndex ( ( t ) => t . id === id ) ;
const dst = tabs . findIndex ( ( t ) => t . id === targetId ) ;
if ( src < 0 || dst < 0 || src === dst ) return ;
const [ t ] = tabs . splice ( src , 1 ) ;
const insertAt = tabs . findIndex ( ( x ) => x . id === targetId ) ;
tabs . splice ( place === "after" ? insertAt + 1 : insertAt , 0 , t ) ;
emitTabs ( ) ;
} ) ;
ipcMain . handle ( "go-home" , ( ) => loadHome ( activeId ) ) ;
// --- Add-on framework -------------------------------------------------------
// Sidebar toggle + panel switching, driven from the chrome toolbar. `panelId`
// is the namespaced string the loader emits (`<addonId>:<panelId>`) — no
// coercion, main matches it verbatim.
ipcMain . handle ( "sidebar-toggle" , ( ) => { toggleSidebar ( ) ; return sidebarVisible ; } ) ;
// Drag events stream in from sidebar-preload while the user is holding the
// grip. Delta is px per mousemove; we clamp, layout, and debounce the save.
let _sidebarSaveTimer = null ;
ipcMain . handle ( "sidebar-drag" , ( _e , deltaPx ) => {
const d = Number ( deltaPx ) || 0 ;
// Drag-to-resize exits maximize mode — the user is asking for a specific
// width. We snap out of maximize first so the delta lands on the pre-max
// width rather than on the (huge) maximized value.
if ( sidebarMaximized ) {
sidebarMaximized = false ;
sidebarW = Math . max ( SIDEBAR _W _MIN , Math . min ( SIDEBAR _W _MAX , sidebarPreMaxW || SIDEBAR _W _DEFAULT ) ) ;
try { sidebar ? . webContents . send ( "sidebar-max-change" , false ) ; } catch { }
}
const next = Math . max ( SIDEBAR _W _MIN , Math . min ( SIDEBAR _W _MAX , sidebarW + d ) ) ;
if ( next === sidebarW ) return sidebarW ;
sidebarW = next ;
layout ( ) ;
settings . sidebarWidth = sidebarW ;
clearTimeout ( _sidebarSaveTimer ) ;
_sidebarSaveTimer = setTimeout ( saveSettings , 400 ) ;
return sidebarW ;
} ) ;
ipcMain . handle ( "sidebar-open" , ( _e , panelId ) => { setSidebar ( true , panelId ) ; return sidebarVisible ; } ) ;
ipcMain . handle ( "sidebar-close" , ( ) => { setSidebar ( false ) ; return false ; } ) ;
// Panel-driven sidebar maximize: fills the window with the sidebar (tab
// area shrinks to zero-width), remembering the pre-max width so restore
// returns cleanly. Doesn't persist across sessions — a fresh launch
// always starts at the saved settings.sidebarWidth. Layout runs so tab
// views and other floating popovers reposition against the new bounds.
function setSidebarMaximized ( next ) {
const wanted = ! ! next ;
if ( wanted === sidebarMaximized ) return sidebarMaximized ;
if ( wanted ) {
sidebarPreMaxW = sidebarW ;
sidebarW = sidebarMaxWidth ( ) ;
} else {
sidebarW = Math . max ( SIDEBAR _W _MIN , Math . min ( SIDEBAR _W _MAX , sidebarPreMaxW || SIDEBAR _W _DEFAULT ) ) ;
}
sidebarMaximized = wanted ;
layout ( ) ;
try { sidebar ? . webContents . send ( "sidebar-max-change" , sidebarMaximized ) ; } catch { }
return sidebarMaximized ;
}
ipcMain . handle ( "sidebar-maximize" , ( ) => setSidebarMaximized ( true ) ) ;
ipcMain . handle ( "sidebar-restore" , ( ) => setSidebarMaximized ( false ) ) ;
ipcMain . handle ( "sidebar-toggle-max" , ( ) => setSidebarMaximized ( ! sidebarMaximized ) ) ;
ipcMain . handle ( "sidebar-is-max" , ( ) => sidebarMaximized ) ;
ipcMain . handle ( "sidebar-state" , ( ) => sidebarStatePayload ( ) ) ;
// ---- extension dock: reorder by drag, right-click menu --------------------
// Keys match the chrome's: "p:<panelId>" for a sidebar panel button,
// "m:<addonId>" for a toolbar-menu button. The full key list in the order
// the chrome would draw it (prefs applied) is computed here so a move is
// resolved against what the user actually sees.
function dockKeys ( ) {
const keys = [ ] ;
if ( addonHost ) {
for ( const p of addonHost . getSidebarPanels ( ) ) if ( ! p . plugin ) keys . push ( "p:" + p . panelId ) ;
for ( const m of addonHost . getToolbarMenus ( ) ) if ( ! m . plugin ) keys . push ( "m:" + m . addonId ) ;
}
const order = Array . isArray ( settings . dockOrder ) ? settings . dockOrder : [ ] ;
const rank = ( k ) => { const i = order . indexOf ( k ) ; return i < 0 ? order . length : i ; } ;
return keys . map ( ( k , i ) => ( { k , i } ) ) . sort ( ( a , b ) => rank ( a . k ) - rank ( b . k ) || a . i - b . i ) . map ( ( x ) => x . k ) ;
}
function dockLabel ( key ) {
if ( ! addonHost ) return key ;
if ( key . startsWith ( "p:" ) ) { const p = addonHost . getSidebarPanels ( ) . find ( ( x ) => "p:" + x . panelId === key ) ; return p ? ( p . addonName || p . title || key ) : key . slice ( 2 ) ; }
const m = addonHost . getToolbarMenus ( ) . find ( ( x ) => "m:" + x . addonId === key ) ;
return m ? ( m . title || m . addonId ) : key . slice ( 2 ) ;
}
const dockAddonId = ( key ) => key . startsWith ( "p:" ) ? key . slice ( 2 ) . split ( ":" ) [ 0 ] : key . slice ( 2 ) ;
function dockMove ( key , targetKey , place ) {
const keys = dockKeys ( ) ;
if ( ! keys . includes ( key ) || ! keys . includes ( targetKey ) || key === targetKey ) return false ;
const rest = keys . filter ( ( k ) => k !== key ) ;
const at = rest . indexOf ( targetKey ) + ( place === "after" ? 1 : 0 ) ;
rest . splice ( at , 0 , key ) ;
settings . dockOrder = rest ;
saveSettings ( ) ;
emitSidebarState ( ) ;
return true ;
}
function dockSetHidden ( key , hide ) {
const hidden = new Set ( Array . isArray ( settings . dockHidden ) ? settings . dockHidden : [ ] ) ;
if ( hide ) hidden . add ( key ) ; else hidden . delete ( key ) ;
settings . dockHidden = [ ... hidden ] ;
saveSettings ( ) ;
emitSidebarState ( ) ;
}
ipcMain . handle ( "dock-move" , ( e , key , targetKey , place ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "dock-move: untrusted sender" ) ;
if ( typeof key !== "string" || typeof targetKey !== "string" ) return false ;
return dockMove ( key , targetKey , place === "after" ? "after" : "before" ) ;
} ) ;
ipcMain . handle ( "dock-menu" , ( e , key , rect ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "dock-menu: untrusted sender" ) ;
const keys = dockKeys ( ) ;
const visible = keys . filter ( ( k ) => ! ( settings . dockHidden || [ ] ) . includes ( k ) ) ;
const hiddenKeys = ( settings . dockHidden || [ ] ) . filter ( ( k ) => keys . includes ( k ) ) ;
const template = [ ] ;
if ( typeof key === "string" && keys . includes ( key ) ) {
const name = dockLabel ( key ) ;
const pos = visible . indexOf ( key ) ;
if ( key . startsWith ( "p:" ) ) {
const pid = key . slice ( 2 ) ;
const open = sidebarVisible && sidebarActivePanelId === pid ;
template . push ( { label : open ? ` Close ${ name } ` : ` Open ${ name } ` , click : ( ) => setSidebar ( ! open , pid ) } ) ;
}
template . push ( { type : "separator" } ) ;
template . push ( { label : "Move left" , enabled : pos > 0 , click : ( ) => dockMove ( key , visible [ pos - 1 ] , "before" ) } ) ;
template . push ( { label : "Move right" , enabled : pos >= 0 && pos < visible . length - 1 , click : ( ) => dockMove ( key , visible [ pos + 1 ] , "after" ) } ) ;
template . push ( { type : "separator" } ) ;
template . push ( { label : "Hide from toolbar" , click : ( ) => dockSetHidden ( key , true ) } ) ;
template . push ( { label : ` Turn off ${ name } ` , click : ( ) => setAddonEnabled ( dockAddonId ( key ) , false ) } ) ;
template . push ( { type : "separator" } ) ;
}
if ( hiddenKeys . length ) {
template . push ( { label : "Show hidden" , submenu : hiddenKeys . map ( ( k ) => ( { label : dockLabel ( k ) , click : ( ) => dockSetHidden ( k , false ) } ) ) } ) ;
}
template . push ( { label : "Manage extensions…" , click : ( ) => openSettingsTab ( "addons" ) } ) ;
const popup = Menu . buildFromTemplate ( template ) ;
const chromeBounds = chrome ? chrome . getBounds ( ) : { x : 0 , y : 0 } ;
popup . popup ( { window : win , x : Math . max ( 0 , Math . round ( chromeBounds . x + ( rect ? . x || 0 ) ) ) , y : Math . max ( 0 , Math . round ( chromeBounds . y + ( rect ? . y || 0 ) ) ) } ) ;
return true ;
} ) ;
// Toolbar-menu click: chrome sends the {addonId, itemId} of the item the
// user picked. Route to the add-on's registered "menu-select" handler. We
// trust chrome as the sender (same convention as sidebar-toggle et al) —
// it's the only WebContents we ever load chrome.html into.
ipcMain . handle ( "addon-menu-select" , async ( e , addonId , itemId ) => {
if ( ! addonHost ) throw new Error ( "addon host not ready" ) ;
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "addon-menu-select: untrusted sender" ) ;
const id = String ( addonId || "" ) ;
const iid = String ( itemId || "" ) ;
if ( ! id || ! iid ) throw new Error ( "addon-menu-select: addonId and itemId required" ) ;
// Confirm the menu item was actually declared by this add-on — a rogue
// renderer message can't invoke a handler with an item id the manifest
// never listed.
const menu = addonHost . getToolbarMenus ( ) . find ( ( m ) => m . addonId === id ) ;
if ( ! menu ) throw new Error ( ` addon-menu-select: no toolbar menu for " ${ id } " ` ) ;
if ( ! menu . items . find ( ( it ) => it . id === iid ) ) throw new Error ( ` addon-menu-select: item " ${ iid } " not declared by " ${ id } " ` ) ;
return addonHost . dispatch ( id , "menu-select" , { id : iid } , { from : "toolbar-menu" } ) ;
} ) ;
// Toolbar-menu popup — render as a NATIVE OS menu anchored at the button.
// A renderer-DOM popover in chrome.html gets clipped by the chrome view's
// own bounds (height = CHROME_H) and then hidden behind the tab view below
// it. Menu.popup uses an OS window, so it can extend anywhere.
ipcMain . handle ( "toolbar-menu-popup" , async ( e , addonId , rect ) => {
if ( ! addonHost ) throw new Error ( "addon host not ready" ) ;
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "toolbar-menu-popup: untrusted sender" ) ;
const id = String ( addonId || "" ) ;
const menu = addonHost . getToolbarMenus ( ) . find ( ( m ) => m . addonId === id ) ;
if ( ! menu ) throw new Error ( ` toolbar-menu-popup: no menu for " ${ id } " ` ) ;
// Capture the clicked item id here; dispatch runs from the popup's
// `callback` below, AFTER the menu is torn down. Firing synchronously
// in the click handler catches the parent window still non-foreground
// (the OS menu popup is on top), which leaves Chromium's occlusion
// tracker marking the tab view as hidden — WebContents.capturePage()
// then snapshots a blank frame at the correct dimensions (not a 0x0
// that our retry could catch). Deferring until after callback lets
// focus return to the parent so the compositor is live at capture time.
let picked = null ;
const template = menu . items . map ( ( it ) => ( {
label : ( it . icon ? String ( it . icon ) + " " : "" ) + String ( it . label || it . id ) ,
click : ( ) => { picked = it . id ; } ,
} ) ) ;
const popup = Menu . buildFromTemplate ( template ) ;
const chromeBounds = chrome ? chrome . getBounds ( ) : { x : 0 , y : 0 } ;
const x = Math . max ( 0 , Math . round ( chromeBounds . x + ( rect ? . x || 0 ) ) ) ;
const y = Math . max ( 0 , Math . round ( chromeBounds . y + ( rect ? . y || 0 ) ) ) ;
popup . popup ( { window : win , x , y , callback : ( ) => {
try { chrome ? . webContents . send ( "toolbar-menu-closed" ) ; } catch { }
if ( ! picked ) return ; // user hit Escape or clicked outside
// Explicitly return foreground to the parent window; on some Windows
// configurations Electron's popup teardown alone leaves the app in a
// "not-quite-foreground" state until the next OS message pump tick.
try { win ? . focus ( ) ; } catch { }
// Settle before the handler runs. Windows takes several frames to
// restore foreground and un-throttle the compositor; the previous
// 120 ms was too short on slower / higher-latency setups and led to
// blank frames. 250 ms is what the "full page" mode already uses.
// captureTab itself no longer relies on capturePage anyway (it goes
// through CDP Page.captureScreenshot, which forces a fresh composite),
// but the delay still helps addons that do their own DOM work in the
// click handler before capture.
const iid = picked ;
setTimeout ( ( ) => {
addonHost . dispatch ( id , "menu-select" , { id : iid } , { from : "toolbar-menu" } )
. catch ( ( err ) => console . warn ( ` [addons] menu-select ${ id } . ${ iid } failed: ` , err ? . message || err ) ) ;
} , 250 ) ;
} } ) ;
return true ;
} ) ;
// Close the tab a full-tab add-on page lives in. Sender identifies the
// webContents; we match it against our tab list and close that tab only.
// A page hosted elsewhere (or a spoofed sender not in the tab set) gets
// nothing.
ipcMain . handle ( "addon-tab-close" , ( e ) => {
const senderId = e . sender . id ;
const tab = tabs . find ( ( t ) => t . view ? . webContents ? . id === senderId ) ;
if ( ! tab ) return false ;
closeTab ( tab . id ) ;
return true ;
} ) ;
// The counterpart: an add-on page asking for its own tab to be brought to
// the front. Needed when the add-on hands an already-open page something new
// to show and the click that caused it happened somewhere else — the sidebar,
// say — so the result would otherwise land in a tab nobody is looking at.
// Same confinement as the close handler: derived from the sender, so a page
// can only front the tab it is itself in.
ipcMain . handle ( "addon-tab-focus" , ( e ) => {
const senderId = e . sender . id ;
const tab = tabs . find ( ( t ) => t . view ? . webContents ? . id === senderId ) ;
if ( ! tab ) return false ;
setActive ( tab . id ) ;
return true ;
} ) ;
// Read-side of Settings' Add-ons tab.
ipcMain . handle ( "addons-list" , ( ) => {
if ( ! addonHost ) return { installed : [ ] , sidebarPanels : [ ] } ;
const snap = addonHost . snapshot ( ) ;
// Mark bundled add-ons so the extensions UI can render them differently
// (Aegis + friends look built-in rather than removable extensions). A
// sibling folder in bundled-addons/ with the same manifest id is proof
// the addon ships with Theseus; seedBundledAddons keeps them in sync.
let bundledIds = new Set ( ) ;
try {
for ( const e of fs . readdirSync ( bundledAddonsDir ( ) , { withFileTypes : true } ) ) {
if ( ! e . isDirectory ( ) ) continue ;
try {
const m = JSON . parse ( fs . readFileSync ( path . join ( bundledAddonsDir ( ) , e . name , "addon.json" ) , "utf8" ) ) ;
if ( m && m . id ) bundledIds . add ( String ( m . id ) ) ;
} catch { }
}
} catch { }
snap . installed = snap . installed . map ( ( a ) => ( { ... a , bundled : a . id ? bundledIds . has ( a . id ) : false } ) ) ;
return snap ;
} ) ;
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
// add-ons activate immediately and newly-disabled ones drop out — no
// restart required.
function setAddonEnabled ( id , enabled ) {
if ( ! id || typeof id !== "string" ) return false ;
const disabled = new Set ( Array . isArray ( settings . disabledAddons ) ? settings . disabledAddons : [ ] ) ;
if ( enabled ) disabled . delete ( id ) ; else disabled . add ( id ) ;
settings . disabledAddons = [ ... disabled ] ;
saveSettings ( ) ;
// Rebuild the host so state matches settings.
if ( addonHost ) addonHost . discoverAndActivate ( ) ;
// Sidebar may need to close if its current panel came from an add-on we
// just disabled.
const panels = addonHost ? addonHost . getSidebarPanels ( ) : [ ] ;
if ( sidebarVisible && sidebarActivePanelId && ! panels . find ( ( p ) => p . panelId === sidebarActivePanelId ) ) {
sidebarActivePanelId = null ;
setSidebar ( false ) ;
}
emitSidebarState ( ) ;
return true ;
}
ipcMain . handle ( "addons-set-enabled" , ( _e , id , enabled ) => setAddonEnabled ( id , enabled ) ) ;
// Reveal an add-on's folder in the OS file manager — the primary way users
// edit / uninstall add-ons.
ipcMain . handle ( "addons-reveal" , ( _e , folder ) => {
if ( typeof folder !== "string" || ! folder ) return false ;
const norm = path . normalize ( folder ) ;
const base = addonsUserDir ( ) ;
if ( ! norm . toLowerCase ( ) . startsWith ( base . toLowerCase ( ) ) ) return false ; // don't leak arbitrary paths
try { shell . showItemInFolder ( norm ) ; return true ; } catch { return false ; }
} ) ;
ipcMain . handle ( "addons-open-dir" , ( ) => {
try { shell . openPath ( addonsUserDir ( ) ) ; return true ; } catch { return false ; }
} ) ;
// Remove an installed (non-bundled) extension: delete its folder under the
// extensions directory and drop its prefs. Bundled add-ons are refused — a
// deleted folder would just be reseeded on the next launch, so "turn off"
// is the honest control for those. The per-extension data store is kept.
ipcMain . handle ( "addons-remove" , ( _e , id ) => {
if ( ! addonHost || typeof id !== "string" || ! id ) return { ok : false , error : "bad id" } ;
const a = addonHost . snapshot ( ) . installed . find ( ( x ) => x . id === id ) ;
if ( ! a || ! a . folder ) return { ok : false , error : "not installed" } ;
const norm = path . normalize ( a . folder ) , base = addonsUserDir ( ) ;
if ( ! norm . toLowerCase ( ) . startsWith ( base . toLowerCase ( ) + path . sep ) ) return { ok : false , error : "not in the extensions directory" } ;
try {
const bundledManifest = path . join ( bundledAddonsDir ( ) , path . basename ( norm ) , "addon.json" ) ;
if ( fs . existsSync ( bundledManifest ) ) return { ok : false , error : "built into Theseus — turn it off instead" } ;
} catch { }
try { fs . rmSync ( norm , { recursive : true , force : true } ) ; }
catch ( e ) { return { ok : false , error : e ? . message || String ( e ) } ; }
settings . disabledAddons = ( settings . disabledAddons || [ ] ) . filter ( ( x ) => x !== id ) ;
settings . dockOrder = ( settings . dockOrder || [ ] ) . filter ( ( k ) => dockAddonId ( k ) !== id ) ;
settings . dockHidden = ( settings . dockHidden || [ ] ) . filter ( ( k ) => dockAddonId ( k ) !== id ) ;
saveSettings ( ) ;
addonHost . discoverAndActivate ( ) ;
if ( sidebarVisible && sidebarActivePanelId && sidebarActivePanelId . startsWith ( id + ":" ) ) { sidebarActivePanelId = null ; setSidebar ( false ) ; }
emitSidebarState ( ) ;
console . log ( ` [addons] removed ${ id } ( ${ norm } ) ` ) ;
return { ok : true } ;
} ) ;
// Manual "Check for updates" from Settings > Extensions. Runs the same
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
// staged dir so the UI can render "Update to <ver> — restart to apply".
// ---- community extensions (theseus.x/extensions) ----------------------------
// Anyone who owns a BNS name can publish an extension through the gateway
// (PUT /api/ext/<name>/<id>/<version>); the catalog and every package live
// on Sia and are served through the public relay. Trust: each channel entry
// carries the publisher's BCH signature over id|version|sha256|publisher.
// Before installing or updating, Theseus recovers the signer and compares
// it with the name's current NFT owner from ITS OWN chain index — so neither
// the relay nor a tampered catalog can slip in code under a trusted name.
const COMMUNITY _CATALOG _URL = "https://navigate.st/api/ext/catalog" ;
let publisherSigLib = null ;
async function getPublisherSig ( ) {
if ( ! publisherSigLib ) publisherSigLib = await import ( ` file:// ${ path . join ( _ _dirname , "lib" , "publisher-sig.mjs" ) . replace ( /\\/g , "/" ) } ` ) ;
return publisherSigLib ;
}
async function verifyPublisherEntry ( entry ) {
try {
const name = String ( entry ? . publisher || "" ) . toLowerCase ( ) ;
if ( ! name ) return false ;
const owner = ( await resolveHost ( name ) ) ? . owner ;
if ( ! owner ) { console . warn ( ` [addons] publisher ${ name } : owner unknown to the local index ` ) ; return false ; }
const lib = await getPublisherSig ( ) ;
const ok = lib . verifyPublisherEntry ( entry , owner ) ;
if ( ! ok ) console . warn ( ` [addons] publisher signature for ${ entry . id } @ ${ entry . version } does not match ${ name } 's owner ` ) ;
return ok ;
} catch ( e ) { console . warn ( "[addons] publisher verify failed:" , e ? . message ) ; return false ; }
}
async function fetchCommunityCatalog ( ) {
const r = await fetch ( COMMUNITY _CATALOG _URL , { signal : AbortSignal . timeout ( 15000 ) , cache : "no-store" } ) ;
if ( ! r . ok ) throw new Error ( ` catalog HTTP ${ r . status } ` ) ;
const j = await r . json ( ) ;
return Array . isArray ( j ? . extensions ) ? j . extensions : [ ] ;
}
ipcMain . handle ( "addons-community-catalog" , async ( ) => {
try {
const list = await fetchCommunityCatalog ( ) ;
const installed = addonHost ? addonHost . snapshot ( ) . installed : [ ] ;
return { ok : true , extensions : list . map ( ( e ) => {
const cur = installed . find ( ( a ) => a . id === e . id ) ;
return { ... e , installedVersion : cur ? cur . version : null , canUpdate : ! ! ( cur && addonUpdater . cmpVer ( e . latest , cur . version ) > 0 ) } ;
} ) } ;
} catch ( e ) { return { ok : false , error : e ? . message || String ( e ) , extensions : [ ] } ; }
} ) ;
const COMMUNITY _ID _RE = /^[a-z0-9][a-z0-9._-]{1,63}$/ ;
// Install a catalog extension by id: resolve its channel from the catalog,
// verify the publisher signature against the name's owner, place it under
// extensions/<id> and activate it. Shared by Settings and the page bridge.
async function installCommunityById ( id ) {
if ( typeof id !== "string" || ! COMMUNITY _ID _RE . test ( id ) ) return { ok : false , error : "bad id" } ;
try {
const card = ( await fetchCommunityCatalog ( ) ) . find ( ( e ) => e . id === id ) ;
if ( ! card ) return { ok : false , error : "not in the catalog" } ;
const r = await addonUpdater . installCommunity ( {
id , updatesUrl : card . updatesUrl ,
addonsDir : addonsUserDir ( ) , backupsDir : addonsBackupDir ( ) ,
verifyPublisher : verifyPublisherEntry ,
log : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) ;
if ( r . ok && addonHost ) { addonHost . discoverAndActivate ( ) ; emitSidebarState ( ) ; }
return r ;
} catch ( e ) { return { ok : false , error : e ? . message || String ( e ) } ; }
}
ipcMain . handle ( "addons-install-community" , ( _e , id ) => installCommunityById ( id ) ) ;
// One-click install from a web page (theseus.x/extensions, or any page):
// either `window.bcnr.installExtension(id)` or a link to
// theseus://extensions/install/<id>. The page only names a catalog id — the
// package, its hash and the publisher signature still come from the catalog
// and are verified exactly as a Settings install is. The consent lives in a
// native dialog the page cannot draw over or click, one at a time.
const INSTALL _LINK _RE = /^theseus:\/\/extensions\/install\/([a-z0-9][a-z0-9._-]{1,63})\/?$/i ;
function installLinkId ( url ) {
const m = INSTALL _LINK _RE . exec ( String ( url || "" ) . trim ( ) ) ;
return m ? m [ 1 ] . toLowerCase ( ) : null ;
}
let installPromptOpen = false ;
async function installExtensionWithConsent ( id , requester ) {
if ( typeof id !== "string" || ! COMMUNITY _ID _RE . test ( id ) ) return { ok : false , error : "bad id" } ;
if ( installPromptOpen ) return { ok : false , error : "another install prompt is open" } ;
installPromptOpen = true ;
try {
let card = null ;
try { card = ( await fetchCommunityCatalog ( ) ) . find ( ( e ) => e . id === id ) || null ; } catch { }
const parent = win && ! win . isDestroyed ( ) ? win : undefined ;
if ( ! card ) {
await dialog . showMessageBox ( parent , { type : "warning" , title : "Extension not found" , message : ` " ${ id } " is not in the community catalog. ` , buttons : [ "OK" ] } ) ;
return { ok : false , error : "not in the catalog" } ;
}
const installed = addonHost ? addonHost . snapshot ( ) . installed . find ( ( a ) => a . id === id ) : null ;
if ( installed && addonUpdater . cmpVer ( card . latest , installed . version ) <= 0 ) {
const { response : r0 } = await dialog . showMessageBox ( parent , {
type : "info" , title : "Already installed" ,
message : ` ${ card . name || id } ${ installed . version } is already installed. ` ,
detail : "Newer signed versions arrive through the regular update check. Manage it under Settings › Extensions." ,
buttons : [ "OK" , "Open Settings" ] , defaultId : 0 , cancelId : 0 , noLink : true ,
} ) ;
if ( r0 === 1 ) openSettingsTab ( "addons" ) ;
return { ok : true , version : installed . version , publisher : installed . publisher || card . publisher , alreadyInstalled : true } ;
}
const from = requester ? ` Requested by ${ requester } . \n \n ` : "" ;
const { response } = await dialog . showMessageBox ( parent , {
type : "question" , title : "Install extension" ,
message : installed
? ` Update ${ card . name || id } ${ installed . version } → ${ card . latest } ? `
: ` Install ${ card . name || id } ${ card . latest } ? ` ,
detail : ` ${ from } Published by ${ card . publisher } . Theseus verifies the package signature against ${ card . publisher } 's current owner in its own chain index before anything is written. \n \n `
+ ` A community extension runs with the same access as any add-on — treat it like a program from that publisher. ` ,
buttons : [ "Install" , "Cancel" ] , defaultId : 0 , cancelId : 1 , noLink : true ,
} ) ;
if ( response !== 0 ) return { ok : false , error : "cancelled" } ;
const r = await installCommunityById ( id ) ;
if ( r . ok ) {
const { response : after } = await dialog . showMessageBox ( parent , {
type : "info" , title : "Extension installed" ,
message : ` ${ card . name || id } ${ r . version } is installed and active. ` ,
detail : ` Signed by ${ r . publisher || card . publisher } . Manage it under Settings › Extensions. ` ,
buttons : [ "OK" , "Open Settings" ] , defaultId : 0 , cancelId : 0 , noLink : true ,
} ) ;
if ( after === 1 ) openSettingsTab ( "addons" ) ;
} else {
await dialog . showMessageBox ( parent , { type : "error" , title : "Install failed" , message : ` ${ card . name || id } was not installed. ` , detail : r . error || "unknown error" , buttons : [ "OK" ] } ) ;
}
return r ;
} finally { installPromptOpen = false ; }
}
// ---- web apps (PWA install) ----
function installWebAppFromTab ( tab ) {
if ( ! tab || ! tab . webapp ) return Promise . resolve ( { ok : false , error : "not installable" } ) ;
return webapps . install ( tab . webapp , { wc : tab . view . webContents , favicon : tab . favicon || null } ) . then ( ( r ) => { emitTabs ( ) ; return r ; } ) ;
}
// Address-bar chip: not installed → the install dialog; installed → a menu
// to open the app window or remove the app.
ipcMain . handle ( "webapp-chip" , async ( e , rect ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "webapp-chip: untrusted sender" ) ;
const t = activeTab ( ) ; if ( ! t || ! t . webapp ) return false ;
const inst = webapps . find ( t . webapp . key ) ;
if ( ! inst ) { await installWebAppFromTab ( t ) ; return true ; }
const menu = Menu . buildFromTemplate ( [
{ label : ` Open ${ inst . name } in its window ` , click : ( ) => webapps . open ( inst ) } ,
{ type : "separator" } ,
{ label : ` Remove ${ inst . name } from Theseus… ` , click : ( ) => webapps . uninstall ( inst . key , true ) . then ( ( ) => emitTabs ( ) ) } ,
] ) ;
const pos = rect && Number . isFinite ( rect . x ) ? { x : Math . round ( rect . x ) , y : Math . round ( ( rect . y || 0 ) + ( rect . h || 0 ) ) } : { } ;
menu . popup ( { window : win , ... pos , callback : ( ) => { try { chrome ? . webContents . send ( "toolbar-menu-closed" ) ; } catch { } } } ) ;
return true ;
} ) ;
// A page's own install chip calls beforeinstallprompt.prompt(); the session
// preload relays it here. Only the tab that was probed installable, and only
// while it still shows that origin, gets the dialog. Resolves "accepted" /
// "dismissed" like Chrome's userChoice.
ipcMain . handle ( "webapp-prompt" , async ( e ) => {
const tab = tabs . find ( ( t ) => t . view ? . webContents === e . sender ) ;
if ( ! tab || ! tab . webapp ) return "dismissed" ;
let origin = "" ; try { origin = new URL ( e . sender . getURL ( ) . replace ( /^bns:\/\// , "https://" ) ) . origin ; } catch { }
if ( ! origin || origin !== tab . webapp . origin ) return "dismissed" ;
const r = await installWebAppFromTab ( tab ) ;
return r && r . ok ? "accepted" : "dismissed" ;
} ) ;
ipcMain . handle ( "webapps-list" , ( ) => webapps . list ( ) . map ( ( a ) => ( { key : a . key , name : a . name , host : a . host , startUrl : a . startUrl , installedAt : a . installedAt } ) ) ) ;
ipcMain . handle ( "webapps-open" , ( _e , key ) => { const a = webapps . find ( String ( key || "" ) ) ; if ( a ) webapps . open ( a ) ; return ! ! a ; } ) ;
ipcMain . handle ( "webapps-remove" , ( _e , key ) => webapps . uninstall ( String ( key || "" ) , true ) . then ( ( ok ) => { emitTabs ( ) ; return ok ; } ) ) ;
ipcMain . handle ( "bcnr:installExtension" , ( e , id ) => {
let requester = null ;
try { requester = new URL ( e . sender . getURL ( ) ) . host || null ; } catch { }
return installExtensionWithConsent ( id , requester ) ;
} ) ;
// Background / manual add-on update check. Whatever gets staged is pushed to
// the chrome ("addon-updates") so the toolbar can offer "Restart to apply".
async function pollAddonUpdates ( reason ) {
const stagedDir = addonsStagedDir ( ) ;
let report = [ ] , skipped = null ;
try {
const result = await addonUpdater . checkAndStageUpdates ( {
addonsDir : addonsUserDir ( ) ,
stagedDir ,
pubkeysHex : ADDON _UPDATE _PUBKEYS ,
verifyPublisher : verifyPublisherEntry ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) ;
report = result ? . report || [ ] ;
skipped = result ? . skipped || null ;
} catch ( e ) { console . warn ( ` [addons] check-updates ( ${ reason } ) failed: ` , e ? . message || e ) ; }
const staged = listStagedAddons ( stagedDir ) ;
notifyStagedAddons ( staged ) ;
return { report , skipped , staged } ;
}
// Plug-ins (manifest category "plugin", e.g. Aegis) carry their own update
// UI inside their panel, so the toolbar chip only announces extensions.
function isPluginAddon ( id ) {
try { return ( addonHost ? . snapshot ( ) . installed || [ ] ) . some ( ( a ) => a . id === id && a . category === "plugin" ) ; } catch { return false ; }
}
function notifyStagedAddons ( staged ) {
const list = ( staged || listStagedAddons ( addonsStagedDir ( ) ) ) . filter ( ( s ) => ! isPluginAddon ( s . id ) ) ;
try { chrome ? . webContents . send ( "addon-updates" , { staged : list . map ( ( s ) => ( { id : s . id , name : s . name , version : s . version } ) ) } ) ; } catch { }
}
ipcMain . handle ( "addons-check-updates" , ( ) => pollAddonUpdates ( "manual" ) ) ;
// Apply staged extension updates now (no restart): promote the staged
// folder(s) over the installed copy and rebuild the add-on host, which
// re-requires each add-on's main from disk. Plug-ins are left for the next
// launch — a wallet mid-session is not something to hot-swap. `id` limits
// the apply to one extension (Settings row button); omitted = every
// staged extension (toolbar chip).
ipcMain . handle ( "addons-apply-staged" , ( _e , id ) => {
if ( ! addonHost ) return { ok : false , error : "add-ons not ready" , applied : [ ] } ;
const want = typeof id === "string" && id ? id : null ;
const applied = addonUpdater . promoteStagedUpdates ( {
addonsDir : addonsUserDir ( ) ,
backupsDir : addonsBackupDir ( ) ,
stagedDir : addonsStagedDir ( ) ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
only : ( m ) => ( want ? m . id === want : true ) && ! isPluginAddon ( m . id ) ,
} ) ;
if ( applied . length ) { addonHost . discoverAndActivate ( ) ; emitSidebarState ( ) ; }
notifyStagedAddons ( ) ;
return { ok : true , applied , pending : listStagedAddons ( addonsStagedDir ( ) ) . map ( ( s ) => ( { id : s . id , version : s . version } ) ) } ;
} ) ;
ipcMain . handle ( "addons-list-staged" , ( ) => listStagedAddons ( addonsStagedDir ( ) ) ) ;
function listStagedAddons ( stagedDir ) {
const out = [ ] ;
let entries = [ ] ;
try { entries = fs . readdirSync ( stagedDir , { withFileTypes : true } ) ; } catch { return out ; }
for ( const de of entries ) {
if ( ! de . isDirectory ( ) ) continue ;
try {
const m = JSON . parse ( fs . readFileSync ( path . join ( stagedDir , de . name , "addon.json" ) , "utf8" ) ) ;
if ( m ? . id && m ? . version ) out . push ( { id : m . id , name : m . name || m . id , version : m . version , folder : path . join ( stagedDir , de . name ) } ) ;
} catch { }
}
return out ;
}
ipcMain . handle ( "addons-reload" , ( ) => {
if ( ! addonHost ) return false ;
addonHost . discoverAndActivate ( ) ;
emitSidebarState ( ) ;
return true ;
} ) ;
// --- Add-on messaging + capabilities -----------------------------------------
// Panel → add-on: the sidebar panel's file:// URL tells us which add-on it
// belongs to (same gate as storage). The add-on's onMessage handler runs in
// main and its return value is the response.
ipcMain . handle ( "addon-msg" , async ( e , msg , payload ) => {
const id = addonIdForSender ( e . sender ) ;
if ( ! id || ! addonHost ) throw new Error ( "not an add-on panel" ) ;
return addonHost . dispatch ( id , String ( msg ) , payload , { from : "panel" } ) ;
} ) ;
// Page → add-on: only a real tab whose committed URL matches the add-on's
// page-inject origins may talk to it, and only through messages the add-on
// registered. Origin is "<scheme>://<host>" (bns:// shown as https://).
function tabForSender ( sender ) { return tabs . find ( ( t ) => t . view . webContents === sender ) || null ; }
function pageOriginOf ( url ) {
try {
const u = new URL ( String ( url ) . replace ( /^bns:\/\//i , "https://" ) ) ;
return u . protocol && u . host ? ` ${ u . protocol } // ${ u . host } ` : null ;
} catch { return null ; }
}
// wiz:// — WizardConnect pairing links.
//
// WizardConnect is a cross-device protocol: a dapp renders its pairing URI
// as a QR for a phone wallet to scan. On the same device that means copying
// a wiz:// string out of one tab and pasting it into the wallet by hand.
// When a dapp renders the URI as a link instead, we can route the click
// straight to the wallet — no copying, and no change required on the dapp
// side beyond making it an anchor, so this works for third-party dapps that
// will never adopt a Silent Mode API.
//
// The wallet still shows its own approval before anything is paired; all
// this does is carry the URI across, tagged with the origin that offered it
// so the approval can name the real site.
function routeWizUri ( uri , origin , tabId ) {
if ( ! addonHost ) return false ;
const u = String ( uri || "" ) ;
if ( ! /^wiz:/i . test ( u ) || u . length > 4096 ) return false ;
if ( ! addonHost . hasHandler ( "aegis" , "wcConnectFromPage" ) ) return false ;
addonHost
. dispatch ( "aegis" , "wcConnectFromPage" , { uri : u } , { from : "page" , origin : origin || "unknown site" , tabId } )
. catch ( ( err ) => console . log ( "[wiz] pairing failed:" , err ? . message || err ) ) ;
return true ;
}
ipcMain . handle ( "addon-page-msg" , async ( e , addonId , msg , payload ) => {
const tab = tabForSender ( e . sender ) ;
if ( ! tab || ! addonHost ) throw new Error ( "not a page" ) ;
const url = e . sender . getURL ( ) ;
const id = String ( addonId || "" ) ;
if ( ! addonHost . pageAllowed ( id , url ) ) throw new Error ( ` add-on " ${ id } " is not injected on this page ` ) ;
const origin = pageOriginOf ( url ) ;
if ( ! origin ) throw new Error ( "opaque origin" ) ;
return addonHost . dispatch ( id , String ( msg ) , payload , { from : "page" , origin , tabId : tab . id } ) ;
} ) ;
// Synchronous — the inject preload has to know what to run before the page's
// own scripts start. Decided against the sender's committed URL; the href the
// preload reports is only logged when it disagrees.
// Assigning event.returnValue sends the reply at once, so it is set exactly
// once at the end.
function injectionsForSender ( e , href ) {
const tab = tabForSender ( e . sender ) ;
if ( ! tab || ! addonHost ) return [ ] ;
const url = e . sender . getURL ( ) ;
if ( ! url || url . startsWith ( "file:" ) ) return [ ] ;
if ( href && href !== url ) console . log ( ` [addons] inject: preload href ${ href } ≠ committed ${ url } ` ) ;
const origin = pageOriginOf ( url ) ;
const list = addonHost . injectionsFor ( url ) . map ( ( x ) => ( { ... x , origin } ) ) ;
if ( list . length ) console . log ( ` [addons] inject ${ list . map ( ( x ) => x . id ) . join ( "," ) } into ${ origin } ` ) ;
return list ;
}
ipcMain . on ( "addon-inject-scripts" , ( e , href ) => { e . returnValue = injectionsForSender ( e , href ) ; } ) ;
// Approval overlay. One request at a time; later callers queue behind the
// visible one so two dapps can't race each other for the same click.
feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
2026-09-27 17:53:21 +02:00
// ---- page dialogs: alert / confirm / prompt ----
// Chromium's stock JavaScript dialogs are bare OS message boxes titled with
// the package name ("theseus-navigator"). The session preload reroutes the
// page's calls here, synchronously (sendSync), and the answer comes from a
// Theseus-drawn sheet under the toolbar that names who is asking — the
// site's host, or the add-on's name for add-on pages — like Chrome's
// "example.com says". Windows without the chrome (app windows, plain
// windows) fall back to a native box with a proper title.
let jsDialogPop = null ;
const jsDialogQueue = [ ] ;
let jsDialogCurrent = null ; // { e, req, tabId }
let jsDialogSeq = 0 ;
function jsDialogWho ( sender , tab ) {
let u = "" ; try { u = sender . getURL ( ) || "" ; } catch { }
// Add-on pages — a full-tab page, a sidebar panel, a background page — all
// load from <profile>/extensions/<id>/…; the id is the first segment after
// that directory. (A tab's addonId flag would also stay set after the tab
// navigated elsewhere, so the URL is the reliable source.)
if ( /^file:/i . test ( u ) ) {
try {
const base = url . pathToFileURL ( addonsUserDir ( ) ) . href . replace ( /\/?$/ , "/" ) ;
if ( u . startsWith ( base ) ) {
const id = decodeURIComponent ( u . slice ( base . length ) . split ( /[/?#]/ ) [ 0 ] ) ;
const a = addonHost && addonHost . getInstalled ( ) . find ( ( x ) => x . manifest && x . manifest . id === id ) ;
return { label : a && a . manifest . name ? String ( a . manifest . name ) : id , kind : "addon" } ;
}
} catch { }
}
if ( tab && ( tab . settings || ( tab . prov && tab . prov . kind === "home" ) ) ) return { label : "Theseus" , kind : "app" } ;
// file: outside a tab is one of Theseus's own views; inside a tab it is a local file the user opened.
if ( /^file:/i . test ( u ) ) return tab ? { label : "This page" , kind : "site" } : { label : "Theseus" , kind : "app" } ;
try { const p = new URL ( u . replace ( /^bns:\/\//i , "https://" ) ) ; if ( p . host ) return { label : p . host , kind : "site" } ; } catch { }
return { label : "This page" , kind : "site" } ;
}
function jsDialogReply ( item , ok , value ) {
const { kind } = item . req ;
const out = kind === "confirm" ? ( ok ? "1" : "0" ) : kind === "prompt" ? ( ok ? String ( value ? ? "" ) : null ) : "" ;
try { item . e . returnValue = { handled : true , value : out } ; } catch { }
}
function pumpJsDialog ( ) {
if ( jsDialogCurrent || ! jsDialogQueue . length ) return ;
if ( ! jsDialogPop || ! winAlive ( ) ) { for ( const it of jsDialogQueue . splice ( 0 ) ) jsDialogReply ( it , false , null ) ; return ; }
const next = jsDialogQueue . shift ( ) ;
if ( next . tabId != null && ! tabById ( next . tabId ) ) { jsDialogReply ( next , false , null ) ; return pumpJsDialog ( ) ; }
jsDialogCurrent = next ;
// The dialog belongs to the tab that asked: bring that tab forward so
// the sheet never appears over an unrelated page. (A panel's dialog has
// no tab and shows over whatever is current.)
if ( next . tabId != null && next . tabId !== activeId ) { try { setActive ( next . tabId ) ; } catch { } }
overlayReady ( jsDialogPop ) . then ( ( ) => {
if ( jsDialogCurrent !== next ) return ;
try {
jsDialogPop . webContents . send ( "jsdialog-show" , next . req ) ;
jsDialogPop . setVisible ( true ) ;
try { win . contentView . removeChildView ( jsDialogPop ) ; win . contentView . addChildView ( jsDialogPop ) ; } catch { }
layout ( ) ;
jsDialogPop . webContents . focus ( ) ;
} catch ( err ) {
jsDialogCurrent = null ;
jsDialogReply ( next , false , null ) ;
console . warn ( "page dialog show failed:" , err ? . message ) ;
pumpJsDialog ( ) ;
}
} ) ;
}
// The sheet belongs to one tab: hidden while another tab is in front, back
// (and above any tab added since) when its tab returns. A panel's sheet has
// no tab and stays.
function syncJsDialogVisibility ( ) {
if ( ! jsDialogPop || ! jsDialogCurrent || ! winAlive ( ) ) return ;
const show = jsDialogCurrent . tabId == null || jsDialogCurrent . tabId === activeId ;
try {
jsDialogPop . setVisible ( show ) ;
if ( show ) { win . contentView . removeChildView ( jsDialogPop ) ; win . contentView . addChildView ( jsDialogPop ) ; jsDialogPop . webContents . focus ( ) ; }
} catch { }
}
function finishJsDialog ( ok , value ) {
const cur = jsDialogCurrent ; if ( ! cur ) return ;
jsDialogCurrent = null ;
try { if ( jsDialogPop ) jsDialogPop . setVisible ( false ) ; } catch { }
jsDialogReply ( cur , ok , value ) ;
try { const t = cur . tabId != null ? tabById ( cur . tabId ) : null ; if ( t && t . id === activeId ) t . view . webContents . focus ( ) ; } catch { }
pumpJsDialog ( ) ;
}
// A tab closing (or the window going away) must not leave its renderer
// blocked inside a sendSync that nobody will answer.
function dismissJsDialogFor ( tabId ) {
for ( let i = jsDialogQueue . length - 1 ; i >= 0 ; i -- ) if ( tabId == null || jsDialogQueue [ i ] . tabId === tabId ) jsDialogReply ( jsDialogQueue . splice ( i , 1 ) [ 0 ] , false , null ) ;
if ( jsDialogCurrent && ( tabId == null || jsDialogCurrent . tabId === tabId ) ) finishJsDialog ( false , null ) ;
}
ipcMain . on ( "js-dialog" , ( e , raw ) => {
const kind = [ "alert" , "confirm" , "prompt" ] . includes ( raw && raw . kind ) ? raw . kind : "alert" ;
const message = String ( ( raw && raw . message ) ? ? "" ) . slice ( 0 , 4000 ) ;
const def = raw && raw . def != null ? String ( raw . def ) . slice ( 0 , 2000 ) : null ;
const tab = tabs . find ( ( t ) => t . view ? . webContents === e . sender ) ;
const who = jsDialogWho ( e . sender , tab ) ;
// Anything living in the browser window — a tab, a sidebar panel, an
// add-on's page — gets the sheet. Only another window (an installed app's
// window, a plain window) gets a native box: the sheet is drawn in win.
let owner = null ; try { owner = BrowserWindow . fromWebContents ( e . sender ) ; } catch { }
const inMain = winAlive ( ) && ( ! ! tab || ! owner || owner === win ) ;
if ( ! inMain || ! jsDialogPop ) {
if ( kind === "prompt" ) { e . returnValue = { handled : false } ; return ; }
let parent ; try { parent = BrowserWindow . fromWebContents ( e . sender ) || undefined ; } catch { }
let r = 0 ;
try {
r = dialog . showMessageBoxSync ( parent , {
type : kind === "confirm" ? "question" : "info" , title : "Theseus Navigator" ,
message : ` ${ who . label } says ` , detail : message ,
buttons : kind === "confirm" ? [ "OK" , "Cancel" ] : [ "OK" ] , defaultId : 0 , cancelId : 1 , noLink : true ,
} ) ;
} catch { r = 1 ; }
e . returnValue = { handled : true , value : kind === "confirm" ? ( r === 0 ? "1" : "0" ) : "" } ;
return ;
}
jsDialogQueue . push ( { e , tabId : tab ? tab . id : null , req : { reqId : ++ jsDialogSeq , kind , message , def , who } } ) ;
pumpJsDialog ( ) ;
} ) ;
ipcMain . handle ( "jsdialog-answer" , ( e , reqId , ok , value ) => {
if ( ! jsDialogPop || e . sender !== jsDialogPop . webContents ) return false ;
if ( ! jsDialogCurrent || jsDialogCurrent . req . reqId !== reqId ) return false ;
finishJsDialog ( ! ! ok , value ) ;
return true ;
} ) ;
2026-09-27 11:24:36 +02:00
let approvalPop = null ;
const approvalQueue = [ ] ;
let approvalCurrent = null ; // { reqId, resolve }
let approvalSeq = 0 ;
function pumpApproval ( ) {
if ( approvalCurrent || ! approvalQueue . length || ! approvalPop ) return ;
const next = approvalQueue . shift ( ) ;
approvalCurrent = next ;
// The overlay's page loads lazily after first paint; a dapp on a restored
// tab can ask for approval before that, so wait for the page rather than
// sending into an empty renderer (the request would silently hang).
overlayReady ( approvalPop ) . then ( ( ) => {
if ( approvalCurrent !== next ) return ;
try {
approvalPop . webContents . send ( "approval-show" , next . req ) ;
approvalPop . setVisible ( true ) ;
try { win . contentView . removeChildView ( approvalPop ) ; win . contentView . addChildView ( approvalPop ) ; } catch { }
layout ( ) ;
approvalPop . webContents . focus ( ) ;
} catch ( err ) {
approvalCurrent = null ;
next . resolve ( "cancel" ) ;
console . warn ( "[addons] approval show failed:" , err ? . message ) ;
}
} ) ;
}
function showApprovalModal ( opts , addonId ) {
const a = addonHost && addonHost . getInstalled ( ) . find ( ( x ) => x . manifest && x . manifest . id === addonId ) ;
const req = {
reqId : ++ approvalSeq ,
addonId ,
addonName : a ? a . manifest . name : addonId ,
title : String ( opts . title || "Approve?" ) ,
body : opts . body == null ? "" : String ( opts . body ) ,
origin : opts . origin == null ? "" : String ( opts . origin ) ,
rows : Array . isArray ( opts . rows ) ? opts . rows . map ( ( r ) => ( { label : String ( r . label ? ? "" ) , value : String ( r . value ? ? "" ) , mono : ! ! r . mono , strong : ! ! r . strong } ) ) : [ ] ,
actions : Array . isArray ( opts . actions ) ? opts . actions . map ( ( x ) => ( { id : String ( x . id ) , label : String ( x . label || x . id ) , primary : ! ! x . primary , danger : ! ! x . danger } ) ) : [ ] ,
checkbox : opts . checkbox ? { id : String ( opts . checkbox . id || "always" ) , label : String ( opts . checkbox . label || "Always allow" ) } : null ,
// Optional dropdown; a non-empty chosen value comes back as "+<id>=<value>".
select : opts . select && Array . isArray ( opts . select . options ) ? {
id : String ( opts . select . id || "choice" ) , label : String ( opts . select . label || "" ) ,
options : opts . select . options . map ( ( o ) => ( { value : String ( o . value ? ? "" ) , label : String ( o . label ? ? o . value ? ? "" ) } ) ) ,
} : null ,
} ;
return new Promise ( ( resolve ) => {
approvalQueue . push ( { req , resolve } ) ;
pumpApproval ( ) ;
} ) ;
}
ipcMain . handle ( "approval-pick" , ( e , reqId , action , checked , extra ) => {
if ( ! approvalPop || e . sender !== approvalPop . webContents ) return false ;
if ( ! approvalCurrent || approvalCurrent . req . reqId !== reqId ) return false ;
const cur = approvalCurrent ;
approvalCurrent = null ;
approvalPop . setVisible ( false ) ;
let result = String ( action || "cancel" ) ;
if ( result !== "cancel" && checked && cur . req . checkbox ) result += "+" + cur . req . checkbox . id ;
if ( result !== "cancel" && cur . req . select && extra && cur . req . select . options . some ( ( o ) => o . value === extra ) ) {
result += "+" + cur . req . select . id + "=" + extra ;
}
cur . resolve ( result ) ;
pumpApproval ( ) ;
return true ;
} ) ;
// --- Add-on storage (origin-gated to <userData>/addons/<id>/...) ------------
// Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main
// derives the add-on id from the sender's file:// URL so a page can only
// touch its own store; any file:// outside addons/ returns nothing.
ipcMain . handle ( "addon-storage-get" , ( e , key , fallback ) => {
const id = addonIdForSender ( e . sender ) ;
if ( ! id ) return fallback ? ? null ;
try {
const raw = JSON . parse ( fs . readFileSync ( path . join ( addonsDataDir ( ) , id + ".json" ) , "utf8" ) ) ;
return key in raw ? raw [ key ] : ( fallback ? ? null ) ;
} catch { return fallback ? ? null ; }
} ) ;
ipcMain . handle ( "addon-storage-set" , ( e , key , value ) => {
const id = addonIdForSender ( e . sender ) ;
if ( ! id ) return false ;
if ( typeof key !== "string" || key . length > 128 ) return false ;
const file = path . join ( addonsDataDir ( ) , id + ".json" ) ;
let store = { } ;
try { store = JSON . parse ( fs . readFileSync ( file , "utf8" ) ) ; } catch { }
store [ key ] = value ;
try { fs . mkdirSync ( addonsDataDir ( ) , { recursive : true } ) ; fs . writeFileSync ( file , JSON . stringify ( store ) ) ; return true ; }
catch ( err ) { console . warn ( ` [addons] storage.set failed for ${ id } : ` , err ? . message ) ; return false ; }
} ) ;
ipcMain . handle ( "addon-storage-all" , ( e ) => {
const id = addonIdForSender ( e . sender ) ;
if ( ! id ) return { } ;
try { return JSON . parse ( fs . readFileSync ( path . join ( addonsDataDir ( ) , id + ".json" ) , "utf8" ) ) ; }
catch { return { } ; }
} ) ;
// Error-page actions. All origin-gated to error.html so a third-party page
// that happens to see the API shape (home-preload exposes it on every tab)
// can't drive them.
ipcMain . handle ( "error-retry" , ( e , url ) => {
if ( ! isErrorPageSender ( e . sender ) ) return false ;
if ( typeof url !== "string" || ! url ) return false ;
navigateTab ( activeId , url ) ;
return true ;
} ) ;
ipcMain . handle ( "error-home" , ( e ) => {
if ( ! isErrorPageSender ( e . sender ) ) return false ;
loadHome ( activeId ) ;
return true ;
} ) ;
ipcMain . handle ( "error-search" , ( e , text ) => {
if ( ! isErrorPageSender ( e . sender ) ) return false ;
const q = String ( text || "" ) . trim ( ) ;
if ( ! q ) return false ;
navigateTab ( activeId , SEARCH ( q ) ) ;
return true ;
} ) ;
ipcMain . handle ( "error-register" , ( e , host ) => {
if ( ! isErrorPageSender ( e . sender ) ) return false ;
const h = String ( host || "" ) . trim ( ) . toLowerCase ( ) ;
if ( ! h ) return false ;
// Sirius's registrar UI takes ?prefill=<name>; if it ignores an unknown
// param the user just lands on the form and types it themselves.
const url = "https://sirius.x/register.html?prefill=" + encodeURIComponent ( h ) ;
navigateTab ( activeId , url ) ;
return true ;
} ) ;
// "Did you mean" for the error page. Returns up to `limit` BCNR-registered
// names within a small edit distance of `host`, same TLD only. Uses the
// warm sharedIndex — no network call, no wait — so an offline user still
// gets suggestions if the index warmed at least once. Ranks by ascending
// distance, then alphabetical for a stable list.
function levenshtein ( a , b ) {
const m = a . length , n = b . length ;
if ( Math . abs ( m - n ) > 3 ) return 4 ; // early-out, we only care about ≤2
const prev = new Array ( n + 1 ) ; for ( let j = 0 ; j <= n ; j ++ ) prev [ j ] = j ;
const cur = new Array ( n + 1 ) ;
for ( let i = 1 ; i <= m ; i ++ ) {
cur [ 0 ] = i ;
for ( let j = 1 ; j <= n ; j ++ ) {
const cost = a . charCodeAt ( i - 1 ) === b . charCodeAt ( j - 1 ) ? 0 : 1 ;
cur [ j ] = Math . min ( cur [ j - 1 ] + 1 , prev [ j ] + 1 , prev [ j - 1 ] + cost ) ;
}
for ( let j = 0 ; j <= n ; j ++ ) prev [ j ] = cur [ j ] ;
}
return prev [ n ] ;
}
ipcMain . handle ( "error-bns-similar" , ( e , host ) => {
if ( ! isErrorPageSender ( e . sender ) ) return [ ] ;
const h = String ( host || "" ) . trim ( ) . toLowerCase ( ) ;
if ( ! h || ! sharedIndex || typeof sharedIndex . keys !== "function" ) return [ ] ;
const tld = tldOf ( h ) ;
if ( ! tld ) return [ ] ;
const cands = [ ] ;
const maxDist = 2 ;
for ( const key of sharedIndex . keys ( ) ) {
if ( typeof key !== "string" ) continue ;
// Same TLD only — a typo like "games.x" → "game.x" or "gaeme.x" → "game.x".
if ( ! key . endsWith ( "." + tld ) ) continue ;
if ( key === h ) continue ;
const d = levenshtein ( h , key ) ;
if ( d <= maxDist ) cands . push ( { name : key , dist : d } ) ;
if ( cands . length > 200 ) break ; // hard cap so a huge index doesn't stall the render
}
cands . sort ( ( a , b ) => ( a . dist - b . dist ) || a . name . localeCompare ( b . name ) ) ;
return cands . slice ( 0 , 3 ) . map ( ( c ) => c . name ) ;
} ) ;
ipcMain . handle ( "error-open-external" , ( e , url ) => {
if ( ! isErrorPageSender ( e . sender ) ) return false ;
// Allowlist Silent Mode domains only — no arbitrary external opens from
// a page that visits when things are already going wrong.
const ok = typeof url === "string" && /^https:\/\/(silentmode\.st|silentmode\.bch|sirius\.x|theseus\.x|navigate\.st)(\/|$)/i . test ( url ) ;
if ( ! ok ) return false ;
try { shell . openExternal ( url ) ; return true ; } catch { return false ; }
} ) ;
// Update chip: user clicked the download button → download the installer
// through Theseus itself. session.downloadURL triggers the same
// will-download handler our own downloads panel listens on, so the file
// lands in the user's Downloads folder AND appears in the in-app
// downloads chip with progress + Show-in-folder. No system browser
// jump, no "why did another browser open?" confusion.
ipcMain . handle ( "open-update-download" , ( _e , url ) => {
const ok = typeof url === "string" && ( url . startsWith ( "https://dl.silentmode.st/" ) || url . startsWith ( "https://silentmode.st/" ) ) ;
if ( ! ok ) return false ;
try {
// The downloads toolbar button spins + shows a progress badge as
// will-download / did-update updates fire, so the user sees the
// transfer without us having to force-open the downloads panel.
session . defaultSession . downloadURL ( url ) ;
return true ;
} catch ( e ) { console . warn ( "update download failed:" , e ? . message ) ; return false ; }
} ) ;
ipcMain . handle ( "dismiss-update" , ( ) => { updateDismissedThisSession = true ; emitUpdateAvailable ( ) ; return true ; } ) ;
// Find-in-page. Chrome renderer's find bar drives this: the bar sends
// `find-in-page` with the query + direction; main proxies to the active
// tab's webContents.findInPage. Chromium raises `found-in-page` on each
// keystroke with the running match count / active match ordinal; we
// forward that back to chrome so the bar can render "3 of 12".
ipcMain . handle ( "find-in-page" , ( _e , query , opts = { } ) => {
const t = activeTab ( ) ; if ( ! t ) return false ;
if ( typeof query !== "string" || ! query ) { try { t . view . webContents . stopFindInPage ( "clearSelection" ) ; } catch { } return false ; }
try {
t . view . webContents . findInPage ( query , {
forward : opts . forward !== false ,
findNext : ! ! opts . findNext , // false = new query; true = jump next/prev
matchCase : ! ! opts . matchCase ,
} ) ;
return true ;
} catch { return false ; }
} ) ;
ipcMain . handle ( "find-stop" , ( ) => {
const t = activeTab ( ) ; if ( ! t ) return false ;
try { t . view . webContents . stopFindInPage ( "clearSelection" ) ; return true ; } catch { return false ; }
} ) ;
// Just the app version — no network. Used by Settings > General so the
// user can see which Theseus they're on without clicking "Check for updates".
ipcMain . handle ( "app-version" , ( ) => app . getVersion ( ) ) ;
// Clean relaunch — used by the Aegis card to apply a staged add-on update
// (promotion happens on next boot; this is just how the user gets there).
ipcMain . handle ( "app-restart" , ( e ) => { let from = "?" ; try { from = e . sender . getURL ( ) ; } catch { } console . log ( "[restart] requested via app-restart IPC from" , from ) ; try { app . relaunch ( ) ; } catch { } app . quit ( ) ; } ) ;
ipcMain . handle ( "recheck-update" , async ( ) => {
// Manual "Check for updates" also un-dismisses any chip the user closed
// in this session — they're actively asking to see the status, so honour
// that. Report current app version too so the UI can render "you're on
// vN.M.O" when no newer build is out.
updateDismissedThisSession = false ;
await checkForUpdate ( ) ;
return { updateAvailable , currentVersion : app . getVersion ( ) } ;
} ) ;
// One-click "Install & restart". Requires the silent pre-fetch to have
// finished (updateDownloadState === "ready"). Launches the setup with
// /S (silent, skips the wizard) and --force-run (electron-builder's NSIS
// convention for "start the app when the install finishes"), then quits
// Theseus so the installer can overwrite it. The user sees the browser
// disappear for a couple of seconds and come back on the new version —
// no manual relaunch needed. Verified E2E on 0.3.33 → 0.3.34 in the
// 2026-09-08 session; the 0.3.37 → 0.3.39 update ran WITHOUT --force-run
// (the flag was dropped in the 0.3.31 rewrite once it was clear /S alone
// installs correctly) and the user reported the missing relaunch — this
// commit puts --force-run back on so the auto-restart is part of the
// standard flow again. --updated stays out: the earlier E2E showed it
// wasn't load-bearing correctness for our NSIS config.
//
// 2026-09-11: an update ran while the app was still shutting down, both NSIS
// processes died ~8 s in, and the install was left without app.asar. The
// installer is no longer spawned from here: install-update-now only records
// the setup path and quits; will-quit then starts a detached batch helper
// that waits for this PID to be gone, runs the installer, and re-runs it
// once if app.asar is missing afterwards. See lib/update-helper.cjs for
// the script, the console-less cmd.exe traps, and the reasoning.
let pendingInstallerPath = null ;
ipcMain . handle ( "install-update-now" , ( ) => {
if ( updateDownloadState !== "ready" || ! updateDownloadPath ) return false ;
pendingInstallerPath = updateDownloadPath ;
app . quit ( ) ;
return true ;
} ) ;
app . on ( "will-quit" , ( ) => {
if ( ! pendingInstallerPath ) return ;
const setupPath = pendingInstallerPath ;
pendingInstallerPath = null ;
try {
const { buildUpdateHelperCmd } = require ( "./lib/update-helper.cjs" ) ;
const cmdPath = path . join ( app . getPath ( "userData" ) , "update-helper.cmd" ) ;
fs . writeFileSync ( cmdPath , buildUpdateHelperCmd ( { pid : process . pid , setupPath , installDir : path . dirname ( process . execPath ) } ) ) ;
// A detached cmd.exe outlives this process (verified) and is in no job
// of ours; the batch file itself waits for our PID to disappear.
const helper = spawn ( "cmd.exe" , [ ` /d /c " ${ cmdPath } " ` ] ,
{ detached : true , stdio : "ignore" , windowsHide : true , windowsVerbatimArguments : true } ) ;
helper . unref ( ) ;
console . log ( ` [update] helper armed for ${ setupPath } ` ) ;
} catch ( e ) { console . warn ( "[update] helper spawn failed:" , e ? . message ) ; }
} ) ;
// Home page editable cards. Origin-gated to home.html — random pages that
// snoop the preload can't act on the local file.
ipcMain . handle ( "home-cards-get" , ( e ) => isHomePageSender ( e . sender ) ? loadHomeCards ( ) : [ ] ) ;
ipcMain . handle ( "home-cards-set" , ( e , cards ) => { if ( ! isHomePageSender ( e . sender ) ) return false ; if ( ! Array . isArray ( cards ) ) return false ; saveHomeCards ( cards ) ; return true ; } ) ;
ipcMain . handle ( "home-cards-reset" , ( e ) => { if ( ! isHomePageSender ( e . sender ) ) return false ; try { fs . unlinkSync ( homeCardsFile ( ) ) ; } catch { } return true ; } ) ;
ipcMain . handle ( "go-back" , ( ) => { const wc = activeTab ( ) ? . view . webContents ; if ( wc ? . navigationHistory . canGoBack ( ) ) wc . navigationHistory . goBack ( ) ; } ) ;
ipcMain . handle ( "go-forward" , ( ) => { const wc = activeTab ( ) ? . view . webContents ; if ( wc ? . navigationHistory . canGoForward ( ) ) wc . navigationHistory . goForward ( ) ; } ) ;
ipcMain . handle ( "reload" , ( _e , hard ) => {
const wc = activeTab ( ) ? . view . webContents ;
if ( ! wc ) return ;
try { hard ? wc . reloadIgnoringCache ( ) : wc . reload ( ) ; } catch { }
} ) ;
ipcMain . handle ( "stop" , ( ) => { const t = activeTab ( ) ; try { t ? . view . webContents . stop ( ) ; } catch { } setLoading ( t , false ) ; } ) ;
ipcMain . handle ( "toggle-tor" , ( ) => { torState === "off" ? startTor ( ) : stopTor ( ) ; } ) ;
// Open (or focus) the Settings tab. Optional section slug ("passwords",
// "addons", …) lands directly on that sidebar entry when the page loads.
// settings.html watches for a fragment on load and an IPC message when
// already loaded.
function openSettingsTab ( section ) {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i . test ( section ) ? section . toLowerCase ( ) : "" ;
const ex = tabs . find ( ( t ) => t . settings ) ;
if ( ex ) {
setActive ( ex . id ) ;
if ( slug ) { try { ex . view . webContents . send ( "focus-section" , slug ) ; } catch { } }
return ;
}
createTab ( null , { settings : true , settingsSection : slug } ) ;
}
ipcMain . handle ( "open-settings" , ( _e , section ) => openSettingsTab ( section ) ) ;
ipcMain . handle ( "toggle-site-info" , ( _e , rect ) => {
if ( popVisible ) return showPopover ( false ) ;
if ( rect ) popPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showPopover ( true ) ;
} ) ;
ipcMain . handle ( "close-site-info" , ( ) => showPopover ( false ) ) ;
ipcMain . handle ( "popover-resize" , ( _e , h ) => { popH = Math . max ( 90 , Math . min ( 380 , Math . round ( h ) || 210 ) ) ; if ( popVisible ) positionPopover ( ) ; } ) ;
// ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
ipcMain . handle ( "collision-switch" , ( _e , arg ) => switchRegistry ( arg ) ) ;
async function switchRegistry ( arg ) {
const t = activeTab ( ) ; if ( ! t ? . prov ? . host ) return null ;
const host = t . prov . host , tld = tldOf ( host ) ;
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
const raw = arg ? . choice ;
const choice = ( raw === "bcdn" || raw === "bcnr" ) ? "bcnr"
: ( raw === "icann" ) ? "icann"
: ( t . prov . kind === "ok" ? "icann" : "bcnr" ) ; // flip the current one
// Optional persistent memory ("Always use…" from the popover switcher).
rememberCollision ( host , tld , choice , arg ? . remember || "no" ) ;
const registry = registryOf ( tld ) ;
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
// collision-decision path can trigger a re-prompt on an explicit user switch.
// The user clicked the switcher; they've made their choice. Just load it.
setLoading ( t , true ) ;
t . internalNav = true ;
try {
if ( choice === "icann" ) {
t . url = "https://" + host + "/" ;
await t . view . webContents . loadURL ( t . url ) ;
t . prov = { host , kind : "web" , note : "switched to ICANN" , tld , registry } ;
} else {
t . url = "https://" + host + "/" ; // display: https://; internal fetch: bns://
await t . view . webContents . loadURL ( ` bns:// ${ host } / ` ) ;
const rec = entries . get ( host ) ;
const r = rec ? . entry ? . records || { } ;
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
const _isSub = rec ? . entry ? . name && host !== rec . entry . name ;
const src = ( _isSub && r . ip ) ? "direct server"
: r . h ? "on-chain (chain)"
: r . s3 ? "Sia network"
: r . ip ? "direct server"
: ( ! _isSub && r . p ) ? "mirror"
: r . u ? "redirect"
: "record" ;
t . prov = { host , kind : "ok" , source : src , category : rec ? . entry ? . category , records : Object . keys ( r ) , dns : dnsRecordKinds ( rec ? . entry ) , tld , registry } ;
}
} catch ( e ) { console . warn ( "collision-switch load failed:" , e ? . message ) ; }
finally { t . internalNav = false ; setLoading ( t , false ) ; }
if ( t . id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
// Ariadne's Thread menu — the registry button at the end of the address
// bar. Replaces the BCDN/ICANN segmented chips: one icon that never
// overflows the bar, a native popup with the switch, the per-name / per-TLD
// memory, the collision policy, and a jump to the resolver settings.
ipcMain . handle ( "registry-menu-popup" , ( e , rect ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "registry-menu-popup: untrusted sender" ) ;
const t = activeTab ( ) ;
const prov = t ? . prov || null ;
const host = String ( prov ? . host || "" ) ;
const tld = String ( prov ? . tld || ( host ? tldOf ( host ) : "" ) || "" ) ;
const onBcdn = prov ? . kind === "ok" ;
const onIcann = prov ? . kind === "web" ;
const isCand = ! ! tld && ( onBcdn || onIcann ) && ! isBcnrNativeTld ( tld ) ;
const current = onBcdn ? "bcnr" : "icann" ;
const policyItem = ( value , label ) => ( {
label , type : "radio" , checked : settings . collisionPolicy === value ,
click : ( ) => { settings . collisionPolicy = value ; saveSettings ( ) ; } ,
} ) ;
const template = [
{ label : host ? ` Ariadne's Thread · ${ host } ` : "Ariadne's Thread" , enabled : false } ,
{ type : "separator" } ,
{ label : "Serve from BCDN" , type : "radio" , checked : onBcdn , enabled : isCand || onBcdn ,
click : ( ) => { if ( ! onBcdn ) switchRegistry ( { choice : "bcnr" , remember : "no" } ) . catch ( ( ) => { } ) ; } } ,
{ label : "Serve from ICANN" , type : "radio" , checked : onIcann , enabled : isCand || onIcann ,
click : ( ) => { if ( ! onIcann ) switchRegistry ( { choice : "icann" , remember : "no" } ) . catch ( ( ) => { } ) ; } } ,
{ label : "Remember" , enabled : ! ! host , submenu : [
{ label : host ? ` Always open ${ host } this way ` : "Always open this site this way" , enabled : isCand ,
click : ( ) => rememberCollision ( host , tld , current , "name" ) } ,
{ label : tld ? ` Always open . ${ tld } names this way ` : "Always open this TLD this way" , enabled : isCand ,
click : ( ) => rememberCollision ( host , tld , current , "tld" ) } ,
{ type : "separator" } ,
{ label : "Forget remembered choices" , click : ( ) => { collisions = { byName : { } , byTld : { } } ; saveCollisions ( ) ; } } ,
] } ,
{ type : "separator" } ,
{ label : "When a name exists on both registries" , submenu : [
policyItem ( "bcnr-first" , "BCDN first" ) ,
policyItem ( "icann-first" , "ICANN first" ) ,
policyItem ( "soft" , "Ask each time" ) ,
] } ,
{ type : "separator" } ,
{ label : "Ariadne's Thread settings…" , click : ( ) => openSettingsTab ( "plugins" ) } ,
] ;
const popup = Menu . buildFromTemplate ( template ) ;
const chromeBounds = chrome ? chrome . getBounds ( ) : { x : 0 , y : 0 } ;
const x = Math . max ( 0 , Math . round ( chromeBounds . x + ( rect ? . x || 0 ) ) ) ;
const y = Math . max ( 0 , Math . round ( chromeBounds . y + ( rect ? . y || 0 ) ) ) ;
popup . popup ( { window : win , x , y } ) ;
} ) ;
// Back/Forward history menu (press-and-hold or right-click on the buttons).
// Lists up to 15 entries in the requested direction, nearest first, from the
// active tab's navigation history; picking one jumps straight to it.
ipcMain . handle ( "nav-history-menu" , ( e , dir , rect ) => {
if ( chrome && e . sender !== chrome . webContents ) throw new Error ( "nav-history-menu: untrusted sender" ) ;
const t = activeTab ( ) ; const wc = t ? . view ? . webContents ;
if ( ! wc ) return false ;
const nh = wc . navigationHistory ;
const entries = nh . getAllEntries ( ) ;
const cur = nh . getActiveIndex ( ) ;
const picks = [ ] ;
if ( dir === "forward" ) for ( let i = cur + 1 ; i < entries . length && picks . length < 15 ; i ++ ) picks . push ( i ) ;
else for ( let i = cur - 1 ; i >= 0 && picks . length < 15 ; i -- ) picks . push ( i ) ;
if ( ! picks . length ) return false ;
const label = ( en ) => {
const title = String ( en . title || "" ) . trim ( ) ;
let host = "" ; try { host = new URL ( en . url ) . host ; } catch { }
const text = title || en . url || "" ;
return ( text . length > 60 ? text . slice ( 0 , 57 ) + "…" : text ) + ( host && title ? ` — ${ host } ` : "" ) ;
} ;
const template = picks . map ( ( i ) => ( { label : label ( entries [ i ] ) , click : ( ) => { try { nh . goToIndex ( i ) ; } catch { } } } ) ) ;
const popup = Menu . buildFromTemplate ( template ) ;
const chromeBounds = chrome ? chrome . getBounds ( ) : { x : 0 , y : 0 } ;
popup . popup ( { window : win , x : Math . max ( 0 , Math . round ( chromeBounds . x + ( rect ? . x || 0 ) ) ) , y : Math . max ( 0 , Math . round ( chromeBounds . y + ( rect ? . y || 0 ) ) ) } ) ;
return true ;
} ) ;
ipcMain . handle ( "collision-state" , ( ) => ( {
policy : settings . collisionPolicy ,
byName : collisions . byName ,
byTld : collisions . byTld ,
bcnrTlds ,
} ) ) ;
ipcMain . handle ( "collision-set-policy" , ( _e , p ) => {
if ( [ "bcnr-first" , "icann-first" , "soft" ] . includes ( p ) ) { settings . collisionPolicy = p ; saveSettings ( ) ; }
return settings . collisionPolicy ;
} ) ;
ipcMain . handle ( "collision-reset" , ( ) => { collisions = { byName : { } , byTld : { } } ; saveCollisions ( ) ; return true ; } ) ;
// Ariadne's Thread system-wide resolver — installed by AriadneResolver-Setup.exe
// as two Windows Scheduled Tasks ("BNS Resolver Daemon" + "BNS Sia Bridge").
// Turning them off means non-Theseus browsers stop resolving BCDN names on
// this machine; Theseus itself uses its own in-process resolver so it's
// unaffected. Toggling requires admin (tasks run as SYSTEM) — start/stop go
// through an elevated powershell that UAC-prompts once per action.
//
// As of 0.3.23 Ariadne is NOT bundled inside Theseus. Install / Update stream
// AriadneResolver-Setup-<v>.exe directly from silentmode.st and verify its
// SHA-256 against the on-site releases manifest before spawning it, so
// Ariadne's release cadence is decoupled from ours.
const ARIADNE _TASKS = [ "BNS Resolver Daemon" , "BNS Sia Bridge" ] ;
// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both
// native and WOW6432 in case Inno installed either way.
// PowerShell fragment that leaves Ariadne's Inno uninstall entry in $r (or
// $null). Found by DisplayName rather than by key path: the installer's
// AppId is written as `{{…}}`, which Inno stores as `{…}}_is1` (doubled
// closing brace), so the literal key path Theseus used to look for never
// matched — the panel showed no version, no Update, no Uninstall. Matching
// on the name also survives a future AppId fix.
const ARIADNE _REG _LOOKUP =
"$r=$null;foreach($root in 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'){" +
"if($r){break};foreach($k in (Get-ChildItem $root -ErrorAction SilentlyContinue)){$p=Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue;" +
"if($p.DisplayName -like 'Ariadne Resolver*' -and $p.QuietUninstallString){$r=$p;break}}};" ;
// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on
// silentmode.st is a mirror that has lagged behind dl.silentmode.st (2026-09-09:
// it still listed Theseus 0.3.31 while dl had 0.3.44), so the Ariadne "Update"
// button was checking against a stale version list.
const ARIADNE _MANIFEST _URL = "https://dl.silentmode.st/releases-manifest.json" ;
const ARIADNE _DL _ORIGIN = "https://dl.silentmode.st" ;
const ARIADNE _MANIFEST _TTL _MS = 30 * 60 * 1000 ;
let ariadneManifestCache = null ; // { at:number, entry:{version,filename,sha256,url}|null }
// GET the releases manifest, find the win-x64 ariadne-resolver entry, return
// {version, filename, sha256, url}. Cached 30 min in-process; opening the
// Settings panel every few seconds does not spam silentmode.st. On any
// failure (offline, 5xx, malformed JSON) returns null and the caller shows
// bundledVersion:null / canUpdate:false gracefully.
function ariadneManifestFetch ( ) {
const now = Date . now ( ) ;
if ( ariadneManifestCache && now - ariadneManifestCache . at < ARIADNE _MANIFEST _TTL _MS ) {
return Promise . resolve ( ariadneManifestCache . entry ) ;
}
return new Promise ( ( resolve ) => {
const https = require ( "node:https" ) ;
const req = https . request ( ARIADNE _MANIFEST _URL , {
method : "GET" , timeout : 8000 ,
headers : { "user-agent" : "TheseusNavigator/ariadne-updater" } ,
} , ( r ) => {
if ( r . statusCode !== 200 ) { r . resume ( ) ; ariadneManifestCache = { at : now , entry : null } ; return resolve ( null ) ; }
const chunks = [ ] ;
r . on ( "data" , ( c ) => chunks . push ( c ) ) ;
r . on ( "end" , ( ) => {
try {
const j = JSON . parse ( Buffer . concat ( chunks ) . toString ( "utf8" ) ) ;
const rel = ( j . releases || [ ] ) . find ( ( x ) => x . id === "ariadne-resolver" && x . platform === "win-x64" ) ;
if ( ! rel ) { ariadneManifestCache = { at : now , entry : null } ; return resolve ( null ) ; }
const filename = Object . keys ( rel . files || { } ) . find ( ( f ) => / ^ AriadneResolver - Setup - . * \ . exe$ / i . test ( f ) ) ;
if ( ! filename ) { ariadneManifestCache = { at : now , entry : null } ; return resolve ( null ) ; }
const entry = { version : rel . version , filename , sha256 : String ( rel . files [ filename ] || "" ) . toLowerCase ( ) , url : ARIADNE _DL _ORIGIN + "/" + filename } ;
ariadneManifestCache = { at : now , entry } ;
resolve ( entry ) ;
} catch { ariadneManifestCache = { at : now , entry : null } ; resolve ( null ) ; }
} ) ;
} ) ;
req . on ( "timeout" , ( ) => req . destroy ( new Error ( "manifest timeout" ) ) ) ;
req . on ( "error" , ( ) => { ariadneManifestCache = { at : now , entry : null } ; resolve ( null ) ; } ) ;
req . end ( ) ;
} ) ;
}
// Stream the .exe to a per-session temp file, hashing as we go. Reject on
// hash mismatch (and delete the file) so a wrong-hash binary is never spawned.
// The SHA-256 is authoritative because the manifest itself is served over
// HTTPS -- silentmode.st TLS -> manifest.json -> hash -> verified .exe.
function ariadneDownloadInstaller ( entry ) {
return new Promise ( ( resolve , reject ) => {
const https = require ( "node:https" ) ;
const crypto = require ( "node:crypto" ) ;
const dst = path . join ( app . getPath ( "temp" ) , ` ariadne- ${ entry . version } - ${ Date . now ( ) } .exe ` ) ;
const req = https . request ( entry . url , {
method : "GET" , timeout : 60000 ,
headers : { "user-agent" : "TheseusNavigator/ariadne-updater" } ,
} , ( r ) => {
if ( r . statusCode !== 200 ) { r . resume ( ) ; return reject ( new Error ( ` download ${ entry . url } -> HTTP ${ r . statusCode } ` ) ) ; }
const hash = crypto . createHash ( "sha256" ) ;
const out = fs . createWriteStream ( dst ) ;
r . on ( "data" , ( c ) => hash . update ( c ) ) ;
r . pipe ( out ) ;
out . on ( "finish" , ( ) => {
const got = hash . digest ( "hex" ) . toLowerCase ( ) ;
if ( got !== entry . sha256 ) {
try { fs . unlinkSync ( dst ) ; } catch { }
return reject ( new Error ( ` SHA-256 mismatch: got ${ got } , want ${ entry . sha256 } ` ) ) ;
}
resolve ( dst ) ;
} ) ;
out . on ( "error" , ( e ) => { try { fs . unlinkSync ( dst ) ; } catch { } ; reject ( e ) ; } ) ;
} ) ;
req . on ( "timeout" , ( ) => req . destroy ( new Error ( "download timeout" ) ) ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
function ariadneQueryState ( ) {
const { spawn } = require ( "child_process" ) ;
// One shell round-trip for both bits of info:
// - task states for BNS Resolver Daemon + BNS Sia Bridge
// - installed version + quiet-uninstall string from Inno's registry entry
// Task state comes from the Task Scheduler COM object, not Get-ScheduledTask:
// that cmdlet imports the ScheduledTasks module, which took 8– 10 s cold on the
// 0.3.48 install and left the panel on "checking…" with every button hidden
// for that long (user report 2026-09-16). The COM state is a number, so it
// is also locale-independent (schtasks.exe prints localized status words).
// The manifest fetch used to run after this shell finished; it now runs in
// parallel.
const shell = new Promise ( ( resolve ) => {
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-NonInteractive" , "-Command" ,
"$svc=New-Object -ComObject Schedule.Service;$svc.Connect();$f=$svc.GetFolder('\\');" +
"foreach($n in 'BNS Resolver Daemon','BNS Sia Bridge'){try{$t=$f.GetTask($n);\"$n=$($t.State)\"}catch{\"$n=MISSING\"}};" +
ARIADNE _REG _LOOKUP +
"if ($r) { \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\" }"
] , { windowsHide : true } ) ;
let out = "" ;
ps . stdout . on ( "data" , ( d ) => { out += d ; } ) ;
ps . on ( "close" , ( ) => resolve ( out ) ) ;
ps . on ( "error" , ( ) => resolve ( "" ) ) ;
} ) ;
return Promise . all ( [ shell , ariadneManifestFetch ( ) ] ) . then ( ( [ out , latest ] ) => {
const lines = out . trim ( ) . split ( /\r?\n/ ) . filter ( Boolean ) ;
const map = Object . fromEntries ( lines . map ( ( l ) => { const i = l . lastIndexOf ( "=" ) ; return [ l . slice ( 0 , i ) , l . slice ( i + 1 ) ] ; } ) ) ;
const primary = map [ "BNS Resolver Daemon" ] ; // TASK_STATE: 1 disabled, 2 queued, 3 ready, 4 running
const installedVersion = map . _ _VER _ _ || null ;
const quietUninstall = map . _ _UNINSTALL _ _ || null ;
const latestVersion = latest ? latest . version : null ;
const canUpdate = ! ! ( installedVersion && latestVersion && cmpVersions ( latestVersion , installedVersion ) > 0 ) ;
let state ;
if ( ! primary || primary === "MISSING" ) state = installedVersion ? "stopped" : "not-installed" ;
else if ( primary === "4" || primary === "Running" ) state = "running" ;
else state = "stopped" ;
// The "bundledVersion" field name is kept for renderer compatibility --
// it now carries the latest version advertised by silentmode.st's
// releases manifest, not a version physically bundled with Theseus.
return { state , installedVersion , bundledVersion : latestVersion , canUpdate , hasUninstaller : ! ! quietUninstall } ;
} ) ;
}
function cmpVersions ( a , b ) {
const pa = String ( a ) . split ( "." ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
const pb = String ( b ) . split ( "." ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
const n = Math . max ( pa . length , pb . length ) ;
for ( let i = 0 ; i < n ; i ++ ) { const d = ( pa [ i ] || 0 ) - ( pb [ i ] || 0 ) ; if ( d ) return d < 0 ? - 1 : 1 ; }
return 0 ;
}
function ariadneSetState ( on ) {
return new Promise ( ( resolve , reject ) => {
const { spawn } = require ( "child_process" ) ;
// Off = stop AND disable, on = enable AND start. The daemon task has an
// at-startup trigger, so a plain Stop-ScheduledTask came back on the
// next reboot and "Turn off" silently didn't stick.
//
// The inner script goes across as -EncodedCommand (base64 UTF-16LE). The
// previous version embedded it in a double-quoted string on the OUTER
// powershell's command line, which interpolated `$t` to nothing before
// the elevated shell ever saw it — the elevated shell got
// `foreach ( in ...)`, failed to parse, and the outer shell still exited
// 0, so the toggle reported success while doing nothing.
const inner = on
? ` $ ok = $ true
foreach ( $t in 'BNS Resolver Daemon' , 'BNS Sia Bridge' ) {
try { Enable - ScheduledTask - TaskName $t - ErrorAction Stop | Out - Null ; Start - ScheduledTask - TaskName $t - ErrorAction Stop }
catch { if ( $t - eq 'BNS Resolver Daemon' ) { $ok = $false } }
}
if ( $ok ) { exit 0 } else { exit 2 } `
: ` $ ok = $ true
foreach ( $t in 'BNS Resolver Daemon' , 'BNS Sia Bridge' ) {
try { Stop - ScheduledTask - TaskName $t - ErrorAction Stop } catch { }
try { Disable - ScheduledTask - TaskName $t - ErrorAction Stop | Out - Null }
catch { if ( $t - eq 'BNS Resolver Daemon' ) { $ok = $false } }
}
if ( $ok ) { exit 0 } else { exit 2 } ` ;
const encoded = Buffer . from ( inner , "utf16le" ) . toString ( "base64" ) ;
// -PassThru + exit $p.ExitCode: the elevated shell's exit code (0 ok,
// 2 = daemon task missing) reaches us; a declined UAC prompt makes
// Start-Process throw, which exits the outer shell non-zero.
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-Command" ,
` $ p = Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand',' ${ encoded } '; exit $ p.ExitCode ` ] , { windowsHide : true } ) ;
ps . on ( "close" , ( code ) => {
if ( code === 0 ) resolve ( true ) ;
else if ( code === 2 ) reject ( new Error ( "the 'BNS Resolver Daemon' scheduled task is missing — reinstall Ariadne's Thread" ) ) ;
else reject ( new Error ( "UAC declined or task failed (exit " + code + ")" ) ) ;
} ) ;
ps . on ( "error" , ( e ) => reject ( e ) ) ;
} ) ;
}
// Fetch manifest -> download+verify AriadneResolver-Setup-<v>.exe -> spawn
// Inno silently+elevated (/VERYSILENT /SUPPRESSMSGBOXES /NORESTART). The
// downloaded .exe is deleted whether the install succeeds or fails, so a
// wrong-hash abort never leaves a suspect binary behind.
async function ariadneInstall ( ) {
const entry = await ariadneManifestFetch ( ) ;
if ( ! entry ) throw new Error ( "could not reach silentmode.st releases manifest" ) ;
const exePath = await ariadneDownloadInstaller ( entry ) ;
const { spawn } = require ( "child_process" ) ;
return new Promise ( ( resolve , reject ) => {
// -PassThru + exit $p.ExitCode so Inno's own exit code reaches us; a bare
// -Wait always returned 0 and a failed silent install looked like success.
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-Command" ,
` $ p = Start-Process -FilePath ' ${ exePath . replace ( /'/g , "''" ) } ' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $ p.ExitCode `
] , { windowsHide : true } ) ;
const cleanup = ( ) => { try { fs . unlinkSync ( exePath ) ; } catch { } } ;
ps . on ( "close" , ( code ) => { cleanup ( ) ; code === 0 ? resolve ( true ) : reject ( new Error ( "installer exited " + code ) ) ; } ) ;
ps . on ( "error" , ( e ) => { cleanup ( ) ; reject ( e ) ; } ) ;
} ) ;
}
// Run Inno's own quiet uninstaller. Reads the QuietUninstallString from the
// registry (already ends in / VERYSILENT) and spawns it elevated.
function ariadneUninstall ( ) {
const { spawn } = require ( "child_process" ) ;
return new Promise ( ( resolve , reject ) => {
// Read the uninstall string fresh — a stale cache could point at a moved
// file. Extract the path (may be quoted) + any trailing args.
const cmd = ARIADNE _REG _LOOKUP + "if($r){Write-Host $r.QuietUninstallString}" ;
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-NonInteractive" , "-Command" , cmd ] , { windowsHide : true } ) ;
let out = "" ;
ps . stdout . on ( "data" , ( d ) => { out += d ; } ) ;
ps . on ( "close" , ( ) => {
const uninstallCmd = out . trim ( ) ;
if ( ! uninstallCmd ) return reject ( new Error ( "Ariadne uninstaller not registered" ) ) ;
// uninstallCmd typically: "C:\Program Files (x86)\...\unins000.exe" /VERYSILENT
// Spawn it elevated. Inno's silent uninstall respects /VERYSILENT so no UI.
const runCmd = ` $ p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${ uninstallCmd . replace ( /'/g , "''" ) } /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $ p.ExitCode ` ;
const ps2 = spawn ( "powershell.exe" , [ "-NoProfile" , "-Command" , runCmd ] , { windowsHide : true } ) ;
ps2 . on ( "close" , ( code ) => code === 0 ? resolve ( true ) : reject ( new Error ( "uninstaller exited " + code ) ) ) ;
ps2 . on ( "error" , reject ) ;
} ) ;
ps . on ( "error" , reject ) ;
} ) ;
}
// Update = run the bundled installer over the top. Inno Setup detects the
// same AppId and upgrades in place. Same UAC dance as install.
const ariadneUpdate = ariadneInstall ;
ipcMain . handle ( "ariadne-state" , ( ) => ariadneQueryState ( ) . catch ( ( ) => ( { state : "not-installed" , installedVersion : null , bundledVersion : null , canUpdate : false , hasUninstaller : false } ) ) ) ;
ipcMain . handle ( "ariadne-toggle" , ( _e , on ) => ariadneSetState ( ! ! on ) . catch ( ( e ) => ( { ok : false , error : e ? . message || String ( e ) } ) ) ) ;
ipcMain . handle ( "ariadne-install" , ( ) => ariadneInstall ( ) . then ( ( ) => ( { ok : true } ) ) . catch ( ( e ) => ( { ok : false , error : e ? . message || String ( e ) } ) ) ) ;
ipcMain . handle ( "ariadne-update" , ( ) => ariadneUpdate ( ) . then ( ( ) => ( { ok : true } ) ) . catch ( ( e ) => ( { ok : false , error : e ? . message || String ( e ) } ) ) ) ;
ipcMain . handle ( "ariadne-uninstall" , ( ) => ariadneUninstall ( ) . then ( ( ) => ( { ok : true } ) ) . catch ( ( e ) => ( { ok : false , error : e ? . message || String ( e ) } ) ) ) ;
// Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
// held in this main-process closure only — never sent to a renderer except
// in the explicit response to password-get(id). Cleared on quit alongside
// the other storage clears (see before-quit hook).
const vaultFile = ( ) => path . join ( app . getPath ( "userData" ) , "passwords.vault" ) ;
let vaultState = null ; // { key, purposeRoot, entries, _salt, _iters }
// Imports live in a SEPARATE encrypted file (design §3.2) so a bug in one
// vault can't destroy the other, and so an attacker holding the primary
// purposeRoot in RAM never yields the imports' seeds/WIFs. Same master
// password, different KDF salt = disjoint AES keys.
const importsFile = ( ) => path . join ( app . getPath ( "userData" ) , "wallet-imports.enc" ) ;
let importsState = null ; // { key, accounts, _salt, _iters }
let importsUnlockPw = null ; // held only if we may need to write the file this session
const vaultOk = ( ) => ( { ok : true } ) ;
const vaultErr = ( m ) => ( { ok : false , err : String ( m ) } ) ;
ipcMain . handle ( "password-status" , ( ) => ( {
setup : fs . existsSync ( vaultFile ( ) ) ,
unlocked : ! ! vaultState ,
} ) ) ;
ipcMain . handle ( "password-setup" , async ( _e , { masterPassword , seedSource } ) => {
try {
if ( ! masterPassword || String ( masterPassword ) . length < 4 ) return vaultErr ( "master password too short" ) ;
if ( fs . existsSync ( vaultFile ( ) ) ) return vaultErr ( "vault already exists" ) ;
const v = await loadVaultLib ( ) ;
let purposeRootHex , messengerRootHex ;
if ( seedSource && seedSource . kind === "mnemonic" && seedSource . mnemonic ) {
// Same seed, two purpose roots — one for password derivation, one for
// the Nostr messaging identity. Storing both means Hermes can bind to
// the vault so the user never re-enters the mnemonic. Different HKDF
// info strings keep the two subtrees cryptographically disjoint.
const seed = await v . bip39ToSeed ( String ( seedSource . mnemonic ) ) ;
purposeRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "passwords/0" ) ) ;
messengerRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "messenger/0" ) ) ;
} else {
// Independent random seed — 32 bytes of purposeRoot directly. No mnemonic
// means no messenger root; Hermes will fall back to its own mnemonic entry.
const root = require ( "node:crypto" ) . webcrypto . getRandomValues ( new Uint8Array ( 32 ) ) ;
purposeRootHex = v . bytesToHex ( root ) ;
}
vaultState = await v . createVault ( vaultFile ( ) , masterPassword , purposeRootHex ,
messengerRootHex ? { messengerRootHex } : { } ) ;
emitPwAvailability ( ) ;
return vaultOk ( ) ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-unlock" , async ( _e , masterPassword ) => {
try {
if ( ! fs . existsSync ( vaultFile ( ) ) ) return vaultErr ( "no vault" ) ;
const v = await loadVaultLib ( ) ;
vaultState = await v . unlockVault ( vaultFile ( ) , masterPassword ) ;
// Same master password unlocks wallet-imports.enc when it exists. A
// mismatched password wouldn't get us here (the primary decrypt would
// have thrown), so this second decrypt is guaranteed to succeed with
// the same input — differ only in the salt.
importsState = null ;
if ( fs . existsSync ( importsFile ( ) ) ) {
try { importsState = await v . unlockImports ( importsFile ( ) , masterPassword ) ; }
catch ( ie ) { console . error ( "[imports] unlock failed:" , ie ? . message ) ; }
}
importsUnlockPw = masterPassword ;
emitPwAvailability ( ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-lock" , ( ) => {
vaultState = null ;
importsState = null ;
importsUnlockPw = null ;
emitPwAvailability ( ) ;
return true ;
} ) ;
ipcMain . handle ( "password-list" , async ( ) => {
if ( ! vaultState ) return { ok : false , err : "locked" } ;
const v = await loadVaultLib ( ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} ) ;
ipcMain . handle ( "password-get" , async ( _e , id ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const password = await v . resolvePassword ( vaultState , id ) ;
return { ok : true , password } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-add" , async ( _e , spec ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const entry = v . newEntry ( spec || { } ) ;
vaultState . entries . push ( entry ) ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , id : entry . id , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-update" , async ( _e , id , patch ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const e = vaultState . entries . find ( ( x ) => x . id === id ) ;
if ( ! e ) return vaultErr ( "no such entry" ) ;
// Whitelist mutable fields; never let the renderer overwrite id/addedAt.
for ( const k of [ "domain" , "username" , "literal" , "generated" ] ) if ( patch && k in patch ) e [ k ] = patch [ k ] ;
// Switching between literal and generated: drop the other field.
if ( patch && "literal" in patch ) delete e . generated ;
if ( patch && "generated" in patch ) delete e . literal ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-remove" , async ( _e , id ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
vaultState . entries = vaultState . entries . filter ( ( x ) => x . id !== id ) ;
await v . saveVault ( vaultFile ( ) , vaultState ) ;
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "password-generate" , async ( _e , { domain , username = "" , version = 1 , rules } = { } ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const password = await v . derivePassword ( vaultState . purposeRoot , { domain , username , version , rules } ) ;
return { ok : true , password } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
// ---- wallet imports (DESIGN-wallet-multi-account-amendment.md §3.2/§3.3) ---
// Read-only listing — safe for any renderer, does not leak seeds/WIFs.
ipcMain . handle ( "wallet-imports-list" , async ( ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
const v = await loadVaultLib ( ) ;
const entries = importsState ? v . listImportsMetadata ( importsState ) : [ ] ;
return { ok : true , entries } ;
} ) ;
// Add an import. Add-ons (Aegis) call this via api.vault.imports.add(spec).
// The seed/WIF stay in main-process memory — never re-emitted to renderers.
// The caller is expected to have already derived cashaddr client-side; we
// store it verbatim, and Aegis's safety-net check re-derives on load and
// warns on mismatch (design §6).
ipcMain . handle ( "wallet-imports-add" , async ( _e , spec ) => {
if ( ! vaultState ) return vaultErr ( "locked" ) ;
if ( ! importsUnlockPw ) return vaultErr ( "locked" ) ;
try {
if ( ! spec || typeof spec !== "object" ) throw new Error ( "spec required" ) ;
const kind = String ( spec . kind || "" ) ;
if ( kind !== "seed" && kind !== "wif" ) throw new Error ( ` unknown kind: ${ kind } ` ) ;
const cashaddr = String ( spec . cashaddr || "" ) . trim ( ) ;
if ( ! cashaddr ) throw new Error ( "cashaddr required (caller derives)" ) ;
const label = String ( spec . label || "" ) . trim ( ) . slice ( 0 , 120 ) ;
if ( ! label ) throw new Error ( "label required" ) ;
const category = String ( spec . category || "" ) . trim ( ) . slice ( 0 , 40 ) || "operational" ;
const source = String ( spec . source || "" ) . trim ( ) . slice ( 0 , 500 ) ;
const v = await loadVaultLib ( ) ;
if ( ! importsState ) {
importsState = await v . createImports ( importsFile ( ) , importsUnlockPw ) ;
}
// Choose a URL-safe id: user-provided or derived from the label. Collision-
// safe: append a short suffix if it already exists.
const rawId = String ( spec . id || label ) . toLowerCase ( ) . replace ( /[^a-z0-9]+/g , "-" ) . replace ( /^-+|-+$/g , "" ) . slice ( 0 , 60 ) || "wallet" ;
let id = rawId , n = 1 ;
while ( importsState . accounts [ id ] ) { n ++ ; id = ` ${ rawId } - ${ n } ` ; }
const rec = {
kind , cashaddr , label , category , source ,
createdAt : Date . now ( ) ,
} ;
if ( kind === "seed" ) {
if ( ! spec . seed || ! spec . path ) throw new Error ( "seed and path required for kind=seed" ) ;
rec . seed = String ( spec . seed ) ;
rec . path = String ( spec . path ) ;
} else {
if ( ! spec . wif ) throw new Error ( "wif required for kind=wif" ) ;
rec . wif = String ( spec . wif ) ;
}
importsState . accounts [ id ] = rec ;
await v . saveImports ( importsFile ( ) , importsState ) ;
return { ok : true , id , entries : v . listImportsMetadata ( importsState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "wallet-imports-remove" , async ( _e , id ) => {
if ( ! vaultState || ! importsState ) return vaultErr ( "locked" ) ;
try {
if ( ! importsState . accounts [ id ] ) return vaultErr ( "no such import" ) ;
delete importsState . accounts [ id ] ;
const v = await loadVaultLib ( ) ;
await v . saveImports ( importsFile ( ) , importsState ) ;
return { ok : true , entries : v . listImportsMetadata ( importsState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
// Signer material for one import — only for add-ons that already have
// vault-derive-equivalent trust. NEVER called from a page renderer directly;
// gated by addon-msg the same way api.vault.derive is.
ipcMain . handle ( "wallet-imports-signer" , async ( _e , id ) => {
if ( ! vaultState || ! importsState ) return vaultErr ( "locked" ) ;
try {
const v = await loadVaultLib ( ) ;
const signer = v . getImportSigner ( importsState , id ) ;
return { ok : true , signer } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
ipcMain . handle ( "clear-browsing-data" , async ( _e , opts ) => {
const o = opts || { } ;
await clearBrowsingData ( { cookies : ! ! o . cookies , cache : ! ! o . cache , storage : ! ! o . storage } ) ;
if ( o . history ) await clearHistoryNow ( ) ;
return true ;
} ) ;
ipcMain . handle ( "search-engines" , ( ) => ( { engines : allEngines ( ) , current : settings . searchEngine } ) ) ;
ipcMain . handle ( "set-search-engine" , ( _e , id ) => {
if ( allEngines ( ) . some ( ( e ) => e . id === id ) ) { settings . searchEngine = id ; saveSettings ( ) ; emitEngines ( ) ; }
return settings . searchEngine ;
} ) ;
ipcMain . handle ( "add-engine" , ( _e , eng ) => {
// A custom engine needs a name and a URL template containing "%s". Adding
// installs it in both the settings list AND the toolbar dropdown.
if ( eng && eng . name && eng . url && String ( eng . url ) . includes ( "%s" ) ) {
const id = "custom-" + Date . now ( ) . toString ( 36 ) ;
settings . customEngines = [ ... ( settings . customEngines || [ ] ) ,
{ id , name : String ( eng . name ) . slice ( 0 , 40 ) , sym : String ( eng . sym || "🔍" ) . slice ( 0 , 4 ) , url : String ( eng . url ) . slice ( 0 , 400 ) } ] ;
// Custom engines are auto-installed and enabled.
settings . enabledEngines = [ ... new Set ( [ ... ( settings . enabledEngines || DEFAULT _ENABLED ) , id ] ) ] ;
settings . searchEngine = id ; // select the one just added
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
ipcMain . handle ( "remove-engine" , ( _e , id ) => {
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
settings . customEngines = ( settings . customEngines || [ ] ) . filter ( ( e ) => e . id !== id ) ;
settings . enabledEngines = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( settings . searchEngine === id ) settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
// Right-click "Remove from list": drops a built-in from installedEngines AND
// enabledEngines so it goes back to the catalog. For custom engines this
// aliases to remove-engine (they don't live in installedEngines).
ipcMain . handle ( "remove-from-list" , ( _e , id ) => {
if ( ( settings . customEngines || [ ] ) . some ( ( e ) => e . id === id ) ) {
settings . customEngines = ( settings . customEngines || [ ] ) . filter ( ( e ) => e . id !== id ) ;
} else if ( SEARCH _ENGINES [ id ] ) {
settings . installedEngines = ( settings . installedEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
} else {
return { engines : allEngines ( ) , current : settings . searchEngine } ;
}
settings . enabledEngines = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( settings . enabledEngines . length === 0 ) settings . enabledEngines = [ "duckduckgo" ] ; // never empty
if ( settings . searchEngine === id ) settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
// Enable/disable a built-in engine. Enabling from the catalog also INSTALLS it
// (adds to installedEngines). Disabling only removes it from enabledEngines —
// it stays in installedEngines so the row remains visible with the toggle off.
ipcMain . handle ( "set-engine-enabled" , ( _e , id , on ) => {
if ( SEARCH _ENGINES [ id ] ) {
let installed = ( settings . installedEngines || DEFAULT _ENABLED ) . slice ( ) ;
let enabled = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( on ) {
if ( ! installed . includes ( id ) ) installed . push ( id ) ;
enabled . push ( id ) ;
}
settings . installedEngines = installed ;
settings . enabledEngines = enabled . length ? enabled : [ "duckduckgo" ] ; // never empty
if ( ! enabledEnginesList ( ) . some ( ( e ) => e . id === settings . searchEngine ) )
settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
ipcMain . handle ( "set-engine-order" , ( _e , ids ) => {
if ( Array . isArray ( ids ) ) { settings . engineOrder = ids . filter ( ( x ) => typeof x === "string" ) ; saveSettings ( ) ; emitEngines ( ) ; }
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
// The custom dropdown overlay (real favicons).
ipcMain . handle ( "toggle-engine-picker" , ( _e , rect ) => {
if ( epVisible ) return showEnginePicker ( false ) ;
if ( rect ) epPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showEnginePicker ( true ) ;
} ) ;
ipcMain . handle ( "close-engine-picker" , ( ) => showEnginePicker ( false ) ) ;
ipcMain . handle ( "ep-resize" , ( _e , h ) => { epH = Math . max ( 80 , Math . min ( 440 , Math . round ( h ) || 320 ) ) ; if ( epVisible ) positionEnginePicker ( ) ; } ) ;
ipcMain . handle ( "pick-engine" , ( _e , id ) => {
if ( enabledEnginesList ( ) . some ( ( e ) => e . id === id ) ) { settings . searchEngine = id ; saveSettings ( ) ; emitEngines ( ) ; }
showEnginePicker ( false ) ;
} ) ;
ipcMain . handle ( "picker-open-settings" , ( ) => {
showEnginePicker ( false ) ;
const focus = ( t ) => { try { t . view . webContents . send ( "focus-section" , "search" ) ; } catch { } } ;
const ex = tabs . find ( ( t ) => t . settings ) ;
if ( ex ) { setActive ( ex . id ) ; focus ( ex ) ; return ; }
const id = createTab ( null , { settings : true } ) ;
const t = tabById ( id ) ;
if ( t ) t . view . webContents . once ( "did-finish-load" , ( ) => focus ( t ) ) ;
} ) ;
// ---- Downloads --------------------------------------------------------------
ipcMain . handle ( "downloads-get" , ( ) => downloadsPublic ( ) ) ;
ipcMain . handle ( "toggle-downloads" , ( _e , rect ) => {
if ( dlVisible ) return showDownloads ( false ) ;
if ( rect ) dlPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showDownloads ( true ) ;
} ) ;
ipcMain . handle ( "close-downloads" , ( ) => showDownloads ( false ) ) ;
ipcMain . handle ( "downloads-resize" , ( _e , h ) => { dlH = Math . max ( 80 , Math . min ( 480 , Math . round ( h ) || 240 ) ) ; if ( dlVisible ) positionDownloads ( ) ; } ) ;
// Address-bar suggestions: show/hide, forward arrow-keys to the dropdown.
ipcMain . handle ( "suggest-address" , ( _e , query , rect ) => {
const suggestions = historySearch ( query ) ;
if ( ! suggestions . length ) { showAddressPicker ( false ) ; return ; }
if ( rect ) { apPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ; apW = Math . max ( 280 , Math . round ( rect . w || 520 ) ) ; }
showAddressPicker ( true , suggestions ) ;
} ) ;
ipcMain . handle ( "close-address-picker" , ( ) => showAddressPicker ( false ) ) ;
ipcMain . handle ( "address-picker-resize" , ( _e , h ) => {
apH = Math . max ( 40 , Math . min ( 400 , Math . round ( h ) || 60 ) ) ;
if ( apVisible ) positionAddressPicker ( ) ;
} ) ;
// Right-side X on a picker row: drop that URL from history without
// navigating. Sender-URL-gated to our own address-picker.html.
ipcMain . handle ( "address-forget" , ( e , url ) => {
try { const u = e . sender . getURL ( ) || "" ; if ( ! /address-picker\.html/i . test ( u ) ) return false ; } catch { return false ; }
if ( typeof url !== "string" || ! url ) return false ;
const before = history . length ;
history = history . filter ( ( h ) => h . url !== url ) ;
if ( history . length === before ) return false ;
saveHistoryDebounced ( ) ;
// Re-run the current query so the picker rerenders without the removed row.
return true ;
} ) ;
ipcMain . handle ( "address-pick" , ( _e , url ) => {
showAddressPicker ( false ) ;
if ( ! url ) return ;
const u = String ( url ) ;
// Push the picked URL to the chrome renderer directly so the address bar
// shows the full URL immediately. The tabs event's focus guard
// (document.activeElement !== $("url"))
// skips its value overwrite while the URL input still has DOM focus, and
// clicking a WebContentsView sibling doesn't always deliver the blur to
// the chrome renderer in time — user was left staring at their 3-letter
// typed query while the picked URL loaded behind it.
try { chrome ? . webContents . send ( "address-picked" , u ) ; } catch { }
navigateTab ( activeId , u ) ;
} ) ;
// Password fill — chip in the toolbar opens a picker of matching credentials
// for the current site. Clicking a match injects the fill script into the
// active tab. Whole flow is user-initiated; no page-load DOM watchers yet.
ipcMain . handle ( "toggle-pw-fill" , async ( _e , rect ) => {
if ( pwfVisible ) return showPwFill ( false ) ;
const t = activeTab ( ) ; const host = t ? . prov ? . host || "" ;
const matches = pwMatchesForHost ( host ) ;
if ( ! matches . length ) return showPwFill ( false ) ;
if ( rect ) pwfPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showPwFill ( true , matches ) ;
} ) ;
ipcMain . handle ( "close-pw-fill" , ( ) => showPwFill ( false ) ) ;
ipcMain . handle ( "link-status-resize" , ( _e , w , h ) => {
linkStatusW = Math . max ( 60 , Math . min ( 2000 , Math . round ( w ) || 100 ) ) ;
linkStatusH = Math . max ( 20 , Math . min ( 60 , Math . round ( h ) || 22 ) ) ;
if ( linkStatusVisible ) positionLinkStatus ( ) ;
} ) ;
ipcMain . handle ( "pw-fill-resize" , ( _e , h ) => {
pwfH = Math . max ( 60 , Math . min ( 300 , Math . round ( h ) || 80 ) ) ;
if ( pwfVisible ) positionPwFill ( ) ;
} ) ;
ipcMain . handle ( "pw-fill-pick" , async ( _e , id ) => {
showPwFill ( false ) ;
if ( ! vaultState ) return { ok : false , err : "locked" } ;
try {
const v = await loadVaultLib ( ) ;
const entry = vaultState . entries . find ( ( x ) => x . id === id ) ;
if ( ! entry ) return { ok : false , err : "no such entry" } ;
const password = await v . resolvePassword ( vaultState , id ) ;
return await pwFillIntoActiveTab ( { username : entry . username , password } ) ;
} catch ( e ) { return { ok : false , err : e ? . message || String ( e ) } ; }
} ) ;
// The chrome sends arrow-up/down/enter through so the picker can move its
// selection cursor without stealing focus from the address input.
ipcMain . handle ( "address-cursor" , ( _e , dir ) => {
if ( apVisible && addressPicker ) try { addressPicker . webContents . send ( "address-cursor" , dir ) ; } catch { }
} ) ;
ipcMain . handle ( "download-open" , ( _e , id ) => {
const d = downloads . find ( ( x ) => x . id === id ) ;
if ( d ? . state === "completed" && d . savePath ) shell . openPath ( d . savePath ) . catch ( ( ) => { } ) ;
} ) ;
ipcMain . handle ( "download-show" , ( _e , id ) => {
const d = downloads . find ( ( x ) => x . id === id ) ;
if ( d ? . savePath ) { try { shell . showItemInFolder ( d . savePath ) ; } catch { } }
} ) ;
ipcMain . handle ( "download-cancel" , ( _e , id ) => {
const item = dlItems . get ( id ) ; if ( item ) { try { item . cancel ( ) ; } catch { } }
} ) ;
// Only clear finished downloads; a progressing one is cancelled first.
ipcMain . handle ( "download-clear" , ( _e , id ) => {
const i = downloads . findIndex ( ( x ) => x . id === id ) ;
if ( i < 0 ) return ;
if ( downloads [ i ] . state === "progressing" ) { const item = dlItems . get ( id ) ; if ( item ) { try { item . cancel ( ) ; } catch { } } }
downloads . splice ( i , 1 ) ; dlItems . delete ( id ) ;
emitDownloads ( ) ;
} ) ;
ipcMain . handle ( "downloads-clear-all" , ( ) => {
// Keep any still-progressing ones; drop everything else.
for ( let i = downloads . length - 1 ; i >= 0 ; i -- ) if ( downloads [ i ] . state !== "progressing" ) downloads . splice ( i , 1 ) ;
emitDownloads ( ) ;
} ) ;
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain . handle ( "add-detected-engine" , ( ) => {
const d = activeTab ( ) ? . detected ;
if ( d && d . url && d . url . includes ( "%s" ) ) {
const id = "custom-" + Date . now ( ) . toString ( 36 ) ;
settings . customEngines = [ ... ( settings . customEngines || [ ] ) , { id , name : d . name . slice ( 0 , 40 ) , sym : "🔍" , url : d . url . slice ( 0 , 400 ) } ] ;
settings . searchEngine = id ;
saveSettings ( ) ; emitEngines ( ) ;
}
showEnginePicker ( false ) ;
} ) ;
ipcMain . handle ( "bookmarks-get" , ( ) => bookmarks ) ;
ipcMain . handle ( "bookmark-add" , ( _e , bm ) => {
if ( bm && bm . url && ! bookmarks . some ( ( b ) => b . url === bm . url ) ) {
const entry = { title : bm . title || bm . url , url : bm . url } ;
if ( bm . favicon ) entry . favicon = String ( bm . favicon ) . slice ( 0 , 2048 ) ;
bookmarks . push ( entry ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
}
return bookmarks ;
} ) ;
// Update fields on an existing bookmark, identified by URL. Used by the
// inline title editor (window.prompt is disabled in Electron BrowserViews,
// so the renderer builds its own modal and calls this). Merges partial
// updates — omitted fields stay as they are, and blank strings are
// rejected for title so a bad edit can't wipe the label.
ipcMain . handle ( "bookmark-update" , ( _e , url , patch ) => {
if ( typeof url !== "string" || ! url || ! patch || typeof patch !== "object" ) return bookmarks ;
const bm = bookmarks . find ( ( b ) => b . url === url ) ;
if ( ! bm ) return bookmarks ;
if ( typeof patch . title === "string" && patch . title . trim ( ) ) bm . title = patch . title . trim ( ) . slice ( 0 , 200 ) ;
if ( typeof patch . favicon === "string" && patch . favicon ) bm . favicon = patch . favicon . slice ( 0 , 2048 ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return bookmarks ;
} ) ;
ipcMain . handle ( "bookmark-remove" , ( _e , url ) => {
bookmarks = bookmarks . filter ( ( b ) => b . url !== url ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return bookmarks ;
} ) ;
// Reorder: pull `fromUrl` out of the list and reinsert it before or after
// `targetUrl`. Renderer picks the side by which half of the target chip the
// pointer is on, same convention the tab strip uses. A missing entry or a
// self-drop is a no-op, so noisy drag events don't corrupt the list.
ipcMain . handle ( "bookmark-move" , ( _e , fromUrl , targetUrl , place ) => {
if ( typeof fromUrl !== "string" || typeof targetUrl !== "string" || fromUrl === targetUrl ) return bookmarks ;
const from = bookmarks . findIndex ( ( b ) => b . url === fromUrl ) ;
if ( from < 0 ) return bookmarks ;
const [ moved ] = bookmarks . splice ( from , 1 ) ;
let to = bookmarks . findIndex ( ( b ) => b . url === targetUrl ) ;
if ( to < 0 ) { bookmarks . splice ( from , 0 , moved ) ; return bookmarks ; }
if ( place === "after" ) to += 1 ;
bookmarks . splice ( to , 0 , moved ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return bookmarks ;
} ) ;
ipcMain . handle ( "settings-get" , ( ) => settings ) ;
ipcMain . handle ( "settings-set" , ( _e , key , val ) => {
if ( key in SETTINGS _DEFAULTS ) { settings [ key ] = val ; saveSettings ( ) ; }
if ( key === "webrtcMode" ) applyWebRTCPolicy ( ) ;
if ( key === "theme" ) applyTheme ( ) ;
if ( key === "backgroundThrottle" ) applyThrottle ( ) ;
if ( [ "timezoneMode" , "timezoneValue" , "languageMode" , "languageSpoof" , "languageValue" ,
"locationMode" , "locationRegion" , "locationLat" , "locationLon" , "hideMediaDevices" ] . includes ( key ) ) { applyFingerprintAll ( ) ; applyAcceptLanguage ( ) ; }
// Push updates chrome-side reactively so toolbar-size changes (urlBarSize,
// searchBoxSize) take effect without a relaunch. Whole settings object
// fits in one message and chrome only cares about a handful of fields.
try { chrome ? . webContents . send ( "settings-update" , settings ) ; } catch { }
return settings ;
} ) ;
ipcMain . handle ( "set-chrome-height" , ( _e , h ) => {
const next = Math . max ( 74 , Math . min ( 260 , Math . round ( h ) || 84 ) ) ;
if ( next !== CHROME _H ) { CHROME _H = next ; layout ( ) ; }
} ) ;
ipcMain . handle ( "switch-to-bcnr" , ( ) => {
const t = activeTab ( ) ; if ( ! t || ! t . bcnrOffer ) return ;
const { host , rest , tld } = t . bcnrOffer ;
t . bcnrOffer = null ;
chrome . webContents . send ( "bcnr-offer" , null ) ;
return loadBns ( t , activeId , host , rest || "/" , tld ) ;
} ) ;
// ---- Hermes messages panel -------------------------------------------------
// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses
// the wallet mnemonic to derive the Nostr identity in-memory only — the seed
// and secret key are never written to disk. The panel process holds one
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
// and opens a per-send WebSocket for publishes.
const HERMES _DEFAULT _RELAYS = [ "wss://nos.lol" ] ;
const HERMES _INBOX _LIMIT = 200 ;
let hermesWin = null ;
// State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map<url, { ws, ready }> }
// skHex is held instead of the raw Uint8Array so it can be Buffer-restored per operation
// (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about).
let hermesState = null ;
const hOk = ( o = { } ) => ( { ok : true , ... o } ) ;
const hErr = ( e ) => ( { ok : false , err : String ( ( e && e . message ) || e ) } ) ;
function hermesEmit ( channel , payload ) {
if ( hermesWin && ! hermesWin . isDestroyed ( ) ) hermesWin . webContents . send ( channel , payload ) ;
}
function hermesRecord ( msg ) {
hermesState . inbox . push ( msg ) ;
if ( hermesState . inbox . length > HERMES _INBOX _LIMIT ) {
hermesState . inbox . splice ( 0 , hermesState . inbox . length - HERMES _INBOX _LIMIT ) ;
}
hermesEmit ( "hermes-message" , msg ) ;
}
// Pushed on every relay connect/disconnect so the pill in the panel reflects
// reality without polling. Cheap; sent to the renderer whenever a socket
// transitions ready/not-ready.
function hermesEmitStatus ( ) {
if ( ! hermesState ) return ;
let connected = 0 ;
for ( const s of hermesState . relays . values ( ) ) if ( s . ready ) connected ++ ;
hermesEmit ( "hermes-status-update" , {
relaysConnected : connected ,
relaysTotal : hermesState . relays . size ,
} ) ;
}
// Reverse-resolve a pkHex → .bch name by scanning the (cached) BNS index.
// Populates senderName in the inbox so incoming DMs render as
// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState
// (dropped on hermes-close) so a re-init starts clean.
async function hermesReverseResolve ( pkHex ) {
if ( ! hermesState ) return null ;
if ( ! hermesState . _pkToName ) hermesState . _pkToName = new Map ( ) ;
if ( hermesState . _pkToName . has ( pkHex ) ) return hermesState . _pkToName . get ( pkHex ) ;
try {
const R = await getResolver ( ) ;
const H = await loadHermesLib ( ) ;
const idx = await R . buildIndex ( { WebSocket } ) ;
for ( const [ name , entry ] of idx ) {
const raw = entry && entry . records && entry . records . np ;
if ( typeof raw !== "string" || ! raw . trim ( ) ) continue ;
try {
const hex = H . parseNpRecord ( raw ) ;
if ( hex === pkHex ) {
hermesState . _pkToName . set ( pkHex , name ) ;
return name ;
}
} catch { /* malformed np — skip */ }
}
} catch { /* chain unreachable — leave unresolved this round */ }
hermesState . _pkToName . set ( pkHex , null ) ; // negative-cache so we don't re-scan every message
return null ;
}
// One receive subscription per relay. If the socket dies we resurrect it on a
// backoff — a locked / logged-out state tears them all down cleanly.
async function hermesConnectRelay ( url ) {
const H = await loadHermesLib ( ) ;
const state = { ws : null , ready : false , subId : "hermes-inbox" } ;
const open = ( ) => {
if ( ! hermesState ) return ; // torn down while reconnecting
const ws = new WebSocket ( url ) ;
state . ws = ws ;
ws . on ( "open" , ( ) => {
state . ready = true ;
hermesEmitStatus ( ) ;
ws . send ( JSON . stringify ( [ "REQ" , state . subId , { kinds : [ 1059 ] , "#p" : [ hermesState . pkHex ] } ] ) ) ;
} ) ;
ws . on ( "message" , async ( buf ) => {
let msg ; try { msg = JSON . parse ( buf . toString ( ) ) ; } catch { return ; }
if ( msg [ 0 ] !== "EVENT" || msg [ 1 ] !== state . subId ) return ;
try {
const sk = Buffer . from ( hermesState . skHex , "hex" ) ;
const opened = H . unwrapChat ( { receiverSk : sk , wrap : msg [ 2 ] } ) ;
// Dedupe: a wrap arriving from multiple relays produces the same rumor id
// (kind:14 hash), but since we don't expose the rumor id here we dedupe
// on (senderPkHex, text, createdAt) which is sufficient for MVP.
const key = opened . senderPkHex + "\0" + opened . createdAt + "\0" + opened . text ;
if ( hermesState . _seen && hermesState . _seen . has ( key ) ) return ;
hermesState . _seen && hermesState . _seen . add ( key ) ;
// Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget
// wouldn't work — we need the name in the record we push). Await here;
// the cache short-circuits after the first miss per pk.
const senderName = await hermesReverseResolve ( opened . senderPkHex ) ;
hermesRecord ( {
senderPkHex : opened . senderPkHex ,
senderName ,
text : opened . text ,
createdAt : opened . createdAt ,
} ) ;
} catch { /* unwrap failure = not for us, or malformed — drop silently */ }
} ) ;
ws . on ( "close" , ( ) => {
state . ready = false ;
hermesEmitStatus ( ) ;
// Attempt reconnect if we're still supposed to be running.
if ( hermesState && hermesState . relays . get ( url ) === state ) {
setTimeout ( open , 3000 ) ;
}
} ) ;
ws . on ( "error" , ( ) => { /* close handler will retry */ } ) ;
} ;
open ( ) ;
return state ;
}
function hermesTeardown ( ) {
if ( ! hermesState ) return ;
for ( const state of hermesState . relays . values ( ) ) {
try { state . ws ? . close ( 1000 ) ; } catch { }
}
hermesState = null ;
}
ipcMain . handle ( "hermes-status" , ( ) => {
if ( ! hermesState ) return hOk ( { ready : false } ) ;
let connected = 0 ;
for ( const s of hermesState . relays . values ( ) ) if ( s . ready ) connected ++ ;
return hOk ( {
ready : true ,
npub : hermesState . npub ,
pkHex : hermesState . pkHex ,
relaysConnected : connected ,
relaysTotal : hermesState . relays . size ,
} ) ;
} ) ;
// Init modes:
// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user)
// { useVault: true } — reuse the password vault's messenger root; no re-entry
// required. Available iff vault is unlocked AND was set
// up from a mnemonic (so messengerRoot was persisted).
ipcMain . handle ( "hermes-init" , async ( _e , opts = { } ) => {
try {
hermesTeardown ( ) ;
const H = await loadHermesLib ( ) ;
let sk , pkHex , npub ;
if ( opts . useVault ) {
if ( ! vaultState || ! vaultState . messengerRoot ) {
return hErr ( "password vault is locked or was set up without a mnemonic" ) ;
}
( { sk , pkHex , npub } = H . nostrKeyFromRoot ( vaultState . messengerRoot ) ) ;
} else {
const mnemonic = opts . mnemonic ;
if ( ! mnemonic || typeof mnemonic !== "string" || mnemonic . trim ( ) . split ( /\s+/ ) . length < 12 ) {
return hErr ( "enter a 12- or 24-word mnemonic" ) ;
}
( { sk , pkHex , npub } = await H . nostrKeyFromMnemonic ( mnemonic . trim ( ) ) ) ;
}
hermesState = {
skHex : Buffer . from ( sk ) . toString ( "hex" ) ,
pkHex , npub ,
inbox : [ ] ,
relays : new Map ( ) ,
_seen : new Set ( ) ,
} ;
for ( const url of HERMES _DEFAULT _RELAYS ) {
hermesState . relays . set ( url , await hermesConnectRelay ( url ) ) ;
}
// Give sockets a beat to open before reporting connected count.
await new Promise ( ( r ) => setTimeout ( r , 400 ) ) ;
let connected = 0 ;
for ( const s of hermesState . relays . values ( ) ) if ( s . ready ) connected ++ ;
return hOk ( { npub , pkHex , source : opts . useVault ? "vault" : "mnemonic" ,
relaysConnected : connected , relaysTotal : hermesState . relays . size } ) ;
} catch ( e ) { hermesTeardown ( ) ; return hErr ( e ) ; }
} ) ;
// Cheap query: "is the vault-bound sign-in path available right now?" Panel
// uses this to decide whether to show the 'Use password vault' button.
ipcMain . handle ( "hermes-can-use-vault" , ( ) => hOk ( {
available : ! ! ( vaultState && vaultState . messengerRoot ) ,
} ) ) ;
ipcMain . handle ( "hermes-close" , ( ) => { hermesTeardown ( ) ; return hOk ( ) ; } ) ;
ipcMain . handle ( "hermes-inbox" , ( ) => {
if ( ! hermesState ) return hErr ( "not initialised" ) ;
return hOk ( { messages : hermesState . inbox . slice ( ) } ) ;
} ) ;
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
// via the portable resolver (getResolver above), the `np` record parsed, then
// wrapped + published to each relay listed in `nr` (falling back to defaults).
ipcMain . handle ( "hermes-send" , async ( _e , { to , text } = { } ) => {
if ( ! hermesState ) return hErr ( "not initialised" ) ;
if ( ! to || ! text ) return hErr ( "to and text required" ) ;
try {
const H = await loadHermesLib ( ) ;
let recipientPkHex ; let relays = HERMES _DEFAULT _RELAYS . slice ( ) ;
const trimmed = String ( to ) . trim ( ) ;
if ( /^[0-9a-f]{64}$/i . test ( trimmed ) ) {
recipientPkHex = trimmed . toLowerCase ( ) ;
} else if ( trimmed . startsWith ( "npub1" ) ) {
recipientPkHex = H . parseNpRecord ( trimmed ) ;
} else {
const R = await getResolver ( ) ;
const name = R . normalizeName ( trimmed ) ;
const entry = await R . resolveName ( name , { WebSocket } ) ;
if ( ! entry ) return hErr ( ` no on-chain registration for ${ name } ` ) ;
const parsed = H . parseHermesRecords ( entry , { defaultRelays : HERMES _DEFAULT _RELAYS } ) ;
recipientPkHex = parsed . npPk ;
relays = parsed . relays ;
}
const sk = Buffer . from ( hermesState . skHex , "hex" ) ;
const wrap = H . wrapChat ( { senderSk : sk , recipientPkHex , text } ) ;
const publishOne = ( url ) => new Promise ( ( resolve ) => {
const ws = new WebSocket ( url ) ;
let settled = false ;
const done = ( r ) => { if ( settled ) return ; settled = true ; try { ws . close ( 1000 ) ; } catch { } resolve ( r ) ; } ;
const t = setTimeout ( ( ) => done ( { url , ok : false , detail : "timeout" } ) , 8000 ) ;
ws . on ( "open" , ( ) => ws . send ( JSON . stringify ( [ "EVENT" , wrap ] ) ) ) ;
ws . on ( "message" , ( buf ) => {
let msg ; try { msg = JSON . parse ( buf . toString ( ) ) ; } catch { return ; }
if ( msg [ 0 ] === "OK" && msg [ 1 ] === wrap . id ) {
clearTimeout ( t ) ;
done ( { url , ok : ! ! msg [ 2 ] , detail : msg [ 3 ] || "" } ) ;
}
} ) ;
ws . on ( "error" , ( e ) => done ( { url , ok : false , detail : "ws error: " + e . message } ) ) ;
} ) ;
const results = await Promise . all ( relays . map ( publishOne ) ) ;
const accepted = results . filter ( ( r ) => r . ok ) . length ;
return hOk ( { recipientPkHex , accepted , total : results . length , results } ) ;
} catch ( e ) { return hErr ( e ) ; }
} ) ;
// ---- "Open link in new window" ----
// A standalone page window: same session (cookies, the bns:// protocol, the
// session-wide bcnr preload), Theseus's fingerprint + WebRTC policy, no
// toolbar. Loads BCNR-first like a tab does: a dotted host with a BCNR
// record goes over bns://, anything else over clearnet. Collision names
// follow the configured policy without the "Open with…" interstitial.
// Add-on page bridges (the wallet inject) are tab-scoped and don't run here.
const linkWindows = new Set ( ) ;
async function targetUrlFor ( input ) {
let u ;
try { u = new URL ( String ( input ) . includes ( "://" ) ? String ( input ) : "https://" + String ( input ) ) ; } catch { return null ; }
if ( u . protocol !== "http:" && u . protocol !== "https:" ) return u . href ;
const host = u . hostname . toLowerCase ( ) ;
if ( ! isBnsHost ( host ) ) return u . href ;
let entry = null ;
try { entry = await resolveHost ( host ) ; } catch { }
if ( ! entry ) return u . href ;
const policy = settings . collisionPolicy || "bcnr-first" ;
if ( ! isBcnrNativeTld ( tldOf ( host ) ) && policy === "icann-first" ) return u . href ;
return ` bns:// ${ host } ${ u . pathname } ${ u . search } ${ u . hash } ` ;
}
async function openLinkWindow ( input ) {
const target = await targetUrlFor ( input ) ;
if ( ! target ) return null ;
const w = new BrowserWindow ( {
width : 1100 , height : 760 , title : "Theseus Navigator" ,
backgroundColor : nativeTheme . shouldUseDarkColors ? "#0b0e14" : "#ffffff" ,
icon : app . isPackaged ? path . join ( process . resourcesPath , "icon.ico" ) : path . join ( _ _dirname , "build" , "icon.ico" ) ,
webPreferences : { contextIsolation : true , nodeIntegration : false , sandbox : true } ,
} ) ;
w . setMenuBarVisibility ( false ) ;
const wc = w . webContents ;
styleScrollbars ( wc ) ;
try { wc . setWebRTCIPHandlingPolicy ( webrtcPolicy ( ) ) ; } catch { }
try { wc . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { }
applyFingerprint ( wc ) ;
wc . on ( "page-title-updated" , ( _e , title ) => { try { w . setTitle ( title ? ` ${ title } — Theseus ` : "Theseus Navigator" ) ; } catch { } } ) ;
// Cross-host navigations inside the window stay BCNR-first too. Same-host
// ones go through Chromium untouched (form POSTs must survive).
wc . on ( "will-navigate" , ( e , u ) => {
try {
const p = new URL ( u ) ;
if ( p . protocol !== "http:" && p . protocol !== "https:" ) return ;
if ( ! isBnsHost ( p . hostname ) ) return ;
let cur = "" ; try { cur = new URL ( wc . getURL ( ) ) . hostname ; } catch { }
if ( cur === p . hostname ) return ;
e . preventDefault ( ) ;
targetUrlFor ( u ) . then ( ( t ) => { if ( t ) wc . loadURL ( t ) . catch ( ( ) => { } ) ; } ) ;
} catch { }
} ) ;
// Popups from a page here go to the main window's tabs when it exists —
// one place for tabs — otherwise to another plain window.
wc . setWindowOpenHandler ( ( { url } ) => {
if ( url && url !== "about:blank" ) {
if ( win && ! win . isDestroyed ( ) ) { createTab ( url ) ; try { win . focus ( ) ; } catch { } }
else openLinkWindow ( url ) ;
}
return { action : "deny" } ;
} ) ;
wc . on ( "context-menu" , ( _e , p ) => {
const items = [ ] ;
const haveMain = ! ! win && ! win . isDestroyed ( ) ;
if ( p . linkURL ) {
items . push (
{ label : "Open link in new tab" , enabled : haveMain , click : ( ) => { createTab ( p . linkURL ) ; try { win . focus ( ) ; } catch { } } } ,
{ label : "Open link in new window" , click : ( ) => openLinkWindow ( p . linkURL ) } ,
{ label : "Copy link address" , click : ( ) => clipboard . writeText ( p . linkURL ) } ,
{ type : "separator" } ,
) ;
}
if ( p . isEditable ) items . push ( { role : "cut" } , { role : "copy" } , { role : "paste" } , { type : "separator" } ) ;
else if ( p . selectionText ) items . push ( { role : "copy" } , { type : "separator" } ) ;
// Add-on context-menu items (same shape as the main-window handler).
try {
const addonItems = addonHost ? addonHost . getContextMenuItems ( {
selectionText : p . selectionText , linkURL : p . linkURL ,
mediaType : p . mediaType , srcURL : p . srcURL , isEditable : p . isEditable ,
pageURL : p . pageURL ,
} ) : [ ] ;
if ( addonItems . length ) {
for ( const it of addonItems ) {
const label = ( it . icon ? String ( it . icon ) + " " : "" ) + String ( it . label || it . id ) ;
items . push ( { label , click : ( ) => {
const payload = {
itemId : it . id ,
selectionText : p . selectionText || "" ,
linkURL : p . linkURL || "" ,
mediaType : p . mediaType || "" ,
srcURL : p . srcURL || "" ,
pageURL : p . pageURL || ( wc && wc . getURL ( ) ) || "" ,
host : ( ( ) => { try { return new URL ( p . pageURL || wc . getURL ( ) ) . host ; } catch { return "" ; } } ) ( ) ,
} ;
addonHost . dispatch ( it . addonId , "context-menu" , payload , { from : "context-menu" } )
. catch ( ( err ) => console . warn ( ` [addons] context-menu ${ it . addonId } . ${ it . id } failed: ` , err ? . message || err ) ) ;
} } ) ;
}
items . push ( { type : "separator" } ) ;
}
} catch ( err ) { console . warn ( "context-menu addon merge failed:" , err ? . message || err ) ; }
items . push (
{ label : "Back" , enabled : wc . navigationHistory . canGoBack ( ) , click : ( ) => wc . navigationHistory . goBack ( ) } ,
{ label : "Forward" , enabled : wc . navigationHistory . canGoForward ( ) , click : ( ) => wc . navigationHistory . goForward ( ) } ,
{ label : "Reload" , click : ( ) => wc . reload ( ) } ,
) ;
Menu . buildFromTemplate ( items ) . popup ( { window : w } ) ;
} ) ;
linkWindows . add ( w ) ;
w . on ( "closed" , ( ) => linkWindows . delete ( w ) ) ;
wc . loadURL ( target ) . catch ( ( ) => { } ) ;
return w ;
}
function openHermesWindow ( ) {
if ( hermesWin && ! hermesWin . isDestroyed ( ) ) {
hermesWin . focus ( ) ; return ;
}
hermesWin = new BrowserWindow ( {
width : 720 , height : 620 , title : "Messages — Theseus" ,
backgroundColor : "#0b0e14" ,
webPreferences : {
preload : path . join ( _ _dirname , "messages-preload.js" ) ,
contextIsolation : true , nodeIntegration : false ,
} ,
} ) ;
hermesWin . setMenuBarVisibility ( false ) ;
hermesWin . loadFile ( "messages.html" ) ;
hermesWin . on ( "closed" , ( ) => { hermesWin = null ; } ) ;
}
ipcMain . handle ( "hermes-open" , ( ) => { openHermesWindow ( ) ; return { ok : true } ; } ) ;
// --- BCNR provider (window.bcnr) — read-only surface. See
// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus
// already runs; nothing about the user leaks, so no origin/permission gate.
// A malicious page can call these; the worst it learns is what the chain
// says publicly, which it could equally get through Argus. The preload that
// exposes these lives at bcnr-preload.js and is installed session-wide
// inside whenReady below.
//
// B.2b: eTLD+1 origin binding. The read methods below don't need the origin
// — chain data is public — so they don't compute it. Instead pages that
// want to see how Theseus will bucket their permissions can call
// `window.bcnr.getOrigin()` (handler further down). B.3's write methods
// (signMessage/sendPayment/registerName) will call `callerOrigin(event)` at
// entry and check the result against wallet-permissions.json.
const { originOf } = require ( "./bcnr-origin.js" ) ;
function callerOrigin ( event ) {
try { return originOf ( event . sender . getURL ( ) , { bcnrTlds } ) ; }
catch { return null ; }
}
// wallet-permissions.json — per-origin (eTLD+1) grants for B.3's write
// methods. Scaffolded in B.2b so B.3 doesn't have to touch main.js's
// on-disk conventions. Shape is intentionally open — B.3 will define the
// concrete decision values ("always" | "once" | "never", amount caps,
// expiries) as each write method lands.
let walletPermissions = { } ;
const walletPermissionsFile = ( ) => path . join ( app . getPath ( "userData" ) , "wallet-permissions.json" ) ;
function loadWalletPermissions ( ) {
try {
if ( fs . existsSync ( walletPermissionsFile ( ) ) ) {
const raw = JSON . parse ( fs . readFileSync ( walletPermissionsFile ( ) , "utf8" ) ) ;
if ( raw && typeof raw === "object" ) walletPermissions = raw ;
}
} catch ( e ) { console . error ( "wallet-permissions load failed:" , e . message ) ; }
}
function saveWalletPermissions ( ) {
try { fs . writeFileSync ( walletPermissionsFile ( ) , JSON . stringify ( walletPermissions , null , 2 ) ) ; }
catch ( e ) { console . error ( "wallet-permissions save failed:" , e . message ) ; }
}
// B.3 will use these. Kept here so the storage owner is one place.
function getWalletPermission ( origin , method ) {
if ( ! origin || ! method ) return null ;
return walletPermissions [ origin ] ? . [ method ] ? ? null ;
}
function setWalletPermission ( origin , method , value ) {
if ( ! origin || ! method ) return ;
if ( ! walletPermissions [ origin ] ) walletPermissions [ origin ] = { } ;
walletPermissions [ origin ] [ method ] = value ;
saveWalletPermissions ( ) ;
}
void getWalletPermission ; void setWalletPermission ; // silence unused-in-B.2b
function serializeEntry ( entry ) {
if ( ! entry ) return null ;
// Explicit whitelist — records/category/txid/height are the on-chain facts
// the design's `resolveName` promises. `updatedTxid` is included because it
// shifts every UPD and lets `getRecordVersion` distinguish a REG-only entry
// from one that has been updated in place.
return {
name : entry . name ,
category : entry . category ,
records : entry . records ? ? { } ,
txid : entry . txid ,
height : entry . height ,
updatedTxid : entry . updatedTxid ? ? null ,
// Signed DNS records as last fetched: undefined → not known yet (call
// bcnr:dnsRecords to wait for them), null → none published.
dns : entry . dns === undefined ? undefined : entry . dns ,
} ;
}
ipcMain . handle ( "bcnr:resolveName" , async ( _e , name ) => {
if ( typeof name !== "string" || ! name ) return null ;
try { return serializeEntry ( await resolveHost ( name ) ) ; }
catch { return null ; }
} ) ;
// Signed DNS records for a registered name, waiting (≤ 3 s) for the fetch.
// For add-ons that need TXT (verification, cert pins), MX (mail bridges) or
// A/AAAA. Null when the name is unregistered or has no manifest.
ipcMain . handle ( "bcnr:dnsRecords" , async ( _e , name ) => {
if ( typeof name !== "string" || ! name ) return null ;
try {
const entry = await resolveHost ( name ) ;
if ( ! entry ) return null ;
const v = entry . dns !== undefined ? entry . dns : await fetchDnsRecords ( entry . name ) ;
return v ? { name : entry . name , ... v } : null ;
} catch { return null ; }
} ) ;
ipcMain . handle ( "bcnr:isRegistered" , async ( _e , name ) => {
if ( typeof name !== "string" || ! name ) return false ;
try { return ( await resolveHost ( name ) ) != null ; }
catch { return false ; }
} ) ;
ipcMain . handle ( "bcnr:getBcnrTlds" , ( ) => bcnrTlds . slice ( ) ) ;
// Diagnostic — returns the eTLD+1 permission origin Theseus computes for the
// caller. Same value B.3's write methods will gate on. No leak: a page can
// already read its own location.href; this just tells it how Theseus buckets
// its permissions (so a dApp dev can see that pay.foo.bch and blog.foo.bch
// share one grant).
ipcMain . handle ( "bcnr:getOrigin" , ( e ) => callerOrigin ( e ) ) ;
ipcMain . handle ( "bcnr:getRecordVersion" , async ( _e , name ) => {
if ( typeof name !== "string" || ! name ) return null ;
try {
const entry = await resolveHost ( name ) ;
if ( ! entry ) return null ;
// The pair (updatedTxid ?? txid, height) uniquely identifies which reveal
// a dApp is looking at. dApps poll this cheaply and re-fetch records only
// when it changes.
return {
txid : entry . updatedTxid ? ? entry . txid ,
regTxid : entry . txid ,
height : entry . height ,
} ;
} catch { return null ; }
} ) ;
// App-level keyboard shortcuts. One `web-contents-created` hook covers
// every WebContents (chrome, tab views, overlays) without per-view wiring.
// Reload/hard-reload always target the active tab, regardless of which
// view received the key (URL bar focused, overlay focused, etc.), so the
// user's mental model matches every browser they've ever used.
app . on ( "web-contents-created" , ( _event , wc ) => {
wc . on ( "before-input-event" , ( e , input ) => {
if ( input . type !== "keyDown" ) return ;
// Ctrl+Shift+M -> Messages panel
if ( input . control && input . shift && ( input . key === "M" || input . key === "m" ) ) {
openHermesWindow ( ) ;
return e . preventDefault ( ) ;
}
// Ctrl+B -> toggle add-on sidebar (matches the VS Code convention).
// Silently no-ops if no add-on has registered a sidebar panel yet.
if ( input . control && ! input . shift && ! input . alt && ( input . key === "B" || input . key === "b" ) ) {
toggleSidebar ( ) ;
return e . preventDefault ( ) ;
}
// Zoom: Ctrl + / Ctrl = / numpad + zoom in, Ctrl - / numpad - zoom out,
// Ctrl 0 reset. Always targets the active tab, whichever view has focus.
if ( input . control && ! input . alt ) {
const k = input . key , code = input . code ;
if ( k === "+" || k === "=" || code === "NumpadAdd" ) { zoomStep ( activeTab ( ) , 1 ) ; return e . preventDefault ( ) ; }
if ( k === "-" || k === "_" || code === "NumpadSubtract" ) { zoomStep ( activeTab ( ) , - 1 ) ; return e . preventDefault ( ) ; }
if ( ( k === "0" || code === "Numpad0" ) && ! input . shift ) { zoomSet ( activeTab ( ) , 100 ) ; return e . preventDefault ( ) ; }
}
// Find-in-page: Ctrl+F opens the find bar in chrome. Chrome renderer
// owns the UI (input, next/prev, count, close); it drives the active
// tab's webContents.findInPage via IPC (find-in-page / find-stop).
const isF = input . key === "F" || input . key === "f" ;
if ( input . control && ! input . shift && ! input . alt && isF ) {
try { chrome ? . webContents . send ( "find-open" ) ; } catch { }
return e . preventDefault ( ) ;
}
// DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active
// TAB. Position follows the devToolsDock setting: "bottom" docks under
// the tab (Chrome's own default, matches most web-dev muscle memory);
// "sidebar" docks on the right; "two-sidebars" also docks on the right
// but leaves the add-on sidebar in place — Electron's mode:right shares
// the right edge with whatever we've already positioned there. Users
// who prefer a detached window can still drag out from inside the
// DevTools frontend itself.
const isI = input . key === "I" || input . key === "i" ;
if ( input . key === "F12" || ( input . control && input . shift && isI ) ) {
const t = activeTab ( ) ;
if ( t ) {
try {
const twc = t . view . webContents ;
if ( twc . isDevToolsOpened ( ) ) twc . closeDevTools ( ) ;
else {
const dock = settings . devToolsDock === "sidebar" ? "right"
: settings . devToolsDock === "two-sidebars" ? "right"
: "bottom" ;
twc . openDevTools ( { mode : dock } ) ;
}
} catch { }
}
return e . preventDefault ( ) ;
}
// Reload: F5 or Ctrl+R soft; Ctrl+F5 or Ctrl+Shift+R hard (bypass cache).
// Chromium's built-in accelerators are unreliable once the app menu is
// null (Menu.setApplicationMenu(null) above), and none of them cover
// the hard variants anyway — so we wire all four explicitly.
const isR = input . key === "R" || input . key === "r" ;
const isF5 = input . key === "F5" ;
if ( isF5 || ( input . control && isR ) ) {
const t = activeTab ( ) ;
if ( t && ! t . settings ) {
const hard = ( input . control && input . shift && isR ) || ( input . control && isF5 ) ;
try {
if ( hard ) t . view . webContents . reloadIgnoringCache ( ) ;
else t . view . webContents . reload ( ) ;
} catch { }
}
return e . preventDefault ( ) ;
}
} ) ;
} ) ;
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
// Opening a page in a normal tab from an app window: the main window may be
// gone (closed while app windows stayed up) — bring it back first and let
// the session restore run before the requested page joins the strip.
const pendingTabUrls = [ ] ;
function openInMainTab ( url ) {
if ( win && ! win . isDestroyed ( ) ) { createTab ( url ) ; try { if ( win . isMinimized ( ) ) win . restore ( ) ; win . focus ( ) ; } catch { } return ; }
pendingTabUrls . push ( url ) ;
createWindow ( ) ;
}
// A second launch: `Theseus.exe --app=<url>` (an installed app's shortcut)
// opens that app's window here; a plain launch fronts the browser window,
// recreating it when only app windows are left.
function handleLaunch ( argv ) {
const appUrl = webapps . appUrlFromArgv ( argv ) ;
if ( appUrl ) { if ( webapps . launch ( appUrl ) ) return ; openInMainTab ( appUrl ) ; return ; }
if ( win && ! win . isDestroyed ( ) ) { try { if ( win . isMinimized ( ) ) win . restore ( ) ; win . focus ( ) ; } catch { } }
else createWindow ( ) ;
}
if ( ! process . env . THESEUS _NO _AUTOSTART && ! app . requestSingleInstanceLock ( ) ) {
// Another Theseus owns this profile; it got our argv via second-instance.
app . quit ( ) ;
} else if ( ! process . env . THESEUS _NO _AUTOSTART ) {
app . on ( "second-instance" , ( _e , argv ) => { try { handleLaunch ( argv ) ; } catch ( err ) { console . warn ( "second-instance failed:" , err ? . message ) ; } } ) ;
app . whenReady ( ) . then ( ( ) => {
Menu . setApplicationMenu ( null ) ; // drop the native File/Edit/View/Help menu bar
loadSettings ( ) ;
applyTheme ( ) ;
loadBookmarks ( ) ;
loadHistory ( ) ;
loadCollisions ( ) ;
loadWalletPermissions ( ) ;
applyPermissions ( ) ;
applyEmbedCookieShim ( ) ;
applyAcceptLanguage ( ) ;
applyClientHintsSpoof ( ) ;
// Session-wide preload for `window.bcnr` — runs BEFORE per-WebContentsView
// preloads (home/settings/popover/etc.), which stack on top of it. Must be
// called before any tab is created; whenReady runs before createWindow().
try {
const bcnrPreload = path . join ( _ _dirname , "bcnr-preload.js" ) ;
// Add-on page-inject bridges ride the same session-wide slot; the
// preload asks main which (if any) apply to the tab it runs in.
const injectPreload = path . join ( _ _dirname , "addon-inject-preload.js" ) ;
const ses = session . defaultSession ;
if ( typeof ses . registerPreloadScript === "function" ) {
// Electron ≥ 35: setPreloads is deprecated in favour of per-script registration.
for ( const filePath of [ bcnrPreload , injectPreload ] ) ses . registerPreloadScript ( { type : "frame" , filePath } ) ;
} else {
const existing = ses . getPreloads ( ) ;
const wanted = [ bcnrPreload , injectPreload ] . filter ( ( p ) => ! existing . includes ( p ) ) ;
if ( wanted . length ) ses . setPreloads ( [ ... existing , ... wanted ] ) ;
}
} catch ( err ) { console . warn ( "[bcnr] preload registration failed:" , err ? . message ? ? err ) ; }
protocol . handle ( "bns" , serveBns ) ;
installDownloadTracker ( ) ;
migrateExtensionDirs ( ) ;
initAddons ( ) ;
webapps . init ( {
parentWindow : ( ) => ( win && ! win . isDestroyed ( ) ? win : undefined ) ,
openInTab : openInMainTab ,
targetUrlFor , isBnsHost ,
prepareContents : ( wc ) => { styleScrollbars ( wc ) ; try { wc . setWebRTCIPHandlingPolicy ( webrtcPolicy ( ) ) ; } catch { } try { wc . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { } applyFingerprint ( wc ) ; } ,
changed : ( ) => emitTabs ( ) ,
} ) ;
// Kick off signed add-on update polling 30 s after boot so it never
// slows launch. Any staged update lands in <userData>/addons-updates-
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
// A single boot check missed everything published after launch (2026-09-22:
// Aegis 0.8.3 and VPN 0.1.3 landed on the channel minutes after the app's
// check and never showed up), so re-check every 4 h while running, and
// tell the chrome whenever something is staged so the user sees a
// "restart to apply" chip instead of finding out in Settings.
setTimeout ( ( ) => pollAddonUpdates ( "boot" ) , 30_000 ) ;
setInterval ( ( ) => pollAddonUpdates ( "interval" ) , 4 * 60 * 60 * 1000 ) ;
// Launched from an installed app's shortcut: just that app's window; the
// browser window comes up on the next plain launch (second-instance).
const appUrl = webapps . appUrlFromArgv ( process . argv ) ;
if ( appUrl && webapps . launch ( appUrl ) ) { /* app window only */ }
else createWindow ( ) ;
// Multi-source BNS warm-up so the first .bch page opens near-instantly and
// stays fresh for as long as the browser is running. Every source runs in
// parallel — none of them can block a navigation. Source 1 starts now
// (chrome.html's bookmark favicons are bns:// fetches, so the index has
// to be warm by the time the toolbar asks for them); 2– 4 are kicked off
// from onChromeReady() once the toolbar has painted.
// 1) sync: load the on-disk snapshot (user cache > bundled).
// sharedIndex is set BEFORE the first restored tab navigates.
// 2) async, continuous: startBnsPolling() opens one electrum connection
// every 30 s, fetches the beacon history (one call), and only pulls
// the tx bodies we don't already have. Merges into currentSnapshotState
// and persists — so on every page navigation the sharedIndex is at
// most 30 s old with zero user-visible latency.
// 3) async, one-shot: refresh the on-disk snapshot from the operator's
// Sia mirror. Wins the NEXT boot, not this one — after a long idle
// period the browser resumes from a snapshot fresher than the poll
// could catch up on quickly.
// 4) fallback: ensureIndex() still exists for the very first launch
// where the bundled snapshot is absent AND the poll hasn't landed
// yet — a full-walk build.
bnsWarm = warmFromSnapshot ( ) . catch ( ( ) => null ) ;
// Cheap update check: fetch the releases manifest and, if a newer
// version is out, surface a chip in the toolbar. No auto-install —
// clicking the chip opens the download URL. First check runs from
// onChromeReady(); recheck every 6h so a browser left running for days
// catches updates without a relaunch.
setInterval ( ( ) => checkForUpdate ( ) . catch ( ( ) => { } ) , 6 * 60 * 60 * 1000 ) ;
// Home cards are also polled from a remote URL — brand copy updates then
// reach every install without a browser release. User's local edits stay
// authoritative (loadHomeCards checks them first).
setInterval ( ( ) => refreshRemoteHomeCards ( ) . catch ( ( ) => { } ) , HOME _CARDS _REFRESH _MS ) ;
app . on ( "activate" , ( ) => { if ( BrowserWindow . getAllWindows ( ) . length === 0 ) createWindow ( ) ; } ) ;
} ) ;
app . on ( "before-quit" , async ( e ) => {
// Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank.
saveSession ( ) ;
stopTor ( ) ;
stopBnsPolling ( ) ; // silence the background delta refresh before exit
vaultState = null ; // drop the in-memory vault key + purposeRoot
try {
await clearBrowsingData ( {
cookies : settings . clearCookiesOnQuit ,
cache : settings . clearCacheOnQuit ,
storage : settings . clearStorageOnQuit ,
} ) ;
if ( settings . clearHistoryOnQuit ) {
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
}
} catch ( err ) { console . error ( "before-quit clear failed:" , err ? . message ) ; }
} ) ;
app . on ( "window-all-closed" , ( ) => { stopTor ( ) ; if ( process . platform !== "darwin" ) app . quit ( ) ; } ) ;
}
module . exports = { serveBns , resolveHost , isBnsHost , nativeTld , dualTld , registryOf , openLinkWindow } ;