2026-07-29 13:54:34 +02:00
// Theseus Navigator — Electron main process.
// Native .bch via a custom `bns://` protocol: resolves names with the shared
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app , BrowserWindow , WebContentsView , ipcMain , protocol , session , Menu , clipboard } = require ( "electron" ) ;
const path = require ( "path" ) ;
const http = require ( "http" ) ;
const https = require ( "https" ) ;
const { spawn } = require ( "child_process" ) ;
const fs = require ( "fs" ) ;
const WebSocket = require ( "ws" ) ;
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES _DIR = app . isPackaged ? process . resourcesPath : _ _dirname ;
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app . isPackaged
? path . join ( RES _DIR , "resolver-web.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "resolver-web.js" ) ;
2026-07-30 08:16:55 +02:00
// Search engines the user can pick from (default persisted in settings.searchEngine).
const SEARCH _ENGINES = {
duckduckgo : { name : "DuckDuckGo" , url : ( q ) => "https://duckduckgo.com/?q=" + encodeURIComponent ( q ) } ,
google : { name : "Google" , url : ( q ) => "https://www.google.com/search?q=" + encodeURIComponent ( q ) } ,
bing : { name : "Bing" , url : ( q ) => "https://www.bing.com/search?q=" + encodeURIComponent ( q ) } ,
brave : { name : "Brave" , url : ( q ) => "https://search.brave.com/search?q=" + encodeURIComponent ( q ) } ,
startpage : { name : "Startpage" , url : ( q ) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent ( q ) } ,
} ;
const SEARCH = ( q ) => ( SEARCH _ENGINES [ settings . searchEngine ] || SEARCH _ENGINES . duckduckgo ) . url ( q ) ;
2026-07-29 13:54:34 +02:00
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st" ;
// ---- BNS name detection (multi-TLD) --------------------------------------
// The engine (resolver-web.js) resolves any <label>.<tld> from the BCNR beacon.
// Two client-side sets decide how Theseus treats a name:
// NATIVE — not in the ICANN root, so we own them outright (go straight to BCNR).
// DUAL — real ICANN TLDs we ALSO offer on BCNR. These never hijack the real
// web: the ICANN site loads normally, and if a BCNR name also exists
// we surface a passive "also on BCNR" switch (see navigateTab).
const BNS _NATIVE _TLDS = new Set ( [ "bch" , "p2p" , "bit" , "nav" ] ) ;
const BNS _DUAL _TLDS = new Set ( [ "de" , "dev" , "ltd" ] ) ;
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 ) ;
} ;
const nativeTld = ( host ) => { const t = tldOf ( host ) ; return t && BNS _NATIVE _TLDS . has ( t ) ? t : null ; } ;
const dualTld = ( host ) => { const t = tldOf ( host ) ; return t && BNS _DUAL _TLDS . has ( t ) ? t : null ; } ;
// Kept for will-navigate: only native BNS hosts are intercepted as BCNR up front.
const isBnsHost = ( host ) => nativeTld ( host ) !== null ;
const registryOf = ( _tld ) => REGISTRY ;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl ( q ) {
if ( ! q ) return false ;
if ( /\s/ . test ( q ) ) return false ; // has whitespace -> search
if ( /^[a-z][a-z0-9+.-]*:\/\//i . test ( q ) ) return true ; // scheme://…
if ( /^localhost(:\d+)?([/?#]|$)/i . test ( q ) ) return true ; // localhost[:port]
if ( /^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/ . test ( q ) ) return true ; // IPv4[:port]
const host = q . split ( /[/?#]/ ) [ 0 ] ; // strip path/query/frag
return host . includes ( "." ) && ! host . startsWith ( "." ) && ! host . endsWith ( "." ) ; // dotted host
}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS _DEFAULTS = {
webrtcProtect : true , // reduce WebRTC IP leaks even without Tor (Tor strengthens it)
blockCamera : true , // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone : true , // deny microphone by default (also hides mic labels)
blockLocation : true , // deny geolocation by default
restoreSession : true , // reopen last session's tabs on launch
backgroundThrottle : true , // throttle inactive tabs / the window when unfocused
timezoneMode : "show" , // show (real) | hide (UTC) | change (timezoneValue)
timezoneValue : "UTC" , // IANA zone used when timezoneMode === "change"
languageMode : "show" , // show (real) | hide (en-US) | change (languageValue)
languageValue : "en-US" , // locale used when languageMode === "change"
2026-07-30 08:16:55 +02:00
searchEngine : "duckduckgo" , // default search engine (see SEARCH_ENGINES)
2026-07-29 13:54:34 +02:00
} ;
let settings = { ... SETTINGS _DEFAULTS } ;
const settingsFile = ( ) => path . join ( app . getPath ( "userData" ) , "settings.json" ) ;
function loadSettings ( ) {
try { if ( fs . existsSync ( settingsFile ( ) ) ) settings = { ... SETTINGS _DEFAULTS , ... JSON . parse ( fs . readFileSync ( settingsFile ( ) , "utf8" ) ) } ; }
catch ( e ) { console . error ( "settings load failed:" , e . message ) ; }
}
function saveSettings ( ) {
try { fs . writeFileSync ( settingsFile ( ) , JSON . stringify ( settings , null , 2 ) ) ; } catch ( e ) { console . error ( "settings save failed:" , e . message ) ; }
}
2026-07-30 08:16:55 +02:00
// ---- bookmarks / saved pages (userData/bookmarks.json) ----
let bookmarks = [ ] ;
const bookmarksFile = ( ) => path . join ( app . getPath ( "userData" ) , "bookmarks.json" ) ;
function loadBookmarks ( ) { try { if ( fs . existsSync ( bookmarksFile ( ) ) ) bookmarks = JSON . parse ( fs . readFileSync ( bookmarksFile ( ) , "utf8" ) ) ; } catch ( e ) { console . error ( "bookmarks load failed:" , e . message ) ; } }
function saveBookmarks ( ) { try { fs . writeFileSync ( bookmarksFile ( ) , JSON . stringify ( bookmarks , null , 2 ) ) ; } catch ( e ) { console . error ( "bookmarks save failed:" , e . message ) ; } }
function emitBookmarks ( ) { try { chrome ? . webContents . send ( "bookmarks" , bookmarks ) ; } catch { } }
2026-07-29 13:54:34 +02:00
// WebRTC IP-handling policy: protect by default (independent of Tor), strongest under Tor.
function webrtcPolicy ( ) {
if ( ! settings . webrtcProtect ) return "default" ;
return torState === "on" ? "disable_non_proxied_udp" : "default_public_interface_only" ;
}
// ---- anti-fingerprinting: timezone + language (Show / Hide / Change) ----
// Effective override, or null = "show" (report the real value).
function effTimezone ( ) {
if ( settings . timezoneMode === "hide" ) return "UTC" ;
if ( settings . timezoneMode === "change" ) return settings . timezoneValue || "UTC" ;
return null ;
}
function effLocale ( ) {
if ( settings . languageMode === "hide" ) return "en-US" ;
if ( settings . languageMode === "change" ) return settings . languageValue || "en-US" ;
return null ;
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint ( wc ) {
try {
if ( ! wc . debugger . isAttached ( ) ) wc . debugger . attach ( "1.3" ) ;
const tz = effTimezone ( ) ;
await wc . debugger . sendCommand ( "Emulation.setTimezoneOverride" , { timezoneId : tz || "" } ) ;
const loc = effLocale ( ) ;
await wc . debugger . sendCommand ( "Emulation.setLocaleOverride" , loc ? { locale : loc } : { } ) ;
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc . debugger . sendCommand ( "Page.enable" ) ;
if ( wc . _langScript ) {
try { await wc . debugger . sendCommand ( "Page.removeScriptToEvaluateOnNewDocument" , { identifier : wc . _langScript } ) ; } catch { }
wc . _langScript = null ;
}
if ( loc ) {
const langs = JSON . stringify ( [ loc , loc . split ( "-" ) [ 0 ] ] ) ;
const src = ` Object.defineProperty(navigator,'language',{get:()=> ${ JSON . stringify ( loc ) } ,configurable:true}); ` +
` Object.defineProperty(navigator,'languages',{get:()=> ${ langs } ,configurable:true}); ` ;
const res = await wc . debugger . sendCommand ( "Page.addScriptToEvaluateOnNewDocument" , { source : src } ) ;
wc . _langScript = res . identifier ;
try { await wc . executeJavaScript ( src ) ; } catch { } // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll ( ) { for ( const t of tabs ) applyFingerprint ( t . view . webContents ) ; }
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage ( ) {
const loc = effLocale ( ) || app . getLocale ( ) || "en-US" ;
try {
const ua = session . defaultSession . getUserAgent ( ) ;
session . defaultSession . setUserAgent ( ua , ` ${ loc } , ${ loc . split ( "-" ) [ 0 ] } ;q=0.8 ` ) ;
} catch { }
}
// ---- session restore + background throttling ----
const sessionFile = ( ) => path . join ( app . getPath ( "userData" ) , "session.json" ) ;
function saveSession ( ) {
try { fs . writeFileSync ( sessionFile ( ) , JSON . stringify ( tabs . filter ( ( t ) => ! t . settings && t . url ) . map ( ( t ) => t . url ) ) ) ; }
catch ( e ) { console . error ( "session save failed:" , e . message ) ; }
}
function loadSession ( ) {
try { if ( fs . existsSync ( sessionFile ( ) ) ) return JSON . parse ( fs . readFileSync ( sessionFile ( ) , "utf8" ) ) ; } catch { }
return [ ] ;
}
function applyThrottle ( ) {
for ( const t of tabs ) { try { t . view . webContents . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { } }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE _DEVICE = new Set ( [ "hid" , "serial" , "usb" , "bluetooth" , "midi" , "midiSysex" ] ) ;
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed ( kinds ) {
if ( kinds . includes ( "video" ) && settings . blockCamera ) return false ;
if ( kinds . includes ( "audio" ) && settings . blockMicrophone ) return false ;
return true ;
}
function applyPermissions ( ) {
const ses = session . defaultSession ;
ses . setPermissionRequestHandler ( ( _wc , permission , callback , details ) => {
if ( permission === "media" ) return callback ( mediaAllowed ( details ? . mediaTypes || [ ] ) ) ;
if ( permission === "geolocation" ) return callback ( ! settings . blockLocation ) ;
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 ! settings . blockLocation ;
if ( SENSITIVE _DEVICE . has ( permission ) ) return false ;
return true ;
} ) ;
}
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 ( ) ) } ;
}
// ---- 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 ( ) ;
async function resolveHost ( host ) {
const { resolveName } = await getResolver ( ) ;
if ( ! electrumPool ) await initElectrumPool ( ) ;
// Pass the full host; the engine normalizes any <sub>.<label>.<tld> itself.
// directIP: dial pinned electrum IPs when system DNS is dead (desktop lifeboat).
const entry = await resolveName ( host , { WebSocket : currentWS ( ) , directIP : true , electrum : electrumPool } ) ;
if ( entry ) entries . set ( host . toLowerCase ( ) , { entry , host : host . toLowerCase ( ) } ) ;
maybeRefreshElectrum ( ) ;
return entry ;
}
const MIME = { html : "text/html; charset=utf-8" , htm : "text/html; charset=utf-8" , css : "text/css" , js : "text/javascript" ,
json : "application/json" , png : "image/png" , jpg : "image/jpeg" , jpeg : "image/jpeg" , gif : "image/gif" , svg : "image/svg+xml" ,
ico : "image/x-icon" , webp : "image/webp" , woff2 : "font/woff2" , woff : "font/woff" , txt : "text/plain" , wasm : "application/wasm" } ;
const guessType = ( p ) => MIME [ p . split ( "." ) . pop ( ) ? . toLowerCase ( ) ] || "application/octet-stream" ;
async function serveBns ( request ) {
const url = new URL ( request . url ) ;
const host = url . hostname . toLowerCase ( ) ;
const reqPath = decodeURIComponent ( url . pathname ) || "/" ;
let rec = entries . get ( host ) ;
if ( ! rec ) { try { await resolveHost ( host ) ; } catch { } rec = entries . get ( host ) ; }
if ( ! rec ) return new Response ( "NXDOMAIN: " + host , { status : 404 , headers : { "content-type" : "text/plain" } } ) ;
const r = rec . entry . records ;
try {
if ( r . h ) { if ( reqPath === "/" ) return new Response ( r . h , { headers : { "content-type" : "text/html; charset=utf-8" } } ) ; return new Response ( "not found" , { status : 404 } ) ; }
if ( r . s3 ) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch ( ` ${ GATEWAY } /bns/ ${ host } ${ reqPath } ${ url . search } ` , { } ) ;
const ct = up . contentType && up . contentType !== "application/octet-stream"
? up . contentType : guessType ( reqPath === "/" ? "index.html" : reqPath ) ;
let body = up . buffer ;
if ( ct . includes ( "text/html" ) ) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer . from ( body . toString ( "utf8" ) . replace ( /<base\s+href="\/bns\/[^"]*">/i , "" ) , "utf8" ) ;
}
return new Response ( body , { status : up . status , headers : { "content-type" : ct } } ) ;
}
if ( r . ip ) {
const up = await contentFetch ( ` http:// ${ r . ip } ${ reqPath } ${ url . search } ` , { headers : { host } } ) ;
return new Response ( up . buffer , { status : up . status , headers : { "content-type" : up . contentType || guessType ( reqPath ) } } ) ;
}
if ( r . u ) return Response . redirect ( r . u , 302 ) ;
return new Response ( JSON . stringify ( rec . entry , null , 2 ) , { headers : { "content-type" : "application/json" } } ) ;
} catch ( e ) { return new Response ( "Theseus error: " + e . message , { status : 502 } ) ; }
}
// ---- window + tabs ----
let win , chrome , statusbar ;
2026-07-30 08:16:55 +02:00
let CHROME _H = 84 ; // grows when an extra bar (Tor notice / BCNR offer) is shown
2026-07-29 13:54:34 +02:00
const STATUS _H = 24 ; // fixed bottom resolver/provenance line
2026-07-30 08:16:55 +02:00
// 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 , POP _H = 200 ;
2026-07-29 13:54:34 +02:00
const tabs = [ ] ; // { id, view, title, url, prov }
let activeId = null , tabSeq = 0 ;
const tabById = ( id ) => tabs . find ( ( t ) => t . id === id ) ;
const activeTab = ( ) => tabById ( activeId ) ;
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
function pushNav ( prov ) {
chrome ? . webContents . send ( "nav" , prov ) ;
statusbar ? . webContents . send ( "nav" , prov ) ;
2026-07-30 08:16:55 +02:00
if ( popVisible ) popover ? . webContents . send ( "site-info" , prov ) ;
2026-07-29 13:54:34 +02:00
}
function layout ( ) {
if ( ! win ) return ;
const { width , height } = win . getContentBounds ( ) ;
chrome . setBounds ( { x : 0 , y : 0 , width , height : CHROME _H } ) ;
const bodyH = Math . max ( 0 , height - CHROME _H - STATUS _H ) ;
for ( const t of tabs ) t . view . setBounds ( { x : 0 , y : CHROME _H , width , height : bodyH } ) ;
statusbar ? . setBounds ( { x : 0 , y : height - STATUS _H , width , height : STATUS _H } ) ;
2026-07-30 08:16:55 +02:00
positionPopover ( ) ;
}
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 : POP _H } ) ;
}
function showPopover ( show ) {
if ( ! popover ) return ;
if ( show ) {
positionPopover ( ) ;
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
win . contentView . removeChildView ( popover ) ;
win . contentView . addChildView ( popover ) ;
popover . setVisible ( true ) ; popVisible = true ;
popover . webContents . send ( "site-info" , activeTab ( ) ? . prov || { kind : "home" } ) ;
} else { popover . setVisible ( false ) ; popVisible = false ; }
2026-07-29 13:54:34 +02:00
}
function setActive ( id ) {
activeId = id ;
2026-07-30 08:16:55 +02:00
if ( popVisible ) showPopover ( false ) ; // don't carry a stale popover across tabs
2026-07-29 13:54:34 +02:00
for ( const t of tabs ) t . view . setVisible ( t . id === id ) ;
const t = activeTab ( ) ;
if ( t ? . prov ) pushNav ( t . prov ) ;
chrome . webContents . send ( "bcnr-offer" , t ? . bcnrOffer ? { host : t . bcnrOffer . host , tld : t . bcnrOffer . tld , registry : REGISTRY } : null ) ;
emitTabs ( ) ;
}
function emitTabs ( ) {
const t = activeTab ( ) ;
const wc = t ? . view . webContents ;
chrome ? . webContents . send ( "tabs" , {
tabs : tabs . map ( ( x ) => ( { id : x . id , title : x . title || "New Tab" , active : x . id === activeId } ) ) ,
url : t ? . url || "" ,
canBack : wc ? wc . navigationHistory . canGoBack ( ) : false ,
canForward : wc ? wc . navigationHistory . canGoForward ( ) : false ,
} ) ;
}
function loadHome ( id ) {
const t = tabById ( id ) ; if ( ! t ) return ;
t . url = "" ; t . title = "Theseus" ; t . prov = { host : "" , kind : "home" } ;
t . view . webContents . loadFile ( "home.html" ) ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
function createTab ( initial , opts = { } ) {
const id = ++ tabSeq ;
const view = new WebContentsView ( opts . settings ? { webPreferences : { preload : path . join ( _ _dirname , "settings-preload.js" ) } } : { } ) ;
const wc = view . webContents ;
try { wc . setWebRTCIPHandlingPolicy ( webrtcPolicy ( ) ) ; } catch { }
try { wc . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { }
applyFingerprint ( wc ) ;
const tab = { id , view , title : opts . settings ? "Settings" : "New Tab" , url : "" , prov : null , settings : ! ! opts . settings } ;
tabs . push ( tab ) ;
win . contentView . addChildView ( view ) ;
wc . on ( "page-title-updated" , ( _e , title ) => { tab . title = title ; emitTabs ( ) ; } ) ;
wc . on ( "did-navigate" , ( ) => emitTabs ( ) ) ;
wc . on ( "did-navigate-in-page" , ( ) => emitTabs ( ) ) ;
wc . on ( "will-navigate" , ( e , u ) => {
try {
const parsed = new URL ( u ) ;
if ( parsed . protocol === "bns:" ) return ;
if ( isBnsHost ( parsed . hostname ) ) { e . preventDefault ( ) ; navigateTab ( id , parsed . hostname + parsed . pathname ) ; }
} catch { }
} ) ;
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc . setWindowOpenHandler ( ( { url , disposition } ) => {
if ( url && url !== "about:blank" ) createTab ( url , { background : disposition === "background-tab" } ) ;
return { action : "deny" } ;
} ) ;
// Right-click context menu.
wc . on ( "context-menu" , ( _e , p ) => {
const items = [ ] ;
if ( p . linkURL ) {
items . push (
{ label : "Open link in new tab" , click : ( ) => createTab ( p . linkURL ) } ,
{ label : "Open link in new background tab" , click : ( ) => createTab ( p . linkURL , { background : true } ) } ,
{ label : "Copy link address" , click : ( ) => clipboard . writeText ( p . linkURL ) } ,
{ type : "separator" } ,
) ;
}
if ( p . isEditable ) items . push ( { role : "cut" } , { role : "copy" } , { role : "paste" } , { type : "separator" } ) ;
else if ( p . selectionText ) items . push ( { role : "copy" } , { type : "separator" } ) ;
items . push (
{ label : "Back" , enabled : wc . navigationHistory . canGoBack ( ) , click : ( ) => wc . navigationHistory . goBack ( ) } ,
{ label : "Forward" , enabled : wc . navigationHistory . canGoForward ( ) , click : ( ) => wc . navigationHistory . goForward ( ) } ,
{ label : "Reload" , click : ( ) => wc . reload ( ) } ,
) ;
Menu . buildFromTemplate ( items ) . popup ( ) ;
} ) ;
layout ( ) ;
if ( opts . background ) { view . setVisible ( false ) ; emitTabs ( ) ; }
else setActive ( id ) ;
if ( opts . settings ) {
tab . prov = { host : "" , kind : "home" } ;
wc . loadFile ( "settings.html" ) ;
if ( id === activeId ) pushNav ( tab . prov ) ;
emitTabs ( ) ;
} else if ( initial ) navigateTab ( id , initial ) ;
else loadHome ( id ) ;
return id ;
}
function closeTab ( id ) {
const i = tabs . findIndex ( ( t ) => t . id === id ) ;
if ( i < 0 ) return ;
const [ t ] = tabs . splice ( i , 1 ) ;
win . contentView . removeChildView ( t . view ) ;
t . view . webContents . destroy ? . ( ) ;
if ( tabs . length === 0 ) { createTab ( ) ; return ; }
if ( activeId === id ) setActive ( tabs [ Math . max ( 0 , i - 1 ) ] . id ) ;
else emitTabs ( ) ;
}
function createWindow ( ) {
win = new BrowserWindow ( { width : 1220 , height : 840 , title : "Theseus Navigator" , backgroundColor : "#0f1420" } ) ;
chrome = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "preload.js" ) } } ) ;
win . contentView . addChildView ( chrome ) ;
chrome . webContents . loadFile ( "chrome.html" ) ;
// Bottom resolver/provenance line (trusted chrome, painted by the browser).
statusbar = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "preload.js" ) } } ) ;
win . contentView . addChildView ( statusbar ) ;
statusbar . webContents . loadFile ( "statusbar.html" ) ;
// The bottom bar loads async; once ready, paint it with the active tab's state.
statusbar . webContents . once ( "did-finish-load" , ( ) => { const t = activeTab ( ) ; if ( t ? . prov ) statusbar . webContents . send ( "nav" , t . prov ) ; } ) ;
2026-07-30 08:16:55 +02:00
// Floating site-info overlay (hidden until the address-bar badge is clicked).
popover = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "popover-preload.js" ) } } ) ;
try { popover . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( popover ) ;
popover . webContents . loadFile ( "popover.html" ) ;
popover . setVisible ( false ) ;
2026-07-29 13:54:34 +02:00
chrome . webContents . once ( "did-finish-load" , ( ) => {
const saved = settings . restoreSession ? loadSession ( ) : [ ] ;
if ( saved . length ) saved . forEach ( ( u ) => createTab ( u ) ) ; else createTab ( ) ;
} ) ;
win . on ( "resize" , layout ) ;
layout ( ) ;
}
async function navigateTab ( id , input ) {
const t = tabById ( id ) ; if ( ! t ) return ;
let q = String ( input ) . trim ( ) ;
if ( ! q ) return ;
// 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 ) || "/" ;
const native = nativeTld ( host ) ;
const dual = dualTld ( host ) ;
// Every fresh navigation clears any stale "also on BCNR" offer for this tab.
t . nav = ( t . nav || 0 ) + 1 ;
const navId = t . nav ;
t . bcnrOffer = null ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , null ) ;
// Native BCNR TLD (.bch/.p2p/.nav): resolve from the registry outright.
if ( native ) return loadBns ( t , id , host , rest , native ) ;
// Ordinary web — and dual-use ICANN TLDs (.de/.dev/.ltd) — load the REAL site
// immediately. We never hijack or delay the clearnet web.
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 ( ) ;
// Dual-use: in parallel, check whether the name also exists on BCNR. If it
// does, offer a passive switch — the user opts in; nothing is forced.
if ( dual ) {
resolveHost ( host ) . then ( ( entry ) => {
if ( entry && tabById ( id ) === t && t . nav === navId ) {
t . bcnrOffer = { host , rest , tld : dual } ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , { host , tld : dual , registry : REGISTRY } ) ;
}
} ) . catch ( ( ) => { } ) ;
}
}
// Load a name from BCNR into a tab — used by native TLDs and by an accepted
// dual-use switch from the "also on BCNR" bar.
async function loadBns ( t , id , host , rest , tld ) {
const registry = registryOf ( tld ) ;
if ( id === activeId ) pushNav ( { host , kind : "resolving" , tld , registry } ) ;
let entry ;
try { entry = await resolveHost ( host ) ; }
catch ( e ) { t . prov = { host , kind : "error" , error : e . message , tld , registry } ; if ( id === activeId ) pushNav ( t . prov ) ; return ; }
t . url = host + ( rest === "/" ? "" : rest ) ;
if ( ! entry ) {
t . prov = { host , kind : "nxdomain" , tld , registry } ;
await t . view . webContents . loadURL ( ` bns:// ${ host } / ` ) ;
if ( id === activeId ) pushNav ( t . prov ) ; emitTabs ( ) ; return ;
}
await t . view . webContents . loadURL ( ` bns:// ${ host } ${ rest } ` ) ;
const src = entry . records . h ? "on-chain (chain)" : entry . records . s3 ? "Sia network" : entry . records . ip ? "direct server" : entry . records . u ? "redirect" : "record" ;
t . prov = { host , kind : "ok" , source : src , category : entry . category , records : Object . keys ( entry . records ) , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
ipcMain . handle ( "navigate" , ( _e , input ) => navigateTab ( activeId , input ) ) ;
ipcMain . handle ( "search" , ( _e , q ) => navigateTab ( activeId , SEARCH ( q ) ) ) ;
ipcMain . handle ( "new-tab" , ( ) => createTab ( ) ) ;
ipcMain . handle ( "close-tab" , ( _e , id ) => closeTab ( id ) ) ;
ipcMain . handle ( "switch-tab" , ( _e , id ) => setActive ( id ) ) ;
ipcMain . handle ( "go-home" , ( ) => loadHome ( activeId ) ) ;
ipcMain . handle ( "go-back" , ( ) => { const wc = activeTab ( ) ? . view . webContents ; if ( wc ? . navigationHistory . canGoBack ( ) ) wc . navigationHistory . goBack ( ) ; } ) ;
ipcMain . handle ( "go-forward" , ( ) => { const wc = activeTab ( ) ? . view . webContents ; if ( wc ? . navigationHistory . canGoForward ( ) ) wc . navigationHistory . goForward ( ) ; } ) ;
ipcMain . handle ( "reload" , ( ) => activeTab ( ) ? . view . webContents . reload ( ) ) ;
ipcMain . handle ( "toggle-tor" , ( ) => { torState === "off" ? startTor ( ) : stopTor ( ) ; } ) ;
ipcMain . handle ( "open-settings" , ( ) => { const ex = tabs . find ( ( t ) => t . settings ) ; if ( ex ) return setActive ( ex . id ) ; createTab ( null , { settings : true } ) ; } ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "toggle-site-info" , ( _e , rect ) => {
if ( popVisible ) return showPopover ( false ) ;
if ( rect ) popPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showPopover ( true ) ;
} ) ;
ipcMain . handle ( "close-site-info" , ( ) => showPopover ( false ) ) ;
ipcMain . handle ( "search-engines" , ( ) => ( {
engines : Object . entries ( SEARCH _ENGINES ) . map ( ( [ id , e ] ) => ( { id , name : e . name } ) ) ,
current : settings . searchEngine ,
} ) ) ;
ipcMain . handle ( "set-search-engine" , ( _e , id ) => {
if ( SEARCH _ENGINES [ id ] ) { settings . searchEngine = id ; saveSettings ( ) ; }
return settings . searchEngine ;
} ) ;
ipcMain . handle ( "bookmarks-get" , ( ) => bookmarks ) ;
ipcMain . handle ( "bookmark-add" , ( _e , bm ) => {
if ( bm && bm . url && ! bookmarks . some ( ( b ) => b . url === bm . url ) ) {
bookmarks . push ( { title : bm . title || bm . url , url : bm . url } ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
}
return bookmarks ;
} ) ;
ipcMain . handle ( "bookmark-remove" , ( _e , url ) => {
bookmarks = bookmarks . filter ( ( b ) => b . url !== url ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return bookmarks ;
} ) ;
2026-07-29 13:54:34 +02:00
ipcMain . handle ( "settings-get" , ( ) => settings ) ;
ipcMain . handle ( "settings-set" , ( _e , key , val ) => {
if ( key in SETTINGS _DEFAULTS ) { settings [ key ] = val ; saveSettings ( ) ; }
if ( key === "webrtcProtect" ) applyWebRTCPolicy ( ) ;
if ( key === "backgroundThrottle" ) applyThrottle ( ) ;
if ( [ "timezoneMode" , "timezoneValue" , "languageMode" , "languageValue" ] . includes ( key ) ) { applyFingerprintAll ( ) ; applyAcceptLanguage ( ) ; }
return settings ;
} ) ;
ipcMain . handle ( "set-chrome-height" , ( _e , h ) => {
const next = Math . max ( 74 , Math . min ( 260 , Math . round ( h ) || 84 ) ) ;
if ( next !== CHROME _H ) { CHROME _H = next ; layout ( ) ; }
} ) ;
ipcMain . handle ( "switch-to-bcnr" , ( ) => {
const t = activeTab ( ) ; if ( ! t || ! t . bcnrOffer ) return ;
const { host , rest , tld } = t . bcnrOffer ;
t . bcnrOffer = null ;
chrome . webContents . send ( "bcnr-offer" , null ) ;
return loadBns ( t , activeId , host , rest || "/" , tld ) ;
} ) ;
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
if ( ! process . env . THESEUS _NO _AUTOSTART ) {
app . whenReady ( ) . then ( ( ) => {
2026-07-30 08:16:55 +02:00
Menu . setApplicationMenu ( null ) ; // drop the native File/Edit/View/Help menu bar
2026-07-29 13:54:34 +02:00
loadSettings ( ) ;
2026-07-30 08:16:55 +02:00
loadBookmarks ( ) ;
2026-07-29 13:54:34 +02:00
applyPermissions ( ) ;
applyAcceptLanguage ( ) ;
protocol . handle ( "bns" , serveBns ) ;
createWindow ( ) ;
app . on ( "activate" , ( ) => { if ( BrowserWindow . getAllWindows ( ) . length === 0 ) createWindow ( ) ; } ) ;
} ) ;
app . on ( "before-quit" , ( ) => { saveSession ( ) ; stopTor ( ) ; } ) ;
app . on ( "window-all-closed" , ( ) => { stopTor ( ) ; if ( process . platform !== "darwin" ) app . quit ( ) ; } ) ;
}
module . exports = { serveBns , resolveHost , isBnsHost , nativeTld , dualTld , registryOf } ;