2026-07-29 13:54:34 +02:00
// Theseus Navigator — Electron main process.
// Native .bch via a custom `bns://` protocol: resolves names with the shared
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
2026-08-02 18:50:58 +02:00
const { app , BrowserWindow , WebContentsView , ipcMain , protocol , session , Menu , clipboard , nativeTheme , shell , dialog } = require ( "electron" ) ;
2026-07-29 13:54:34 +02:00
const path = require ( "path" ) ;
2026-09-15 01:36:10 +02:00
const url = require ( "url" ) ;
2026-07-29 13:54:34 +02:00
const http = require ( "http" ) ;
const https = require ( "https" ) ;
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
const tls = require ( "tls" ) ;
2026-07-29 13:54:34 +02:00
const { spawn } = require ( "child_process" ) ;
const fs = require ( "fs" ) ;
const WebSocket = require ( "ws" ) ;
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES _DIR = app . isPackaged ? process . resourcesPath : _ _dirname ;
2026-09-06 02:21:16 +02:00
// 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 ) ; }
}
2026-07-29 13:54:34 +02:00
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app . isPackaged
? path . join ( RES _DIR , "resolver-web.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "resolver-web.js" ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Password vault — same .mjs-in-resources pattern as the resolver.
const VAULT _MOD = app . isPackaged
? path . join ( RES _DIR , "password-vault.mjs" )
: path . join ( _ _dirname , ".." , "Argus" , "src" , "lib" , "password-vault.js" ) ;
let vaultLib ;
async function loadVaultLib ( ) {
if ( ! vaultLib ) vaultLib = await import ( ` file:// ${ VAULT _MOD . replace ( /\\/g , "/" ) } ` ) ;
return vaultLib ;
}
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
// 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 ;
}
2026-08-02 11:39:00 +02:00
// Built-in engines. Users can also add their own (settings.customEngines,
2026-07-30 20:58:45 +02:00
// each { id, name, url } where the url contains "%s" for the query).
2026-07-30 22:55:52 +02:00
// Catalog of built-in engines (users pick which to enable + can add their own).
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
// fav = the domain to load a real favicon from; sym = emoji fallback.
2026-08-02 11:39:00 +02:00
// kind = "search" (traditional search engines) or "llm" (AI answer engines).
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
// tier = "catalog" (curated first-class options shown in the primary Add
// panel) or "extra" (a wider bank hidden behind a filter box for
// discovery). Missing tier defaults to "catalog".
// URL routing is identical for all — kind and tier are display-only grouping.
2026-07-30 08:16:55 +02:00
const SEARCH _ENGINES = {
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
duckduckgo : { kind : "search" , tier : "catalog" , name : "DuckDuckGo" , sym : "🦆" , fav : "duckduckgo.com" , url : ( q ) => "https://duckduckgo.com/?q=" + encodeURIComponent ( q ) } ,
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
google : { kind : "search" , tier : "catalog" , name : "Google" , sym : "🔵" , fav : "www.google.com" , url : ( q , h = { } ) => ` https://www.google.com/search?q= ${ encodeURIComponent ( q ) } &hl= ${ h . hl || "en" } &gl= ${ h . gl || "us" } &pws=0 ` } ,
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
brave : { kind : "search" , tier : "catalog" , name : "Brave" , sym : "🦁" , fav : "search.brave.com" , url : ( q ) => "https://search.brave.com/search?q=" + encodeURIComponent ( q ) } ,
bing : { kind : "search" , tier : "catalog" , name : "Bing" , sym : "🔎" , fav : "www.bing.com" , url : ( q ) => "https://www.bing.com/search?q=" + encodeURIComponent ( q ) } ,
startpage : { kind : "search" , tier : "catalog" , name : "Startpage" , sym : "🛡️" , fav : "www.startpage.com" , url : ( q ) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent ( q ) } ,
yandex : { kind : "search" , tier : "catalog" , name : "Yandex" , sym : "🔴" , fav : "yandex.com" , url : ( q ) => "https://yandex.com/search/?text=" + encodeURIComponent ( q ) } ,
ecosia : { kind : "search" , tier : "catalog" , name : "Ecosia" , sym : "🌱" , fav : "www.ecosia.org" , url : ( q ) => "https://www.ecosia.org/search?q=" + encodeURIComponent ( q ) } ,
mojeek : { kind : "search" , tier : "catalog" , name : "Mojeek" , sym : "🧭" , fav : "www.mojeek.com" , url : ( q ) => "https://www.mojeek.com/search?q=" + encodeURIComponent ( q ) } ,
2026-08-02 14:43:31 +02:00
// SearXNG is federated (dozens of public instances at searx.space); any single
// default becomes stale as instances rate-limit / die (searx.be is anti-bot-locked).
// Users who want SearXNG add their preferred instance via the custom URL form.
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
wikipedia : { kind : "search" , tier : "catalog" , name : "Wikipedia" , sym : "📖" , fav : "en.wikipedia.org" , url : ( q ) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent ( q ) } ,
2026-08-02 11:39:00 +02:00
// AI / LLM answer engines that ANSWER the URL query without requiring a login.
// ChatGPT / Claude / You.com's youchat all bounce to sign-in before running
// ?q=, so they'd fail silently as a "search engine" — omitted deliberately.
Theseus: download tracker, search split, discover-more tier, UX polish
Bundle of UX + feature work. Split from packaging by intent so the diff
is reviewable; the next Theseus rebuild ships it.
Features
- Download tracker (new): session.on("will-download") → per-item state
{id, filename, url, mime, total, received, state, savePath, startedAt}
with updated/done event handlers. New downloadsPop WebContentsView
loads downloads.html (new file) + downloads-preload.js (new file);
panel positioned under a new #downloads toolbar button between search
and Tor. Full IPC: downloads-get, toggle/close/resize-downloads,
download-open/show/cancel/clear, downloads-clear-all. In-memory only —
cross-session persistence is a future addition. Button badge shows
active count + spin/done/err color.
- Search engines split by kind + tier:
* kind: "search" | "llm" — separate headers in picker + settings
("Search with" / "Ask an AI"). Empty sections hidden.
* tier: "catalog" | "extra" — Settings now has THREE panes behind the
"+ Add search engine" button: curated catalog, wider discoverable
bank filtered by a live search input, custom URL form.
* DEFAULT_ENABLED unchanged (5 major engines).
* Custom user-added engines carry tier="custom" (never in catalog/extra
panes).
- 9 tier="extra" engines added (all non-login ?q=): Marginalia, Stract,
Yep, Presearch, MetaGer, Qwant, Swisscows, Naver, Baidu. Same rot rule
as LLMs: if one starts bouncing to a login gate, drop it.
Bug fixes
- Loadbar collapses to 0px when idle (was reserving a permanent 2px
strip below the address bar). .loadbar {height:0} + .loadbar.on
{height:2px} + 120ms transition.
- Native <select> popup theme sync via :root { color-scheme: dark } +
@media(prefers-color-scheme: light). nativeTheme.themeSource already
drives prefers-color-scheme, so the OS popup color follows the app
theme automatically (fixed light popup on dark app / vice versa).
- .ctl layout flipped to flex-direction: row with flex-wrap so
anti-fingerprint mode + value fields fit side-by-side.
Settings restructure
- General section: Startup group at the top ("Open previous windows and
tabs" toggle), then Appearance below with three visual THEME CARDS
(System / Light / Dark) — small mock-browser previews per theme,
Firefox-style, active card gets a blue ring. System pipes through to
nativeTheme.themeSource = "system".
- Search promoted to a top-level sidebar item between General and
Naming. Search-engine controls moved out of General into Search.
- Search section: enabled list shows only enabled engines, grouped by
kind, drag-reorder within a kind. "+ Add search engine" opens the
catalog/extras/custom-URL panel.
Search engine catalog trims (already flagged in prior work)
- Removed ChatGPT / Claude / You.com (login-gated ?q=).
- Removed SearXNG (federated; every single-instance default rots).
Docs
- TheseusNavigator/PENDING.md and GOTCHAS.md born with this work
(see the HANDOFF.md commit for the convention).
- PENDING.md's own "session: 2026-08-02:theseus-ux-polish" group will be
emptied after this ship lands.
Preview harness
- _preview.html + _settings-preview.html stubs updated with kind + tier
+ downloads seed + tier="extra" samples so the preview reflects reality.
Both files are gitignored — local only.
Coordination
- Parallel session's collision-policy work (chrome.html registry chips,
popover switcher, Naming section, in-tab collision prompt) already
landed in commits 256079d/42b340f/b0d6375/78dddda. This commit adds
cleanly on top.
2026-08-02 15:31:47 +02:00
perplexity : { kind : "llm" , tier : "catalog" , name : "Perplexity" , sym : "🧠" , fav : "www.perplexity.ai" , url : ( q ) => "https://www.perplexity.ai/search?q=" + encodeURIComponent ( q ) } ,
phind : { kind : "llm" , tier : "catalog" , name : "Phind" , sym : "🧑💻" , fav : "www.phind.com" , url : ( q ) => "https://www.phind.com/search?q=" + encodeURIComponent ( q ) } ,
// ---- Extras: wider bank, discoverable via the Search filter box in Settings.
// These are known-working engines that don't require sign-in on ?q= but aren't
// first-class enough to sit in the primary catalog. Keep the list vetted — if
// an entry starts bouncing to a login gate, drop it (same rule as the LLMs).
marginalia : { kind : "search" , tier : "extra" , name : "Marginalia" , sym : "🕸" , fav : "search.marginalia.nu" , url : ( q ) => "https://search.marginalia.nu/search?query=" + encodeURIComponent ( q ) } ,
stract : { kind : "search" , tier : "extra" , name : "Stract" , sym : "🧵" , fav : "stract.com" , url : ( q ) => "https://stract.com/search?q=" + encodeURIComponent ( q ) } ,
yep : { kind : "search" , tier : "extra" , name : "Yep" , sym : "✳️" , fav : "yep.com" , url : ( q ) => "https://yep.com/web?q=" + encodeURIComponent ( q ) } ,
presearch : { kind : "search" , tier : "extra" , name : "Presearch" , sym : "🔷" , fav : "presearch.com" , url : ( q ) => "https://presearch.com/search?q=" + encodeURIComponent ( q ) } ,
metager : { kind : "search" , tier : "extra" , name : "MetaGer" , sym : "🇩🇪" , fav : "metager.org" , url : ( q ) => "https://metager.org/meta/meta.ger3?eingabe=" + encodeURIComponent ( q ) } ,
qwant : { kind : "search" , tier : "extra" , name : "Qwant" , sym : "🇫🇷" , fav : "www.qwant.com" , url : ( q ) => "https://www.qwant.com/?q=" + encodeURIComponent ( q ) } ,
swisscows : { kind : "search" , tier : "extra" , name : "Swisscows" , sym : "🐄" , fav : "swisscows.com" , url : ( q ) => "https://swisscows.com/en/web?query=" + encodeURIComponent ( q ) } ,
naver : { kind : "search" , tier : "extra" , name : "Naver" , sym : "🇰🇷" , fav : "www.naver.com" , url : ( q ) => "https://search.naver.com/search.naver?query=" + encodeURIComponent ( q ) } ,
baidu : { kind : "search" , tier : "extra" , name : "Baidu" , sym : "🇨🇳" , fav : "www.baidu.com" , url : ( q ) => "https://www.baidu.com/s?wd=" + encodeURIComponent ( q ) } ,
2026-07-30 08:16:55 +02:00
} ;
2026-07-30 22:55:52 +02:00
// Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
2026-08-31 16:32:16 +02:00
const DEFAULT _ENABLED = [ "startpage" , "duckduckgo" , "google" , "brave" , "bing" ] ;
2026-08-01 01:26:51 +02:00
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
2026-09-08 13:12:55 +02:00
// 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 ) ;
2026-07-30 22:55:52 +02:00
function customFavicon ( url ) { try { return faviconUrl ( new URL ( String ( url ) . replace ( "%s" , "x" ) ) . hostname ) ; } catch { return null ; } }
function isEnabled ( id ) { return ( settings . enabledEngines || DEFAULT _ENABLED ) . includes ( id ) ; }
2026-08-06 01:41:31 +02:00
// Two-tier state: an engine is INSTALLED if it's in the user's Additional
// list (visible in Settings), and ENABLED if it's currently toggled on
// (visible in the toolbar dropdown). Toggle flips enabled only; right-click
// "Remove from list" is what actually removes an installed engine.
function isInstalled ( id ) {
if ( ( settings . customEngines || [ ] ) . some ( ( e ) => e . id === id ) ) return true ; // customs are always installed
return ( settings . installedEngines || DEFAULT _ENABLED ) . includes ( id ) ;
}
2026-07-30 20:58:45 +02:00
function allEngines ( ) {
2026-07-30 22:55:52 +02:00
const list = Object . entries ( SEARCH _ENGINES ) . map ( ( [ id , e ] ) =>
2026-08-06 01:41:31 +02:00
( { id , name : e . name , sym : e . sym , favicon : faviconUrl ( e . fav ) , kind : e . kind || "search" , tier : e . tier || "catalog" , builtin : true , installed : isInstalled ( id ) , enabled : isEnabled ( id ) } ) ) ;
2026-07-30 22:55:52 +02:00
for ( const c of settings . customEngines || [ ] )
2026-08-06 01:41:31 +02:00
list . push ( { id : c . id , name : c . name , sym : c . sym || "🔍" , favicon : customFavicon ( c . url ) , kind : c . kind || "search" , tier : "custom" , builtin : false , installed : true , enabled : isEnabled ( c . id ) } ) ;
2026-07-30 23:26:00 +02:00
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
const order = settings . engineOrder || [ ] ;
return list . slice ( ) . sort ( ( a , b ) => {
const ia = order . indexOf ( a . id ) , ib = order . indexOf ( b . id ) ;
if ( ia === - 1 && ib === - 1 ) return 0 ;
if ( ia === - 1 ) return 1 ;
if ( ib === - 1 ) return - 1 ;
return ia - ib ;
} ) ;
2026-07-30 20:58:45 +02:00
}
2026-07-30 22:55:52 +02:00
function enabledEnginesList ( ) { return allEngines ( ) . filter ( ( e ) => e . enabled ) ; }
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Region → 2-letter country code, for engines that accept a `gl`-style hint
// (Google's the notable one — without it Google may bounce a raw ?q= URL to
// a consent redirect or the region-detect start page instead of results).
const REGION _TO _COUNTRY = {
europe : "de" , asia : "jp" , north _america : "us" , south _america : "br" ,
africa : "ke" , middle _east : "ae" , australia : "au" ,
} ;
function searchHints ( ) {
const loc = effLocale ( ) ; // e.g. "en-US" (or null → show real)
const region = settings . locationMode === "spoof" ? settings . locationRegion : null ;
return {
hl : ( loc || app . getLocale ( ) || "en" ) . split ( "-" ) [ 0 ] ,
gl : REGION _TO _COUNTRY [ region ] || ( loc && loc . split ( "-" ) [ 1 ] ? . toLowerCase ( ) ) || "us" ,
} ;
}
2026-07-30 20:58:45 +02:00
function engineUrl ( id , q ) {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const h = searchHints ( ) ;
if ( SEARCH _ENGINES [ id ] ) return SEARCH _ENGINES [ id ] . url ( q , h ) ;
2026-07-30 20:58:45 +02:00
const c = ( settings . customEngines || [ ] ) . find ( ( e ) => e . id === id ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return c ? c . url . replace ( /%s/g , encodeURIComponent ( q ) ) : SEARCH _ENGINES . duckduckgo . url ( q , h ) ;
2026-07-30 20:58:45 +02:00
}
const SEARCH = ( q ) => engineUrl ( settings . searchEngine , q ) ;
2026-07-29 13:54:34 +02:00
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st" ;
2026-09-16 00:53:13 +02:00
// ---- 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 ;
}
2026-07-29 13:54:34 +02:00
// ---- BNS name detection (multi-TLD) --------------------------------------
2026-07-30 22:55:52 +02:00
// Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
// <label>.<tld> from the BCNR beacon. Flow:
// 1. User navigates to `<label>.<tld>` (address bar or link click)
// 2. Theseus asks BCNR first
// 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u)
// 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web
// (https://<host><path>), so users aren't locked out of the clearnet
// when the chain is down or the name isn't registered.
// Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames,
// non-http schemes) bypass BCNR and load directly.
// The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN.
2026-07-29 13:54:34 +02:00
const REGISTRY = "BCNR" ; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = ( host ) => {
const h = String ( host ) . toLowerCase ( ) . replace ( /\.$/ , "" ) ;
const dot = h . lastIndexOf ( "." ) ;
return dot < 0 ? null : h . slice ( dot + 1 ) ;
} ;
2026-07-30 22:55:52 +02:00
// Any dotted host that isn't an IP or localhost is a BCNR candidate.
const isBnsHost = ( host ) => {
if ( ! host ) return false ;
const h = String ( host ) . toLowerCase ( ) . replace ( /\.$/ , "" ) ;
if ( h === "localhost" || h . startsWith ( "localhost:" ) ) return false ;
if ( /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/ . test ( h ) ) return false ; // IPv4[:port]
if ( h . startsWith ( "[" ) ) return false ; // IPv6 literal
const dot = h . lastIndexOf ( "." ) ;
return dot > 0 && dot < h . length - 1 ; // has a real TLD
} ;
// Kept as aliases so external callers (tests, module.exports) don't break.
const nativeTld = ( host ) => isBnsHost ( host ) ? tldOf ( host ) : null ;
const dualTld = ( ) => null ; // dual-priority mode is gone — no ICANN-first TLDs
2026-07-29 13:54:34 +02:00
const registryOf = ( _tld ) => REGISTRY ;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl ( q ) {
if ( ! q ) return false ;
if ( /\s/ . test ( q ) ) return false ; // has whitespace -> search
if ( /^[a-z][a-z0-9+.-]*:\/\//i . test ( q ) ) return true ; // scheme://…
if ( /^localhost(:\d+)?([/?#]|$)/i . test ( q ) ) return true ; // localhost[:port]
if ( /^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/ . test ( q ) ) return true ; // IPv4[:port]
const host = q . split ( /[/?#]/ ) [ 0 ] ; // strip path/query/frag
return host . includes ( "." ) && ! host . startsWith ( "." ) && ! host . endsWith ( "." ) ; // dotted host
}
2026-09-15 01:36:10 +02:00
// 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 ; }
}
2026-07-29 13:54:34 +02:00
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS _DEFAULTS = {
2026-07-30 22:00:01 +02:00
webrtcMode : "public_only" , // WebRTC IP policy: default | public_only | public_private | disable_udp
2026-07-29 13:54:34 +02:00
blockCamera : true , // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone : true , // deny microphone by default (also hides mic labels)
2026-07-30 23:11:28 +02:00
hideMediaDevices : true , // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
2026-07-29 13:54:34 +02:00
restoreSession : true , // reopen last session's tabs on launch
backgroundThrottle : true , // throttle inactive tabs / the window when unfocused
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage retention — nothing persists by default. Auto-clear on quit
// means a session leaves no trace on disk unless the user opts in per-type.
clearCookiesOnQuit : true , // drop cookies + logins + saved-form data
clearCacheOnQuit : true , // drop HTTP cache (images, scripts, etc.)
clearHistoryOnQuit : true , // drop navigation history + saved tabs
clearStorageOnQuit : true , // drop localStorage / IndexedDB / service workers / cache API
2026-07-30 19:29:32 +02:00
// Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value)
timezoneMode : "show" , timezoneValue : "Europe/Berlin" , // IANA zone for manual
2026-07-30 23:11:28 +02:00
languageMode : "show" , languageSpoof : "en-US" , languageValue : "en-US" , // spoof = top-10 pick, manual = free text
locationMode : "hide" , locationRegion : "europe" , locationLat : "40.7128" , locationLon : "-74.0060" , // spoof by region, or manual coords
2026-08-31 16:32:16 +02:00
searchEngine : "startpage" , // default search engine (built-in id or a custom id)
2026-08-06 01:41:31 +02:00
installedEngines : DEFAULT _ENABLED . slice ( ) , // built-in engines added to the user's list (visible in Settings)
enabledEngines : DEFAULT _ENABLED . slice ( ) , // subset that's currently toggled on (shown in the toolbar dropdown)
2026-07-30 23:26:00 +02:00
engineOrder : [ ] , // user-defined display order of engine ids (empty = natural)
2026-07-30 20:58:45 +02:00
customEngines : [ ] , // user-added: [{ id, name, url-with-%s }]
2026-07-30 22:00:01 +02:00
theme : "dark" , // dark | light | system — drives prefers-color-scheme in all views
2026-08-01 01:26:51 +02:00
// BCNR/ICANN collision policy (see SilentMode/Argus/DESIGN-collision-modes.md):
// "bcnr-first" — BCNR wins collisions (default).
// "icann-first" — ICANN wins collisions; BCNR fills gaps.
// "soft" — "Open with…" prompt on collision, remembered per name/TLD.
collisionPolicy : "bcnr-first" ,
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// Add-on framework: ids the user has explicitly turned off. Installed but
// disabled add-ons are still discovered — they just never activate.
disabledAddons : [ ] ,
2026-08-31 16:00:34 +02:00
// 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 ,
2026-09-06 17:14:57 +02:00
// 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
2026-09-07 01:07:23 +02:00
// 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 ,
2026-09-09 00:51:05 +02:00
// 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
2026-07-29 13:54:34 +02:00
} ;
2026-07-30 22:00:01 +02:00
// Applying the theme via nativeTheme.themeSource makes prefers-color-scheme update
// in every renderer (chrome, settings, popover, page views) with no per-view IPC.
function applyTheme ( ) {
try { nativeTheme . themeSource = [ "dark" , "light" , "system" ] . includes ( settings . theme ) ? settings . theme : "dark" ; } catch { }
}
2026-07-30 19:29:32 +02:00
// Auto decoys used by "spoof" mode (plausible but not the user's real values).
const SPOOF = { tz : "America/New_York" , lang : "en-US" , lat : 40.7128 , lon : - 74.0060 } ;
2026-07-29 13:54:34 +02:00
let settings = { ... SETTINGS _DEFAULTS } ;
const settingsFile = ( ) => path . join ( app . getPath ( "userData" ) , "settings.json" ) ;
function loadSettings ( ) {
try { if ( fs . existsSync ( settingsFile ( ) ) ) settings = { ... SETTINGS _DEFAULTS , ... JSON . parse ( fs . readFileSync ( settingsFile ( ) , "utf8" ) ) } ; }
catch ( e ) { console . error ( "settings load failed:" , e . message ) ; }
2026-08-31 16:00:34 +02:00
// 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 ) ) ;
Ship Theseus 0.2.4 7fd323a8 (home cards refresh + engine-picker sync + proxy auth support)
Setup 7fd323a87bd32b780e147de18e16ecd82f89960bbd8e9a619c5d25d374597cd2
Portable 3166e64cf56badd7b26c4c793dc79bbed6f9d6c48fbd467d97f845385b91b1dd
Home cards: DEFAULT_HOME_CARDS replaced with the .x sibling grid the
user asked for -- hello.bch, siatest.bch (the "types of BCDN" pair),
then silentmode.x / theseus.x / sirius.x / hephaestus.x /
prometheus.x / helios.x / hermes.x. Existing installs with a saved
home-cards.json keep their edits (defaults only seed fresh profiles).
Search engine picker sync: user reported the toolbar dropdown listed
engines as active that Settings > Search showed differently. Root
cause: settings.searchEngine could be pointing at an id not in the
currently-enabled set (stale settings.json after DEFAULT_ENABLED
changes across versions). loadSettings now normalizes on boot -- if
searchEngine isn't enabled, fall back to enabled[0]; and
installedEngines gets unioned with enabledEngines so the two lists
can't disagree in ways that make toolbar and Settings render
different rows.
Proxy auth support in the framework: setSessionProxy accepts
`{ proxyRules, auth: { username, password } }` or an inline
`socks5://user:pass@host:port` URL. When creds are present, the
handler strips them from the URL, installs a session#login listener
on the default session that answers with them, then calls setProxy.
Chromium's SOCKS5 client doesn't consume proxy auth (known Chromium
limitation), but HTTP proxies work; SOCKS-based extensions need to
gate by IP allowlist at their server. Log line masks the password.
Update chip note: the "download opens in a different browser" was
0.2.0-era behavior. 0.2.1 rewired it to session.downloadURL. Anyone
still seeing it needs to install 0.2.1+ once.
Deployed: scp + sia-upload both trees, verified HEAD 200 + manifest
0.2.4 live.
2026-08-31 17:24:39 +02:00
// 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 ) ; }
2026-07-29 13:54:34 +02:00
}
function saveSettings ( ) {
try { fs . writeFileSync ( settingsFile ( ) , JSON . stringify ( settings , null , 2 ) ) ; } catch ( e ) { console . error ( "settings save failed:" , e . message ) ; }
}
2026-07-30 08:16:55 +02:00
// ---- bookmarks / saved pages (userData/bookmarks.json) ----
let bookmarks = [ ] ;
const bookmarksFile = ( ) => path . join ( app . getPath ( "userData" ) , "bookmarks.json" ) ;
function loadBookmarks ( ) { try { if ( fs . existsSync ( bookmarksFile ( ) ) ) bookmarks = JSON . parse ( fs . readFileSync ( bookmarksFile ( ) , "utf8" ) ) ; } catch ( e ) { console . error ( "bookmarks load failed:" , e . message ) ; } }
function saveBookmarks ( ) { try { fs . writeFileSync ( bookmarksFile ( ) , JSON . stringify ( bookmarks , null , 2 ) ) ; } catch ( e ) { console . error ( "bookmarks save failed:" , e . message ) ; } }
function emitBookmarks ( ) { try { chrome ? . webContents . send ( "bookmarks" , bookmarks ) ; } catch { } }
2026-08-01 01:26:51 +02:00
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
// ---- 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 ( ) {
2026-09-15 22:30:29 +02:00
// 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 ;
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
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.
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
if ( updateAvailable ) { updateAvailable = null ; updateDownloadState = "idle" ; updateDownloadPath = null ; emitUpdateAvailable ( ) ; }
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
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 ) ) ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
const nextAvailable = {
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
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 ,
} ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
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 ( ) ;
}
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
emitUpdateAvailable ( ) ;
} catch { /* offline / manifest unreachable — silent */ }
}
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
// 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 ( ) ;
}
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
function emitUpdateAvailable ( ) {
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
const base = ( updateDismissedThisSession || ! updateAvailable ) ? null : updateAvailable ;
const payload = base ? {
... base ,
downloadState : updateDownloadState , // idle | downloading | ready | failed
downloadReceived : updateDownloadReceived ,
downloadTotal : updateDownloadTotal ,
} : null ;
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
try { chrome ? . webContents . send ( "update-available" , payload ) ; } catch { }
}
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// ---- 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 = [
2026-08-31 18:03:02 +02:00
{ 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" } ,
2026-08-31 18:38:09 +02:00
{ title : "Prometheus.X" , url : "https://prometheus.x/" , sub : "Decentralized App Marketplace." , badge : "App store" } ,
2026-08-31 18:03:02 +02:00
{ 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" } ,
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
] ;
2026-08-31 18:38:09 +02:00
// 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.
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
const homeCardsFile = ( ) => path . join ( app . getPath ( "userData" ) , "home-cards.json" ) ;
2026-08-31 18:38:09 +02:00
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
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
function loadHomeCards ( ) {
2026-08-31 18:38:09 +02:00
// Priority: user's local edits > cached remote copy > code defaults.
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
try {
if ( fs . existsSync ( homeCardsFile ( ) ) ) {
const v = JSON . parse ( fs . readFileSync ( homeCardsFile ( ) , "utf8" ) ) ;
2026-08-31 18:38:09 +02:00
if ( Array . isArray ( v ) && v . length ) return v ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
}
2026-08-31 18:38:09 +02:00
} 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 ) ; }
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ) ; }
}
2026-08-31 18:38:09 +02:00
// 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 */ }
}
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ; }
}
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
// 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 ; }
}
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// ---- 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 ) ;
}
2026-08-01 01:26:51 +02:00
// ---- BCNR/ICANN collisions (userData/collisions.json) --------------------
// Per-name / per-TLD "always use X" overrides for soft "Open with…" mode.
// See D:\Dev\SilentMode\Argus\DESIGN-collision-modes.md for the full model.
let collisions = { byName : { } , byTld : { } } ;
const collisionsFile = ( ) => path . join ( app . getPath ( "userData" ) , "collisions.json" ) ;
function loadCollisions ( ) {
try { if ( fs . existsSync ( collisionsFile ( ) ) ) collisions = { byName : { } , byTld : { } , ... JSON . parse ( fs . readFileSync ( collisionsFile ( ) , "utf8" ) ) } ; }
catch ( e ) { console . error ( "collisions load failed:" , e . message ) ; }
}
function saveCollisions ( ) {
try { fs . writeFileSync ( collisionsFile ( ) , JSON . stringify ( collisions , null , 2 ) ) ; } catch ( e ) { console . error ( "collisions save failed:" , e . message ) ; }
}
// per-name > per-TLD > hard policy. Returns "bcnr" | "icann" | null (null = ask in soft).
function overrideFor ( host , tld ) {
const n = String ( host ) . toLowerCase ( ) ;
const t = String ( tld || "" ) . toLowerCase ( ) ;
if ( collisions . byName [ n ] ) return collisions . byName [ n ] ;
if ( collisions . byTld [ t ] ) return collisions . byTld [ t ] ;
return null ;
}
// Cached BCNR-native TLD list from tlds.bch. Registered names under a native TLD
// are NOT collision candidates (whole TLD belongs to BCNR); non-native = might collide.
let bcnrTlds = [ "bch" ] ;
function isBcnrNativeTld ( tld ) { return bcnrTlds . includes ( String ( tld || "" ) . toLowerCase ( ) ) ; }
function refreshBcnrTlds ( index ) {
try {
const raw = index ? . get ? . ( "tlds.bch" ) ? . records ? . tlds ;
if ( typeof raw === "string" ) {
const list = raw . split ( /\s+/ ) . filter ( Boolean ) . map ( ( s ) => s . toLowerCase ( ) ) ;
if ( list . length ) bcnrTlds = list ;
}
} catch { }
}
2026-08-02 14:43:31 +02:00
// (The old modal-based collisionPromptOnce() was removed 2026-08-02 — the
// prompt is now an in-tab full-page interstitial loaded from collision.html,
// wired via the bns://collision-choose/ handler in serveBns().)
2026-08-01 01:26:51 +02:00
function rememberCollision ( host , tld , choice , remember ) {
if ( choice !== "bcnr" && choice !== "icann" ) return ;
if ( remember === "name" ) collisions . byName [ String ( host ) . toLowerCase ( ) ] = choice ;
else if ( remember === "tld" ) collisions . byTld [ String ( tld || "" ) . toLowerCase ( ) ] = choice ;
if ( remember !== "no" ) saveCollisions ( ) ;
}
2026-07-30 22:55:52 +02:00
function emitEngines ( ) {
try { chrome ? . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine } ) ; } catch { }
if ( epVisible ) try { enginePicker ? . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine , detected : activeTab ( ) ? . detected || null } ) ; } catch { }
}
2026-07-30 22:00:01 +02:00
// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter"
// Chrome extension provides, done natively (that extension's chrome.privacy API
// isn't available in Electron, and this is more reliable). Tor forces the strongest.
const WEBRTC _POLICIES = {
default : "default" , // allow all (may expose local IP)
public _only : "default_public_interface_only" , // only the default public interface
public _private : "default_public_and_private_interfaces" ,
disable _udp : "disable_non_proxied_udp" , // strongest (only proxied UDP)
} ;
2026-07-29 13:54:34 +02:00
function webrtcPolicy ( ) {
2026-07-30 22:00:01 +02:00
if ( torState === "on" ) return "disable_non_proxied_udp" ;
return WEBRTC _POLICIES [ settings . webrtcMode ] || "default_public_interface_only" ;
2026-07-29 13:54:34 +02:00
}
2026-07-30 19:29:32 +02:00
// ---- anti-fingerprinting: timezone + language + location ----
// Show/Hide/Spoof/Manual. Effective override, or null = "show" (real value).
2026-07-29 13:54:34 +02:00
function effTimezone ( ) {
2026-07-30 19:29:32 +02:00
switch ( settings . timezoneMode ) {
case "hide" : return "UTC" ;
case "spoof" : return SPOOF . tz ;
case "manual" : return settings . timezoneValue || "UTC" ;
default : return null ;
}
2026-07-29 13:54:34 +02:00
}
function effLocale ( ) {
2026-07-30 19:29:32 +02:00
switch ( settings . languageMode ) {
case "hide" : return "en-US" ;
2026-07-30 23:11:28 +02:00
case "spoof" : return settings . languageSpoof || SPOOF . lang ; // chosen from the top-languages list
2026-07-30 19:29:32 +02:00
case "manual" : return settings . languageValue || "en-US" ;
default : return null ;
}
}
2026-07-30 23:11:28 +02:00
// Representative coordinates per world region — used when the spoofed location is
// set to a region rather than exact coordinates (a major city stands in for each).
const REGIONS = {
europe : { lat : 52.5200 , lon : 13.4050 } , // Berlin
asia : { lat : 35.6762 , lon : 139.6503 } , // Tokyo
north _america : { lat : 40.7128 , lon : - 74.0060 } , // New York
south _america : { lat : - 23.5505 , lon : - 46.6333 } , // São Paulo
africa : { lat : - 1.2921 , lon : 36.8219 } , // Nairobi
middle _east : { lat : 25.2048 , lon : 55.2708 } , // Dubai
australia : { lat : - 33.8688 , lon : 151.2093 } , // Sydney
} ;
2026-07-30 19:29:32 +02:00
// Geolocation: null = show (real, allowed); "deny" = hide (blocked);
2026-07-30 23:11:28 +02:00
// {lat,lon} = spoof (region-based) / manual (exact) — overridden in-page.
2026-07-30 19:29:32 +02:00
function effLocation ( ) {
const m = settings . locationMode ;
if ( m === "hide" ) return "deny" ;
2026-07-30 23:11:28 +02:00
if ( m === "spoof" ) { const r = REGIONS [ settings . locationRegion ] || REGIONS . europe ; return { lat : r . lat , lon : r . lon } ; }
2026-07-30 19:29:32 +02:00
if ( m === "manual" ) return { lat : Number ( settings . locationLat ) || 0 , lon : Number ( settings . locationLon ) || 0 } ;
return null ; // show
2026-07-29 13:54:34 +02:00
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint ( wc ) {
try {
if ( ! wc . debugger . isAttached ( ) ) wc . debugger . attach ( "1.3" ) ;
const tz = effTimezone ( ) ;
await wc . debugger . sendCommand ( "Emulation.setTimezoneOverride" , { timezoneId : tz || "" } ) ;
const loc = effLocale ( ) ;
await wc . debugger . sendCommand ( "Emulation.setLocaleOverride" , loc ? { locale : loc } : { } ) ;
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc . debugger . sendCommand ( "Page.enable" ) ;
if ( wc . _langScript ) {
try { await wc . debugger . sendCommand ( "Page.removeScriptToEvaluateOnNewDocument" , { identifier : wc . _langScript } ) ; } catch { }
wc . _langScript = null ;
}
2026-07-30 19:29:32 +02:00
// Build one injected script covering navigator.language(s) and geolocation.
let src = "" ;
2026-07-29 13:54:34 +02:00
if ( loc ) {
const langs = JSON . stringify ( [ loc , loc . split ( "-" ) [ 0 ] ] ) ;
2026-07-30 19:29:32 +02:00
src += ` Object.defineProperty(navigator,'language',{get:()=> ${ JSON . stringify ( loc ) } ,configurable:true}); ` +
` Object.defineProperty(navigator,'languages',{get:()=> ${ langs } ,configurable:true}); ` ;
}
const geo = effLocation ( ) ;
if ( geo && geo !== "deny" ) { // spoof/manual: override the reported coordinates
const pos = ` {coords:{latitude: ${ geo . lat } ,longitude: ${ geo . lon } ,accuracy:100,altitude:null,altitudeAccuracy:null,heading:null,speed:null},timestamp:Date.now()} ` ;
src += ` try{const p=()=>( ${ pos } );if(navigator.geolocation){navigator.geolocation.getCurrentPosition=(ok)=>{try{ok(p())}catch(e){}};navigator.geolocation.watchPosition=(ok)=>{try{ok(p())}catch(e){}return 0};}}catch(e){} ` ;
}
2026-07-30 23:11:28 +02:00
// Media-device privacy: Chromium leaks audiooutput (speaker) labels + deviceIds
// via enumerateDevices even when camera/mic are blocked. Like Firefox, blank
// every device's label/deviceId/groupId and collapse to one entry per kind.
if ( settings . hideMediaDevices ) {
src += ` try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){} ` ;
}
2026-07-30 19:29:32 +02:00
if ( src ) {
2026-07-29 13:54:34 +02:00
const res = await wc . debugger . sendCommand ( "Page.addScriptToEvaluateOnNewDocument" , { source : src } ) ;
wc . _langScript = res . identifier ;
try { await wc . executeJavaScript ( src ) ; } catch { } // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll ( ) { for ( const t of tabs ) applyFingerprint ( t . view . webContents ) ; }
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage retention — Chromium/Electron sessions accumulate cookies, HTTP
// cache, localStorage, IndexedDB, service workers, cache API by default.
// This wipes whichever the caller asked for. The `storages` list mirrors
// Chromium's clearStorageData taxonomy — we group them into a small user-
// facing bucket ("cookies" / "cache" / "storage") so settings stay simple.
async function clearBrowsingData ( { cookies = false , cache = false , storage = false } = { } ) {
const ses = session . defaultSession ;
if ( cache ) { try { await ses . clearCache ( ) ; } catch ( e ) { console . warn ( "clearCache:" , e . message ) ; } }
const storages = [ ] ;
if ( cookies ) storages . push ( "cookies" ) ;
if ( storage ) storages . push ( "localstorage" , "indexdb" , "serviceworkers" , "cachestorage" , "shadercache" ) ;
if ( storages . length ) {
try { await ses . clearStorageData ( { storages } ) ; }
catch ( e ) { console . warn ( "clearStorageData:" , e . message ) ; }
}
// navigation history lives in each webContents; drop it too when history-clear was asked.
// (called separately by the before-quit hook, since history-clear also deletes session.json)
}
async function clearHistoryNow ( ) {
for ( const t of tabs ) {
try { t . view . webContents . navigationHistory . clear ( ) ; } catch { }
}
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// Address-bar suggestions history — wipe both in-memory + on-disk.
history = [ ] ;
clearTimeout ( historySaveTimer ) ; historySaveTimer = null ;
try { fs . unlinkSync ( historyFile ( ) ) ; } catch { }
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
}
2026-09-08 01:04:27 +02:00
// 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.
function stockChromeUA ( ) {
try {
return session . defaultSession . getUserAgent ( )
. replace ( / *theseus-navigator\/\S+/i , "" )
. replace ( / *Electron\/\S+/i , "" ) ;
} catch { return null ; }
}
2026-07-29 13:54:34 +02:00
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage ( ) {
const loc = effLocale ( ) || app . getLocale ( ) || "en-US" ;
try {
2026-09-08 01:04:27 +02:00
const ua = stockChromeUA ( ) || session . defaultSession . getUserAgent ( ) ;
2026-07-29 13:54:34 +02:00
session . defaultSession . setUserAgent ( ua , ` ${ loc } , ${ loc . split ( "-" ) [ 0 ] } ;q=0.8 ` ) ;
} catch { }
}
2026-09-09 03:27:41 +02:00
// 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 chromeVer = String ( process . versions . chrome || "130" ) ;
const major = chromeVer . split ( "." ) [ 0 ] || "130" ;
const brands = ` "Google Chrome";v=" ${ major } ", "Chromium";v=" ${ major } ", "Not?A_Brand";v="99" ` ;
const fullList = ` "Google Chrome";v=" ${ chromeVer } ", "Chromium";v=" ${ chromeVer } ", "Not?A_Brand";v="99.0.0.0" ` ;
const platform = process . platform === "darwin" ? '"macOS"'
: process . platform === "win32" ? '"Windows"'
: '"Linux"' ;
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 ) ; }
}
2026-07-29 13:54:34 +02:00
// ---- session restore + background throttling ----
const sessionFile = ( ) => path . join ( app . getPath ( "userData" ) , "session.json" ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// 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).
2026-07-29 13:54:34 +02:00
function saveSession ( ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
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 ) ; }
2026-07-29 13:54:34 +02:00
}
function loadSession ( ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
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 { } }
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 ( ( ) => { } ) ;
2026-07-29 13:54:34 +02:00
}
function applyThrottle ( ) {
for ( const t of tabs ) { try { t . view . webContents . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { } }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE _DEVICE = new Set ( [ "hid" , "serial" , "usb" , "bluetooth" , "midi" , "midiSysex" ] ) ;
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed ( kinds ) {
if ( kinds . includes ( "video" ) && settings . blockCamera ) return false ;
if ( kinds . includes ( "audio" ) && settings . blockMicrophone ) return false ;
return true ;
}
function applyPermissions ( ) {
const ses = session . defaultSession ;
ses . setPermissionRequestHandler ( ( _wc , permission , callback , details ) => {
if ( permission === "media" ) return callback ( mediaAllowed ( details ? . mediaTypes || [ ] ) ) ;
2026-07-30 19:29:32 +02:00
if ( permission === "geolocation" ) return callback ( effLocation ( ) !== "deny" ) ; // allow unless "hide"
2026-07-29 13:54:34 +02:00
if ( SENSITIVE _DEVICE . has ( permission ) ) return callback ( false ) ;
callback ( true ) ; // benign UX permissions (fullscreen, pointerLock, …)
} ) ;
ses . setPermissionCheckHandler ( ( _wc , permission , _origin , details ) => {
if ( permission === "media" ) {
if ( details ? . mediaType === "video" ) return ! settings . blockCamera ;
if ( details ? . mediaType === "audio" ) return ! settings . blockMicrophone ;
return ! ( settings . blockCamera && settings . blockMicrophone ) ;
}
2026-07-30 19:29:32 +02:00
if ( permission === "geolocation" ) return effLocation ( ) !== "deny" ;
2026-07-29 13:54:34 +02:00
if ( SENSITIVE _DEVICE . has ( permission ) ) return false ;
return true ;
} ) ;
}
2026-08-05 18:38:38 +02:00
// Cookie shim for cross-site embeds. Sites like the faucet hub's captcha-gated testnet
// faucets set session cookies with no SameSite attribute; Chromium defaults those to
// Lax and withholds them inside cross-site iframes, so cookie-bound captcha endpoints
// fail (tbch.googol.cash /captcha 500s without its session cookie). Rewriting their
// Set-Cookie to SameSite=None; Secure makes the cookie frame-eligible. Allowlist only —
// SameSite is CSRF protection, never relax it globally. NOTE: Electron keeps a single
// onHeadersReceived listener per session; if another is ever added, merge them.
const EMBED _COOKIE _SITES = [ "https://tbch.googol.cash/*" , "https://signetfaucet.com/*" ] ;
function applyEmbedCookieShim ( ) {
session . defaultSession . webRequest . onHeadersReceived ( { urls : EMBED _COOKIE _SITES } , ( details , callback ) => {
const headers = details . responseHeaders || { } ;
for ( const key of Object . keys ( headers ) ) {
if ( key . toLowerCase ( ) !== "set-cookie" ) continue ;
headers [ key ] = headers [ key ] . map ( ( c ) => ( /;\s*samesite=/i . test ( c ) ? c : c + "; SameSite=None; Secure" ) ) ;
}
callback ( { responseHeaders : headers } ) ;
} ) ;
}
2026-07-29 13:54:34 +02:00
protocol . registerSchemesAsPrivileged ( [
{ scheme : "bns" , privileges : { standard : true , secure : true , supportFetchAPI : true , stream : true } } ,
] ) ;
let resolver ;
async function getResolver ( ) {
if ( ! resolver ) resolver = await import ( ` file:// ${ RESOLVER . replace ( /\\/g , "/" ) } ` ) ;
return resolver ;
}
// ---- Tor (optional onion routing, toggled from the UI) ----
// IP privacy, not full anonymity: this browser can still be fingerprinted.
const TOR _PORT = 9152 ;
const TOR _BIN = path . join ( RES _DIR , "tor" , "tor" , "tor.exe" ) ;
const TOR _GEOIP = path . join ( RES _DIR , "tor" , "data" , "geoip" ) ;
const TOR _GEOIP6 = path . join ( RES _DIR , "tor" , "data" , "geoip6" ) ;
let torProc = null , torState = "off" ;
let torWsAgent = null ;
let SocksProxyAgent ;
async function loadSocks ( ) { if ( ! SocksProxyAgent ) ( { SocksProxyAgent } = await import ( "socks-proxy-agent" ) ) ; }
function sendTor ( ) { try { chrome ? . webContents . send ( "tor" , { state : torState } ) ; } catch { } }
async function startTor ( ) {
if ( torProc ) return ;
torState = "connecting" ; sendTor ( ) ;
await loadSocks ( ) ;
const dataDir = path . join ( app . getPath ( "userData" ) , "tor-data" ) ;
torProc = spawn ( TOR _BIN , [ "--SocksPort" , String ( TOR _PORT ) , "--ControlPort" , "0" ,
"--DataDirectory" , dataDir , "--GeoIPFile" , TOR _GEOIP , "--GeoIPv6File" , TOR _GEOIP6 ] , { windowsHide : true } ) ;
torProc . stdout . on ( "data" , ( d ) => { if ( /Bootstrapped 100%/ . test ( d . toString ( ) ) ) torReady ( ) ; } ) ;
torProc . stderr . on ( "data" , ( ) => { } ) ;
torProc . on ( "exit" , ( ) => { torProc = null ; if ( torState !== "off" ) torOff ( ) ; } ) ;
}
function torReady ( ) {
torState = "on" ;
torWsAgent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
session . defaultSession . setProxy ( { proxyRules : ` socks5://127.0.0.1: ${ TOR _PORT } ` } ) ;
applyWebRTCPolicy ( ) ;
sendTor ( ) ;
}
function torOff ( ) {
torState = "off" ; torWsAgent = null ;
session . defaultSession . setProxy ( { proxyRules : "" } ) ;
applyWebRTCPolicy ( ) ;
sendTor ( ) ;
}
function stopTor ( ) { torOff ( ) ; if ( torProc ) { try { torProc . kill ( ) ; } catch { } torProc = null ; } }
// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy
// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the
// way the Tor Browser does). This is the usual reason a site still sees your IP.
function applyWebRTCPolicy ( ) {
const policy = webrtcPolicy ( ) ;
for ( const t of tabs ) { try { t . view . webContents . setWebRTCIPHandlingPolicy ( policy ) ; } catch { } }
}
class TorWebSocket extends WebSocket { constructor ( url , opts ) { super ( url , { agent : torWsAgent , ... opts } ) ; } }
const currentWS = ( ) => ( torState === "on" ? TorWebSocket : WebSocket ) ;
function nodeRequest ( urlStr , { method = "GET" , headers = { } , agent } = { } ) {
return new Promise ( ( resolve , reject ) => {
const u = new URL ( urlStr ) ;
const lib = u . protocol === "https:" ? https : http ;
const req = lib . request ( u , { method , headers , agent } , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ; req . end ( ) ;
} ) ;
}
async function contentFetch ( url , init = { } ) {
if ( torState === "on" ) { await loadSocks ( ) ; return nodeRequest ( url , { ... init , agent : new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) } ) ; }
const r = await fetch ( url , init ) ;
return { status : r . status , contentType : r . headers . get ( "content-type" ) , buffer : Buffer . from ( await r . arrayBuffer ( ) ) } ;
}
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
// 1. It follows the site's HTTP→HTTPS 301 into `https://<name>.<tld>/`, which
// isn't in ICANN DNS → "fetch failed".
// 2. It validates TLS against the public CA store, but BNS certs are signed
// by per-machine BNS root CAs — the trust anchor is the on-chain `tls`
// record's SHA-256 fingerprint, which we pin against here. See
// Argus/src/lib/ca.js for the underlying trust model, and the parallel
// implementation in Argus/src/gateway/public-gateway.mjs — keep both in
// step. This code path also runs through Tor when Tor is on.
function certFp ( cert ) {
const fp = cert && cert . fingerprint256 ;
return fp ? fp . toLowerCase ( ) . replace ( /:/g , "" ) : "" ;
}
async function pinnedHttpsGet ( ip , port , servername , reqPath , expectedFp ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port , servername , method : "GET" , path : reqPath ,
headers : { host : servername , "user-agent" : "theseus/1" } ,
} ;
opts [ "rejectUnauthorized" ] = false ; // fingerprint pin below is the gate.
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = https . request ( opts , ( res ) => {
const gotFp = certFp ( res . socket . getPeerCertificate ( false ) ) ;
if ( gotFp !== expectedFp ) {
res . socket . destroy ( ) ;
return reject ( new Error ( ` tls fingerprint mismatch for ${ servername } : got ${ gotFp } , expected ${ expectedFp } ` ) ) ;
}
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . setTimeout ( 15000 , ( ) => { req . destroy ( new Error ( "tls request timeout" ) ) ; } ) ;
req . on ( "error" , reject ) ;
req . end ( ) ;
} ) ;
}
async function httpGetByIp ( ip , reqPath , hostHeader ) {
const useTor = torState === "on" ;
if ( useTor ) await loadSocks ( ) ;
return new Promise ( ( resolve , reject ) => {
const opts = {
host : ip , port : 80 , method : "GET" , path : reqPath ,
headers : { host : hostHeader , "user-agent" : "theseus/1" } ,
} ;
if ( useTor ) opts . agent = new SocksProxyAgent ( ` socks5h://127.0.0.1: ${ TOR _PORT } ` ) ;
const req = http . request ( opts , ( res ) => {
const chunks = [ ] ;
res . on ( "data" , ( c ) => chunks . push ( c ) ) ;
res . on ( "end" , ( ) => resolve ( { status : res . statusCode , contentType : res . headers [ "content-type" ] , buffer : Buffer . concat ( chunks ) } ) ) ;
} ) ;
req . on ( "error" , reject ) ; req . end ( ) ;
} ) ;
}
// Compose: pinned HTTPS if the on-chain `tls` fingerprint is available, else
// plain HTTP by IP. No silent HTTP fallback on pin failure — a mismatch means
// "not the site the chain says it is" and returning HTTP anyway would defeat
// the pin.
async function ipRequest ( ip , reqPath , hostHeader , tlsFingerprint ) {
if ( tlsFingerprint ) return await pinnedHttpsGet ( ip , 443 , hostHeader , reqPath , String ( tlsFingerprint ) . toLowerCase ( ) ) ;
return await httpGetByIp ( ip , reqPath , hostHeader ) ;
}
2026-07-30 22:55:52 +02:00
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
async function fetchOpenSearch ( href ) {
try {
const r = await contentFetch ( href , { } ) ;
const xml = r . buffer . toString ( "utf8" ) ;
const nameM = xml . match ( /<ShortName>([^<]+)<\/ShortName>/i ) ;
const urlM = xml . match ( /<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i )
|| xml . match ( /<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i ) ;
if ( ! urlM ) return null ;
const template = urlM [ 1 ] . replace ( /\{searchTerms\??\}/gi , "%s" ) . replace ( /\{[^}]*\}/g , "" ) ; // drop other {params}
if ( ! template . includes ( "%s" ) || ! /^https?:\/\//i . test ( template ) ) return null ;
return { name : ( nameM ? nameM [ 1 ] : new URL ( href ) . hostname ) . trim ( ) . slice ( 0 , 40 ) , url : template } ;
} catch { return null ; }
}
2026-07-29 13:54:34 +02:00
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
// build. The last discovered list is cached to disk and tried first next launch.
let electrumPool = null ;
let lastElectrumRefresh = 0 ;
const electrumFile = ( ) => path . join ( app . getPath ( "userData" ) , "electrum-servers.json" ) ;
const serverKey = ( s ) => ( typeof s === "string" ? s : s && s . url ) ;
function mergeServers ( preferred , rest ) {
const seen = new Set ( ) , out = [ ] ;
for ( const s of [ ... ( preferred || [ ] ) , ... ( rest || [ ] ) ] ) {
const k = serverKey ( s ) ;
if ( k && ! seen . has ( k ) ) { seen . add ( k ) ; out . push ( s ) ; }
}
return out ;
}
async function initElectrumPool ( ) {
const { CHIPNET _ELECTRUM } = await getResolver ( ) ;
let saved = [ ] ;
try { if ( fs . existsSync ( electrumFile ( ) ) ) saved = JSON . parse ( fs . readFileSync ( electrumFile ( ) , "utf8" ) ) ; } catch { }
electrumPool = mergeServers ( saved , CHIPNET _ELECTRUM ) ; // discovered first, seed always kept
}
async function refreshElectrumPool ( ) {
try {
const { fetchElectrumServers } = await getResolver ( ) ;
const found = await fetchElectrumServers ( { WebSocket : currentWS ( ) , directIP : true , electrum : electrumPool } ) ;
if ( found && found . length ) {
electrumPool = mergeServers ( found , electrumPool ) ;
try { fs . writeFileSync ( electrumFile ( ) , JSON . stringify ( found , null , 2 ) ) ; } catch { }
}
} catch { /* list unpublished or unreachable — keep the current pool */ }
}
function maybeRefreshElectrum ( ) {
if ( Date . now ( ) - lastElectrumRefresh < 30 * 60 * 1000 ) return ;
lastElectrumRefresh = Date . now ( ) ;
refreshElectrumPool ( ) ; // fire-and-forget
}
const entries = new Map ( ) ;
2026-07-30 19:29:32 +02:00
// Cached chain index. Building it (connect + fetch every beacon tx) is the slow
// part, and it was happening on EVERY navigation. Build once, reuse for lookups,
// and refresh in the background — so .bch pages open near-instantly after the first.
let sharedIndex = null , indexBuiltAt = 0 , indexBuilding = null ;
const INDEX _TTL = 45_000 ;
BNS: Sia+Nostr snapshot mirror, Theseus warm-start, VPS playbook, electrum.bch
Four independent pieces of one story — make cold-start .bch resolution fast,
survive individual electrum outages, and turn adding a Silent Mode BNS server
into a checklist run.
* Argus/src/lib/snapshot-name.js — pure, interpretation-free snapshot format
for the name beacon (raw history + verbose tx cache + deterministic root).
* Argus/src/publish-name-mirror.mjs — mirror the snapshot to Sia S3
(floating + content-addressed) and Nostr NIP-33 (kind 30078,
d-tag bns-name-list). Same pattern as publish-tld-mirror.mjs.
* Argus/src/snapshot-name-to-file.mjs — dump the snapshot to a file for
bundling. Writes to TheseusNavigator/snapshots/bns-name-snapshot.json.
* Argus/src/lib/resolver-web.js — new export buildIndexFromSnapshot() runs
the same REG/UPD reduction as buildIndex() but from a pre-fetched snapshot,
no electrum required. Warm-start path in Theseus depends on this.
* TheseusNavigator/main.js — warmFromSnapshot() and refreshSnapshotFromSia()
wired into app.whenReady(). First .bch navigation returns from a warm index
instead of waiting on a full electrum walk.
* TheseusNavigator/package.json — bundles the starter snapshot as an
extraResource so packaged builds ship with a floor.
* TheseusNavigator/snapshots/bns-name-snapshot.json — first starter snapshot
(70 beacon txs, root 8cc859aa…).
* VPS/BNS-SERVER-PLAYBOOK.md — turn-key checklist for spinning up a second
or third Silent Mode BNS server, distilled from what actually worked on
the silentmode box (BCHN 29 [chip]-not-[chipnet], multi-beacon indexer,
wss:// nginx, on-chain publish via electrum.bch, common gotchas table).
* Argus/src/register-electrum-list.mjs — publish the current chipnet
server pool as `el` on electrum.bch. Fits inside the 200-byte OP_RETURN
cap by sizing the record (drops fallback IP pins in favour of URL-only
tokens until they fit). NAME was chosen deliberately: two-part `electrum.bch`
normalises to the bare distinct name `electrum`, whereas the earlier
candidate `electrum.silentmode.bch` collapsed to silentmode.bch and would
have inherited/overwritten that name's records.
* Argus/src/revert-silentmode-bch.mjs — one-off recovery from the earlier
register-electrum-list revision that hit exactly that subdomain-collapse
bug (broadcast an UPD to silentmode.bch and clobbered its s3 record).
Records recovered from the local snapshot cache; safe to leave in the
tree as documentation of what happened.
2026-08-30 09:59:01 +02:00
// ---- 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 ; }
}
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 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 ;
BNS: Sia+Nostr snapshot mirror, Theseus warm-start, VPS playbook, electrum.bch
Four independent pieces of one story — make cold-start .bch resolution fast,
survive individual electrum outages, and turn adding a Silent Mode BNS server
into a checklist run.
* Argus/src/lib/snapshot-name.js — pure, interpretation-free snapshot format
for the name beacon (raw history + verbose tx cache + deterministic root).
* Argus/src/publish-name-mirror.mjs — mirror the snapshot to Sia S3
(floating + content-addressed) and Nostr NIP-33 (kind 30078,
d-tag bns-name-list). Same pattern as publish-tld-mirror.mjs.
* Argus/src/snapshot-name-to-file.mjs — dump the snapshot to a file for
bundling. Writes to TheseusNavigator/snapshots/bns-name-snapshot.json.
* Argus/src/lib/resolver-web.js — new export buildIndexFromSnapshot() runs
the same REG/UPD reduction as buildIndex() but from a pre-fetched snapshot,
no electrum required. Warm-start path in Theseus depends on this.
* TheseusNavigator/main.js — warmFromSnapshot() and refreshSnapshotFromSia()
wired into app.whenReady(). First .bch navigation returns from a warm index
instead of waiting on a full electrum walk.
* TheseusNavigator/package.json — bundles the starter snapshot as an
extraResource so packaged builds ship with a floor.
* TheseusNavigator/snapshots/bns-name-snapshot.json — first starter snapshot
(70 beacon txs, root 8cc859aa…).
* VPS/BNS-SERVER-PLAYBOOK.md — turn-key checklist for spinning up a second
or third Silent Mode BNS server, distilled from what actually worked on
the silentmode box (BCHN 29 [chip]-not-[chipnet], multi-beacon indexer,
wss:// nginx, on-chain publish via electrum.bch, common gotchas table).
* Argus/src/register-electrum-list.mjs — publish the current chipnet
server pool as `el` on electrum.bch. Fits inside the 200-byte OP_RETURN
cap by sizing the record (drops fallback IP pins in favour of URL-only
tokens until they fit). NAME was chosen deliberately: two-part `electrum.bch`
normalises to the bare distinct name `electrum`, whereas the earlier
candidate `electrum.silentmode.bch` collapsed to silentmode.bch and would
have inherited/overwritten that name's records.
* Argus/src/revert-silentmode-bch.mjs — one-off recovery from the earlier
register-electrum-list revision that hit exactly that subdomain-collapse
bug (broadcast an UPD to silentmode.bch and clobbered its s3 record).
Records recovered from the local snapshot cache; safe to leave in the
tree as documentation of what happened.
2026-08-30 09:59:01 +02:00
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 ;
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
currentSnapshotState = snap ;
BNS: Sia+Nostr snapshot mirror, Theseus warm-start, VPS playbook, electrum.bch
Four independent pieces of one story — make cold-start .bch resolution fast,
survive individual electrum outages, and turn adding a Silent Mode BNS server
into a checklist run.
* Argus/src/lib/snapshot-name.js — pure, interpretation-free snapshot format
for the name beacon (raw history + verbose tx cache + deterministic root).
* Argus/src/publish-name-mirror.mjs — mirror the snapshot to Sia S3
(floating + content-addressed) and Nostr NIP-33 (kind 30078,
d-tag bns-name-list). Same pattern as publish-tld-mirror.mjs.
* Argus/src/snapshot-name-to-file.mjs — dump the snapshot to a file for
bundling. Writes to TheseusNavigator/snapshots/bns-name-snapshot.json.
* Argus/src/lib/resolver-web.js — new export buildIndexFromSnapshot() runs
the same REG/UPD reduction as buildIndex() but from a pre-fetched snapshot,
no electrum required. Warm-start path in Theseus depends on this.
* TheseusNavigator/main.js — warmFromSnapshot() and refreshSnapshotFromSia()
wired into app.whenReady(). First .bch navigation returns from a warm index
instead of waiting on a full electrum walk.
* TheseusNavigator/package.json — bundles the starter snapshot as an
extraResource so packaged builds ship with a floor.
* TheseusNavigator/snapshots/bns-name-snapshot.json — first starter snapshot
(70 beacon txs, root 8cc859aa…).
* VPS/BNS-SERVER-PLAYBOOK.md — turn-key checklist for spinning up a second
or third Silent Mode BNS server, distilled from what actually worked on
the silentmode box (BCHN 29 [chip]-not-[chipnet], multi-beacon indexer,
wss:// nginx, on-chain publish via electrum.bch, common gotchas table).
* Argus/src/register-electrum-list.mjs — publish the current chipnet
server pool as `el` on electrum.bch. Fits inside the 200-byte OP_RETURN
cap by sizing the record (drops fallback IP pins in favour of URL-only
tokens until they fit). NAME was chosen deliberately: two-part `electrum.bch`
normalises to the bare distinct name `electrum`, whereas the earlier
candidate `electrum.silentmode.bch` collapsed to silentmode.bch and would
have inherited/overwritten that name's records.
* Argus/src/revert-silentmode-bch.mjs — one-off recovery from the earlier
register-electrum-list revision that hit exactly that subdomain-collapse
bug (broadcast an UPD to silentmode.bch and clobbered its s3 record).
Records recovered from the local snapshot cache; safe to leave in the
tree as documentation of what happened.
2026-08-30 09:59:01 +02:00
// 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 ;
}
}
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// ---- 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 ; } }
BNS: Sia+Nostr snapshot mirror, Theseus warm-start, VPS playbook, electrum.bch
Four independent pieces of one story — make cold-start .bch resolution fast,
survive individual electrum outages, and turn adding a Silent Mode BNS server
into a checklist run.
* Argus/src/lib/snapshot-name.js — pure, interpretation-free snapshot format
for the name beacon (raw history + verbose tx cache + deterministic root).
* Argus/src/publish-name-mirror.mjs — mirror the snapshot to Sia S3
(floating + content-addressed) and Nostr NIP-33 (kind 30078,
d-tag bns-name-list). Same pattern as publish-tld-mirror.mjs.
* Argus/src/snapshot-name-to-file.mjs — dump the snapshot to a file for
bundling. Writes to TheseusNavigator/snapshots/bns-name-snapshot.json.
* Argus/src/lib/resolver-web.js — new export buildIndexFromSnapshot() runs
the same REG/UPD reduction as buildIndex() but from a pre-fetched snapshot,
no electrum required. Warm-start path in Theseus depends on this.
* TheseusNavigator/main.js — warmFromSnapshot() and refreshSnapshotFromSia()
wired into app.whenReady(). First .bch navigation returns from a warm index
instead of waiting on a full electrum walk.
* TheseusNavigator/package.json — bundles the starter snapshot as an
extraResource so packaged builds ship with a floor.
* TheseusNavigator/snapshots/bns-name-snapshot.json — first starter snapshot
(70 beacon txs, root 8cc859aa…).
* VPS/BNS-SERVER-PLAYBOOK.md — turn-key checklist for spinning up a second
or third Silent Mode BNS server, distilled from what actually worked on
the silentmode box (BCHN 29 [chip]-not-[chipnet], multi-beacon indexer,
wss:// nginx, on-chain publish via electrum.bch, common gotchas table).
* Argus/src/register-electrum-list.mjs — publish the current chipnet
server pool as `el` on electrum.bch. Fits inside the 200-byte OP_RETURN
cap by sizing the record (drops fallback IP pins in favour of URL-only
tokens until they fit). NAME was chosen deliberately: two-part `electrum.bch`
normalises to the bare distinct name `electrum`, whereas the earlier
candidate `electrum.silentmode.bch` collapsed to silentmode.bch and would
have inherited/overwritten that name's records.
* Argus/src/revert-silentmode-bch.mjs — one-off recovery from the earlier
register-electrum-list revision that hit exactly that subdomain-collapse
bug (broadcast an UPD to silentmode.bch and clobbered its s3 record).
Records recovered from the local snapshot cache; safe to leave in the
tree as documentation of what happened.
2026-08-30 09:59:01 +02:00
// 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 */ }
}
2026-07-30 19:29:32 +02:00
async function ensureIndex ( force = false ) {
const { buildIndex } = await getResolver ( ) ;
2026-07-29 13:54:34 +02:00
if ( ! electrumPool ) await initElectrumPool ( ) ;
2026-07-30 19:29:32 +02:00
if ( ! force && sharedIndex && Date . now ( ) - indexBuiltAt < INDEX _TTL ) return sharedIndex ;
if ( indexBuilding ) return indexBuilding ; // dedupe concurrent builds
indexBuilding = buildIndex ( { WebSocket : currentWS ( ) , directIP : true , electrum : electrumPool } )
2026-08-02 11:39:00 +02:00
. then ( ( idx ) => { sharedIndex = idx ; indexBuiltAt = Date . now ( ) ; refreshBcnrTlds ( idx ) ; return idx ; } )
2026-07-30 19:29:32 +02:00
. finally ( ( ) => { indexBuilding = null ; } ) ;
// If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return ( sharedIndex && ! force ) ? sharedIndex : indexBuilding ;
}
async function resolveHost ( host ) {
const { normalizeName } = await getResolver ( ) ;
let key ; try { key = normalizeName ( host ) ; } catch { return null ; }
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 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 ( ) ) ;
2026-07-30 19:29:32 +02:00
let entry = idx . get ( key ) ? ? null ;
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 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 ;
}
}
2026-07-29 13:54:34 +02:00
if ( entry ) entries . set ( host . toLowerCase ( ) , { entry , host : host . toLowerCase ( ) } ) ;
2026-09-16 00:53:13 +02:00
// Signed DNS records ride alongside the on-chain answer — started here,
// never awaited (see attachDnsRecords).
if ( entry ) attachDnsRecords ( entry ) ;
2026-07-29 13:54:34 +02:00
maybeRefreshElectrum ( ) ;
return entry ;
}
const MIME = { html : "text/html; charset=utf-8" , htm : "text/html; charset=utf-8" , css : "text/css" , js : "text/javascript" ,
json : "application/json" , png : "image/png" , jpg : "image/jpeg" , jpeg : "image/jpeg" , gif : "image/gif" , svg : "image/svg+xml" ,
ico : "image/x-icon" , webp : "image/webp" , woff2 : "font/woff2" , woff : "font/woff" , txt : "text/plain" , wasm : "application/wasm" } ;
const guessType = ( p ) => MIME [ p . split ( "." ) . pop ( ) ? . toLowerCase ( ) ] || "application/octet-stream" ;
async function serveBns ( request ) {
const url = new URL ( request . url ) ;
const host = url . hostname . toLowerCase ( ) ;
const reqPath = decodeURIComponent ( url . pathname ) || "/" ;
2026-08-02 14:43:31 +02:00
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.)
2026-08-02 14:43:31 +02:00
2026-07-29 13:54:34 +02:00
let rec = entries . get ( host ) ;
if ( ! rec ) { try { await resolveHost ( host ) ; } catch { } rec = entries . get ( host ) ; }
if ( ! rec ) return new Response ( "NXDOMAIN: " + host , { status : 404 , headers : { "content-type" : "text/plain" } } ) ;
const r = rec . entry . records ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
// registry (see resolver-web `normalizeName`). `ip` semantics apply to the
// whole namespace via Host routing; `s3` semantics are exact-key per name.
// For a subdomain, `ip` is the unambiguous parent intent — prefer it. For the
// apex (host === entry.name), current priority stands. See public-gateway.mjs
// for the full argument; keep this in step with that file.
const isSubdomain = host !== rec . entry . name ;
const serveIp = async ( ) => {
gateway+Theseus: pin BNS ip-record fetch to on-chain tls fingerprint
Uncovered by the 2026-08-13 subdomain-inheritance fix: once
`checkers.game.x` correctly picked the parent's `ip` record instead of
`s3`, the ip branch itself failed. Two reasons:
1. `fetch("http://<ip>/", { headers: { host: name } })` follows the
site's :80→:443 redirect into `https://<name>.<tld>/`, which isn't
in ICANN DNS → "fetch failed".
2. The site's cert is signed by a per-machine BNS root, not a public
CA; standard TLS validation rejects it.
Both are fixed by connecting to the IP with SNI = name, pinning the
presented cert's SHA-256 against the on-chain `tls` record, and only
then issuing the HTTPS request over the same socket. The on-chain
fingerprint is the trust anchor BNS uses everywhere else (see
Argus/src/lib/ca.js).
Gateway (public-gateway.mjs): new pinnedHttpsGet + httpGet + ipRequest
helpers; case "ip" delegates. No silent HTTP fallback on pin failure
(a mismatch means "not the site the chain says it is").
Theseus (main.js): parallel port of the same helpers, Tor-aware
(routes through SocksProxyAgent when Tor is on). serveBns's inner
serveIp() delegates to ipRequest.
Verified live: `curl -sI https://navigate.st/bns/checkers.game.x/`
returns 200 OK with the checkers game (1,179,215 bytes, apex
`game.x` unchanged, served from Sia).
2026-08-16 20:28:58 +02:00
// See ipRequest above: HTTPS-with-fingerprint-pin against the on-chain `tls`
// record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS).
const up = await ipRequest ( r . ip , reqPath + url . search , host , r . tls ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return new Response ( up . buffer , { status : up . status , headers : { "content-type" : up . contentType || guessType ( reqPath ) } } ) ;
} ;
2026-09-07 00:18:07 +02:00
// `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 } } ) ;
} ;
2026-07-29 13:54:34 +02:00
try {
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
if ( isSubdomain && r . ip ) return await serveIp ( ) ;
2026-07-29 13:54:34 +02:00
if ( r . h ) { if ( reqPath === "/" ) return new Response ( r . h , { headers : { "content-type" : "text/html; charset=utf-8" } } ) ; return new Response ( "not found" , { status : 404 } ) ; }
if ( r . s3 ) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch ( ` ${ GATEWAY } /bns/ ${ host } ${ reqPath } ${ url . search } ` , { } ) ;
const ct = up . contentType && up . contentType !== "application/octet-stream"
? up . contentType : guessType ( reqPath === "/" ? "index.html" : reqPath ) ;
let body = up . buffer ;
if ( ct . includes ( "text/html" ) ) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer . from ( body . toString ( "utf8" ) . replace ( /<base\s+href="\/bns\/[^"]*">/i , "" ) , "utf8" ) ;
}
return new Response ( body , { status : up . status , headers : { "content-type" : ct } } ) ;
}
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
if ( r . ip ) return await serveIp ( ) ;
2026-09-07 00:18:07 +02:00
if ( ! isSubdomain && r . p ) return await serveP ( ) ;
2026-07-29 13:54:34 +02:00
if ( r . u ) return Response . redirect ( r . u , 302 ) ;
2026-09-16 00:53:13 +02:00
// 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 ) } } ) ;
}
2026-07-29 13:54:34 +02:00
return new Response ( JSON . stringify ( rec . entry , null , 2 ) , { headers : { "content-type" : "application/json" } } ) ;
2026-09-12 09:09:06 +02:00
} 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 > ` ;
2026-07-29 13:54:34 +02:00
}
// ---- window + tabs ----
2026-07-30 19:29:32 +02:00
let win , chrome ;
2026-07-30 08:16:55 +02:00
let CHROME _H = 84 ; // grows when an extra bar (Tor notice / BCNR offer) is shown
// Site-info popover: a floating overlay VIEW on top of the page content, so it
// never pushes the page down. Positioned under the address-bar badge on demand.
let popover , popVisible = false , popPos = { x : 8 , y : 90 } ;
2026-07-30 20:58:45 +02:00
const POP _W = 360 ; let popH = 210 ; // popH is updated to fit the popover's content
2026-07-30 22:55:52 +02:00
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker , epVisible = false , epPos = { x : 8 , y : 90 } ;
const EP _W = 250 ; let epH = 320 ;
2026-08-02 11:39:00 +02:00
// Downloads popover — a third floating overlay VIEW showing in-flight and
// recently-finished downloads. Anchored under the toolbar's download button.
let downloadsPop , dlVisible = false , dlPos = { x : 8 , y : 90 } ;
const DL _W = 340 ; let dlH = 240 ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ;
2026-08-29 15:49:30 +02:00
// 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 ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// 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 ;
2026-08-31 16:00:34 +02:00
// 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 ;
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable
User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
2026-09-08 22:18:41 +02:00
// 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 ) ;
}
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// 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 ;
Ship Theseus 0.2.4 7fd323a8 (home cards refresh + engine-picker sync + proxy auth support)
Setup 7fd323a87bd32b780e147de18e16ecd82f89960bbd8e9a619c5d25d374597cd2
Portable 3166e64cf56badd7b26c4c793dc79bbed6f9d6c48fbd467d97f845385b91b1dd
Home cards: DEFAULT_HOME_CARDS replaced with the .x sibling grid the
user asked for -- hello.bch, siatest.bch (the "types of BCDN" pair),
then silentmode.x / theseus.x / sirius.x / hephaestus.x /
prometheus.x / helios.x / hermes.x. Existing installs with a saved
home-cards.json keep their edits (defaults only seed fresh profiles).
Search engine picker sync: user reported the toolbar dropdown listed
engines as active that Settings > Search showed differently. Root
cause: settings.searchEngine could be pointing at an id not in the
currently-enabled set (stale settings.json after DEFAULT_ENABLED
changes across versions). loadSettings now normalizes on boot -- if
searchEngine isn't enabled, fall back to enabled[0]; and
installedEngines gets unioned with enabledEngines so the two lists
can't disagree in ways that make toolbar and Settings render
different rows.
Proxy auth support in the framework: setSessionProxy accepts
`{ proxyRules, auth: { username, password } }` or an inline
`socks5://user:pass@host:port` URL. When creds are present, the
handler strips them from the URL, installs a session#login listener
on the default session that answers with them, then calls setProxy.
Chromium's SOCKS5 client doesn't consume proxy auth (known Chromium
limitation), but HTTP proxies work; SOCKS-based extensions need to
gate by IP allowlist at their server. Log line masks the password.
Update chip note: the "download opens in a different browser" was
0.2.0-era behavior. 0.2.1 rewired it to session.downloadURL. Anyone
still seeing it needs to install 0.2.1+ once.
Deployed: scp + sia-upload both trees, verified HEAD 200 + manifest
0.2.4 live.
2026-08-31 17:24:39 +02:00
// 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 ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
const { AddonHost } = require ( "./addons-host.js" ) ;
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI
Decouples bundled-add-on updates from Theseus releases. An add-on
whose addon.json declares an updateURL can be republished at any time
without shipping a new Theseus installer; existing installs pick it up
on the next boot's +30 s background check.
Client flow (main-process only, no UI touchpoints in this commit):
initAddons()
├── promoteStagedUpdates() # promote signed stage if newer
├── seedBundledAddons() # bundle wins over on-disk if newer
└── AddonHost.discoverAndActivate()
30 s later:
└── checkAndStageUpdates() # fetch, verify, download, stage
Signature: Ed25519 over
"silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>",
verified against a hardcoded set of operator pubkeys living in
addon-update-pubkeys.js. Domain-separated so the operator key can't
be tricked into signing a message with a different purpose. Empty
pubkey array is the shipping default — checkAndStageUpdates() then
short-circuits and no outbound requests are made, which is the safe
posture until the operator ceremonies a key in.
Payload: gzipped tar, extracted with the system tar (present on
Win10 1803+, macOS, Linux). Path traversal defended by tar's default
refusal of `..` entries; the extracted manifest's id + version are
re-checked against the signed values before staging.
Staged updates go to <userData>/addons-updates-staged/<id>-<version>/.
Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's
backup dance: existing folder moves to
<userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local
edits survive.
New files:
- addon-updater.js — client
- addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate)
- scripts/generate-update-keypair.mjs — one-time keygen
- scripts/sign-addon-update.mjs — operator packager+signer
- docs/ADDON-UPDATES.md — operator brief + threat model
Wired into main.js at boot; screenshot add-on's addon.json advertises
the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
const addonUpdater = require ( "./addon-updater.js" ) ;
const { PUBKEYS _HEX : ADDON _UPDATE _PUBKEYS } = require ( "./addon-update-pubkeys.js" ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
function addonsUserDir ( ) { return path . join ( app . getPath ( "userData" ) , "addons" ) ; }
function addonsDataDir ( ) { return path . join ( app . getPath ( "userData" ) , "addons-data" ) ; }
2026-09-07 20:53:21 +02:00
function addonsBackupDir ( ) { return path . join ( app . getPath ( "userData" ) , "addons-backups" ) ; }
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI
Decouples bundled-add-on updates from Theseus releases. An add-on
whose addon.json declares an updateURL can be republished at any time
without shipping a new Theseus installer; existing installs pick it up
on the next boot's +30 s background check.
Client flow (main-process only, no UI touchpoints in this commit):
initAddons()
├── promoteStagedUpdates() # promote signed stage if newer
├── seedBundledAddons() # bundle wins over on-disk if newer
└── AddonHost.discoverAndActivate()
30 s later:
└── checkAndStageUpdates() # fetch, verify, download, stage
Signature: Ed25519 over
"silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>",
verified against a hardcoded set of operator pubkeys living in
addon-update-pubkeys.js. Domain-separated so the operator key can't
be tricked into signing a message with a different purpose. Empty
pubkey array is the shipping default — checkAndStageUpdates() then
short-circuits and no outbound requests are made, which is the safe
posture until the operator ceremonies a key in.
Payload: gzipped tar, extracted with the system tar (present on
Win10 1803+, macOS, Linux). Path traversal defended by tar's default
refusal of `..` entries; the extracted manifest's id + version are
re-checked against the signed values before staging.
Staged updates go to <userData>/addons-updates-staged/<id>-<version>/.
Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's
backup dance: existing folder moves to
<userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local
edits survive.
New files:
- addon-updater.js — client
- addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate)
- scripts/generate-update-keypair.mjs — one-time keygen
- scripts/sign-addon-update.mjs — operator packager+signer
- docs/ADDON-UPDATES.md — operator brief + threat model
Wired into main.js at boot; screenshot add-on's addon.json advertises
the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
function addonsStagedDir ( ) { return path . join ( app . getPath ( "userData" ) , "addons-updates-staged" ) ; }
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
function bundledAddonsDir ( ) { return path . join ( RES _DIR , "bundled-addons" ) ; }
// Copy bundled reference add-ons (shipped inside resources/) into the user's
2026-09-07 20:53:21 +02:00
// 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 ; }
}
2026-09-08 18:17:44 +02:00
// 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" ) ;
2026-09-08 18:42:00 +02:00
// 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 ) ) {
2026-09-08 18:17:44 +02:00
const backup = path . join ( backups , ` bchwallet-migrated- ${ stamp } ` ) ;
2026-09-08 18:42:00 +02:00
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/)" } ` ) ; }
2026-09-08 18:17:44 +02:00
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 ) ; }
}
}
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
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 ;
2026-09-07 20:53:21 +02:00
const from = path . join ( src , e . name ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
const target = path . join ( dst , e . name ) ;
2026-09-07 20:53:21 +02:00
const bundleVer = readAddonVersion ( from ) ;
if ( ! bundleVer ) continue ; // broken bundle — skip rather than corrupt user state
if ( fs . existsSync ( target ) ) {
const userVer = readAddonVersion ( target ) ;
2026-09-13 19:49:23 +02:00
// 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 ;
2026-09-07 20:53:21 +02:00
// 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 } ) ; }
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
catch ( err ) { console . warn ( ` [addons] seed ${ e . name } failed: ` , err ? . message ) ; }
}
}
function initAddons ( ) {
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI
Decouples bundled-add-on updates from Theseus releases. An add-on
whose addon.json declares an updateURL can be republished at any time
without shipping a new Theseus installer; existing installs pick it up
on the next boot's +30 s background check.
Client flow (main-process only, no UI touchpoints in this commit):
initAddons()
├── promoteStagedUpdates() # promote signed stage if newer
├── seedBundledAddons() # bundle wins over on-disk if newer
└── AddonHost.discoverAndActivate()
30 s later:
└── checkAndStageUpdates() # fetch, verify, download, stage
Signature: Ed25519 over
"silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>",
verified against a hardcoded set of operator pubkeys living in
addon-update-pubkeys.js. Domain-separated so the operator key can't
be tricked into signing a message with a different purpose. Empty
pubkey array is the shipping default — checkAndStageUpdates() then
short-circuits and no outbound requests are made, which is the safe
posture until the operator ceremonies a key in.
Payload: gzipped tar, extracted with the system tar (present on
Win10 1803+, macOS, Linux). Path traversal defended by tar's default
refusal of `..` entries; the extracted manifest's id + version are
re-checked against the signed values before staging.
Staged updates go to <userData>/addons-updates-staged/<id>-<version>/.
Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's
backup dance: existing folder moves to
<userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local
edits survive.
New files:
- addon-updater.js — client
- addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate)
- scripts/generate-update-keypair.mjs — one-time keygen
- scripts/sign-addon-update.mjs — operator packager+signer
- docs/ADDON-UPDATES.md — operator brief + threat model
Wired into main.js at boot; screenshot add-on's addon.json advertises
the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
// 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 ) ,
} ) ;
2026-09-08 18:17:44 +02:00
// Retire the pre-rename bundle layouts before reseeding so the fresh
// aegis/ + siawallet-less state is what AddonHost enumerates.
migrateAegisRename ( ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
seedBundledAddons ( ) ;
addonHost = new AddonHost ( {
addonsDir : addonsUserDir ( ) ,
dataDir : addonsDataDir ( ) ,
isDisabled : ( id ) => Array . isArray ( settings . disabledAddons ) && settings . disabledAddons . includes ( id ) ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
2026-08-31 16:00:34 +02:00
// 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.
Ship Theseus 0.2.4 7fd323a8 (home cards refresh + engine-picker sync + proxy auth support)
Setup 7fd323a87bd32b780e147de18e16ecd82f89960bbd8e9a619c5d25d374597cd2
Portable 3166e64cf56badd7b26c4c793dc79bbed6f9d6c48fbd467d97f845385b91b1dd
Home cards: DEFAULT_HOME_CARDS replaced with the .x sibling grid the
user asked for -- hello.bch, siatest.bch (the "types of BCDN" pair),
then silentmode.x / theseus.x / sirius.x / hephaestus.x /
prometheus.x / helios.x / hermes.x. Existing installs with a saved
home-cards.json keep their edits (defaults only seed fresh profiles).
Search engine picker sync: user reported the toolbar dropdown listed
engines as active that Settings > Search showed differently. Root
cause: settings.searchEngine could be pointing at an id not in the
currently-enabled set (stale settings.json after DEFAULT_ENABLED
changes across versions). loadSettings now normalizes on boot -- if
searchEngine isn't enabled, fall back to enabled[0]; and
installedEngines gets unioned with enabledEngines so the two lists
can't disagree in ways that make toolbar and Settings render
different rows.
Proxy auth support in the framework: setSessionProxy accepts
`{ proxyRules, auth: { username, password } }` or an inline
`socks5://user:pass@host:port` URL. When creds are present, the
handler strips them from the URL, installs a session#login listener
on the default session that answers with them, then calls setProxy.
Chromium's SOCKS5 client doesn't consume proxy auth (known Chromium
limitation), but HTTP proxies work; SOCKS-based extensions need to
gate by IP allowlist at their server. Log line masks the password.
Update chip note: the "download opens in a different browser" was
0.2.0-era behavior. 0.2.1 rewired it to session.downloadURL. Anyone
still seeing it needs to install 0.2.1+ once.
Deployed: scp + sia-upload both trees, verified HEAD 200 + manifest
0.2.4 live.
2026-08-31 17:24:39 +02:00
//
// 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.
2026-08-31 16:00:34 +02:00
setSessionProxy : async ( rules , addonId ) => {
const ses = session . defaultSession ;
Ship Theseus 0.2.4 7fd323a8 (home cards refresh + engine-picker sync + proxy auth support)
Setup 7fd323a87bd32b780e147de18e16ecd82f89960bbd8e9a619c5d25d374597cd2
Portable 3166e64cf56badd7b26c4c793dc79bbed6f9d6c48fbd467d97f845385b91b1dd
Home cards: DEFAULT_HOME_CARDS replaced with the .x sibling grid the
user asked for -- hello.bch, siatest.bch (the "types of BCDN" pair),
then silentmode.x / theseus.x / sirius.x / hephaestus.x /
prometheus.x / helios.x / hermes.x. Existing installs with a saved
home-cards.json keep their edits (defaults only seed fresh profiles).
Search engine picker sync: user reported the toolbar dropdown listed
engines as active that Settings > Search showed differently. Root
cause: settings.searchEngine could be pointing at an id not in the
currently-enabled set (stale settings.json after DEFAULT_ENABLED
changes across versions). loadSettings now normalizes on boot -- if
searchEngine isn't enabled, fall back to enabled[0]; and
installedEngines gets unioned with enabledEngines so the two lists
can't disagree in ways that make toolbar and Settings render
different rows.
Proxy auth support in the framework: setSessionProxy accepts
`{ proxyRules, auth: { username, password } }` or an inline
`socks5://user:pass@host:port` URL. When creds are present, the
handler strips them from the URL, installs a session#login listener
on the default session that answers with them, then calls setProxy.
Chromium's SOCKS5 client doesn't consume proxy auth (known Chromium
limitation), but HTTP proxies work; SOCKS-based extensions need to
gate by IP allowlist at their server. Log line masks the password.
Update chip note: the "download opens in a different browser" was
0.2.0-era behavior. 0.2.1 rewired it to session.downloadURL. Anyone
still seeing it needs to install 0.2.1+ once.
Deployed: scp + sia-upload both trees, verified HEAD 200 + manifest
0.2.4 live.
2026-08-31 17:24:39 +02:00
// Always clear any prior proxy-login handler before swapping.
if ( proxyLoginHandler ) { ses . off ( "login" , proxyLoginHandler ) ; proxyLoginHandler = null ; }
2026-08-31 16:00:34 +02:00
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 ;
}
Ship Theseus 0.2.4 7fd323a8 (home cards refresh + engine-picker sync + proxy auth support)
Setup 7fd323a87bd32b780e147de18e16ecd82f89960bbd8e9a619c5d25d374597cd2
Portable 3166e64cf56badd7b26c4c793dc79bbed6f9d6c48fbd467d97f845385b91b1dd
Home cards: DEFAULT_HOME_CARDS replaced with the .x sibling grid the
user asked for -- hello.bch, siatest.bch (the "types of BCDN" pair),
then silentmode.x / theseus.x / sirius.x / hephaestus.x /
prometheus.x / helios.x / hermes.x. Existing installs with a saved
home-cards.json keep their edits (defaults only seed fresh profiles).
Search engine picker sync: user reported the toolbar dropdown listed
engines as active that Settings > Search showed differently. Root
cause: settings.searchEngine could be pointing at an id not in the
currently-enabled set (stale settings.json after DEFAULT_ENABLED
changes across versions). loadSettings now normalizes on boot -- if
searchEngine isn't enabled, fall back to enabled[0]; and
installedEngines gets unioned with enabledEngines so the two lists
can't disagree in ways that make toolbar and Settings render
different rows.
Proxy auth support in the framework: setSessionProxy accepts
`{ proxyRules, auth: { username, password } }` or an inline
`socks5://user:pass@host:port` URL. When creds are present, the
handler strips them from the URL, installs a session#login listener
on the default session that answers with them, then calls setProxy.
Chromium's SOCKS5 client doesn't consume proxy auth (known Chromium
limitation), but HTTP proxies work; SOCKS-based extensions need to
gate by IP allowlist at their server. Log line masks the password.
Update chip note: the "download opens in a different browser" was
0.2.0-era behavior. 0.2.1 rewired it to session.downloadURL. Anyone
still seeing it needs to install 0.2.1+ once.
Deployed: scp + sia-upload both trees, verified HEAD 200 + manifest
0.2.4 live.
2026-08-31 17:24:39 +02:00
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 ) ;
}
2026-08-31 16:00:34 +02:00
try { await ses . setProxy ( opts ) ; } catch ( e ) { console . warn ( "proxy set failed:" , e ? . message ) ; }
} ,
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// 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 ) ) ;
} ,
2026-09-09 03:27:41 +02:00
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 ) ;
} ,
} ,
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
approvalModal : ( opts , addonId ) => showApprovalModal ( opts , addonId ) ,
emitToPanel : ( addonId , msg , payload ) => {
if ( ! sidebar || ! sidebarActivePanelId || ! sidebarActivePanelId . startsWith ( addonId + ":" ) ) return ;
try { sidebar . webContents . send ( "addon-event" , msg , payload ) ; } catch { }
} ,
hostRequire : ( name ) => require ( name ) ,
feat(theseus/bchwallet): receive + history — vault-derived keys, cashaddr, QR, electrum
Wallet core on mainnet:
- keys from api.vault.derive("bchwallet/mainnet/0") -> BIP32 m/44'/145'/0'
(@scure/bip32), never persisted; wiped on deactivate.
- lib/cashaddr.js (encode/decode + legacy Base58Check, spec vectors pass),
lib/keys.js (hash160, p2pkh, electrum scripthash, ECDSA DER + BIP-137
recoverable signing), lib/tx.js (serialization, SIGHASH_ALL|FORKID
digest, coin selection, fee estimate), lib/electrum.js (Fulcrum WSS
client with failover + subscriptions), lib/wallet.js (gap-limit scan,
balance, UTXOs, 25-tx history with per-tx deltas, cached public txs).
- qr.js: dependency-free QR encoder (byte mode, v1-10, EC M/L; verified
against jsQR).
- panel: balance header, Receive (QR, copy, next unused address, explorer),
History (deltas, confirmations, explorer links), Settings (derivation
path, electrum server list, xpub / approval-gated xprv reveal). Locked
and not-set-up vault states explained in-panel.
- host: api.import for ESM-only deps, api.openTab for explorer links; an
add-on whose activate() throws is no longer listed twice.
2026-09-06 02:46:41 +02:00
hostImport : ( name ) => import ( require ( "node:url" ) . pathToFileURL ( require . resolve ( name ) ) . href ) ,
openTab : ( url ) => { if ( win ) createTab ( url ) ; } ,
2026-09-09 02:05:46 +02:00
// 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 } ) ;
} ,
2026-09-14 02:30:51 +02:00
// 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 ) ,
} ) ;
return { report : result ? . report || [ ] , skipped : result ? . skipped || null , staged : listStagedAddons ( stagedDir ) } ;
} catch ( e ) {
console . warn ( "[addons] panel-driven check-updates failed:" , e ? . message || e ) ;
return { report : [ ] , skipped : "unexpected-error" , staged : [ ] } ;
}
} ,
restartApp : ( ) => { try { app . relaunch ( ) ; } catch { } app . quit ( ) ; } ,
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
// 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 } ` ) ;
2026-09-07 01:52:49 +02:00
console . log ( ` [addons] [ ${ addonId } ] openAddonTab -> ${ path . basename ( abs ) } ${ queryString ? "?" + queryString . slice ( 0 , 80 ) + ( queryString . length > 80 ? "…" : "" ) : "" } ` ) ;
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
createTab ( null , { addonFile : { absPath : abs , query : queryString || "" , addonId } } ) ;
} ,
2026-09-07 00:36:08 +02:00
// 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).
captureTab : async ( opts , addonId ) => {
2026-09-08 19:14:29 +02:00
// 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 ;
}
}
2026-09-07 00:36:08 +02:00
if ( ! t ) throw new Error ( "no active tab" ) ;
2026-09-08 19:14:29 +02:00
if ( t . addonId || t . settings ) {
throw new Error ( "no capturable tab — open a page you'd like to shoot first" ) ;
}
2026-09-07 00:36:08 +02:00
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 ) ) ;
2026-09-08 02:27:36 +02:00
// 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 { } }
2026-09-07 01:52:49 +02:00
}
}
2026-09-07 00:36:08 +02:00
if ( mode === "visible" ) {
2026-09-08 02:27:36 +02:00
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 } ;
2026-09-07 00:36:08 +02:00
}
if ( mode === "full" ) {
2026-09-08 02:27:36 +02:00
// Page.captureScreenshot with captureBeyondViewport does the whole
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
// 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 { } }
}
2026-09-07 00:36:08 +02:00
}
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.
2026-09-08 02:27:36 +02:00
const rectRaw = await wc . executeJavaScript ( src , true ) ;
if ( ! rectRaw || typeof rectRaw !== "object" ) {
2026-09-07 00:36:08 +02:00
console . log ( ` [addons] [ ${ addonId } ] captureTab region cancelled ` ) ;
return { dataUrl : "" , width : 0 , height : 0 , host , format , cancelled : true } ;
}
2026-09-08 02:27:36 +02:00
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 ) ) ,
2026-09-07 00:36:08 +02:00
} ;
2026-09-08 02:27:36 +02:00
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 } ;
2026-09-07 00:36:08 +02:00
}
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 } ;
} ,
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
} ) ;
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 ; }
}
2026-08-02 11:39:00 +02:00
// In-memory download list. Not persisted: closing the browser clears history
// (the files are still on disk; only the list of "recent downloads" is dropped).
const downloads = [ ] ; let nextDlId = 1 ; const dlItems = new Map ( ) ; // id -> DownloadItem
2026-07-29 13:54:34 +02:00
const tabs = [ ] ; // { id, view, title, url, prov }
let activeId = null , tabSeq = 0 ;
const tabById = ( id ) => tabs . find ( ( t ) => t . id === id ) ;
const activeTab = ( ) => tabById ( activeId ) ;
2026-09-08 19:14:29 +02:00
// 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 ;
2026-07-29 13:54:34 +02:00
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
2026-08-02 11:39:00 +02:00
// Enrich prov with a "collision candidate?" flag so the popover switcher can
// know whether flipping BCNR<->ICANN is meaningful. A BCNR-native TLD (in
// tlds.bch) is NOT a collision candidate — the whole TLD is BCNR's.
function decorate ( prov ) {
if ( ! prov || ! prov . tld ) return prov ;
return { ... prov , bcnrNativeTld : isBcnrNativeTld ( prov . tld ) } ;
}
2026-07-29 13:54:34 +02:00
function pushNav ( prov ) {
2026-08-02 11:39:00 +02:00
const p = decorate ( prov ) ;
chrome ? . webContents . send ( "nav" , p ) ;
if ( popVisible ) popover ? . webContents . send ( "site-info" , p ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
emitPwAvailability ( ) ;
2026-07-29 13:54:34 +02:00
}
function layout ( ) {
if ( ! win ) return ;
const { width , height } = win . getContentBounds ( ) ;
chrome . setBounds ( { x : 0 , y : 0 , width , height : CHROME _H } ) ;
2026-07-30 19:29:32 +02:00
const bodyH = Math . max ( 0 , height - CHROME _H ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// 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.
2026-08-31 16:00:34 +02:00
const sideW = sidebarVisible ? sidebarW : 0 ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
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 } ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// 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 } ) ;
2026-07-30 08:16:55 +02:00
positionPopover ( ) ;
2026-07-30 22:55:52 +02:00
positionEnginePicker ( ) ;
2026-08-02 11:39:00 +02:00
positionDownloads ( ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
positionAddressPicker ( ) ;
positionPwFill ( ) ;
2026-08-29 15:49:30 +02:00
if ( linkStatusVisible ) positionLinkStatus ( ) ;
2026-07-30 08:16:55 +02:00
}
function positionPopover ( ) {
if ( ! popover ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( popPos . x , width - POP _W - 6 ) ) ;
2026-07-30 20:58:45 +02:00
popover . setBounds ( { x , y : popPos . y , width : POP _W , height : popH } ) ;
2026-07-30 08:16:55 +02:00
}
function showPopover ( show ) {
if ( ! popover ) return ;
if ( show ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
loadOverlays ( ) ;
2026-07-30 08:16:55 +02:00
positionPopover ( ) ;
// Re-add to the top of the z-order (tabs added later would otherwise cover it).
win . contentView . removeChildView ( popover ) ;
win . contentView . addChildView ( popover ) ;
popover . setVisible ( true ) ; popVisible = true ;
2026-08-02 11:39:00 +02:00
popover . webContents . send ( "site-info" , decorate ( activeTab ( ) ? . prov ) || { kind : "home" } ) ;
2026-07-30 08:16:55 +02:00
} else { popover . setVisible ( false ) ; popVisible = false ; }
2026-07-29 13:54:34 +02:00
}
2026-07-30 22:55:52 +02:00
function positionEnginePicker ( ) {
if ( ! enginePicker ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( epPos . x , width - EP _W - 6 ) ) ;
enginePicker . setBounds ( { x , y : epPos . y , width : EP _W , height : epH } ) ;
}
function showEnginePicker ( show ) {
if ( ! enginePicker ) return ;
if ( show ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
loadOverlays ( ) ;
2026-07-30 22:55:52 +02:00
positionEnginePicker ( ) ;
win . contentView . removeChildView ( enginePicker ) ;
win . contentView . addChildView ( enginePicker ) ;
enginePicker . setVisible ( true ) ; epVisible = true ;
enginePicker . webContents . send ( "engines" , { engines : enabledEnginesList ( ) , current : settings . searchEngine , detected : activeTab ( ) ? . detected || null } ) ;
} else { enginePicker . setVisible ( false ) ; epVisible = false ; }
}
2026-08-02 11:39:00 +02:00
function positionDownloads ( ) {
if ( ! downloadsPop ) return ;
const { width } = win . getContentBounds ( ) ;
const x = Math . max ( 6 , Math . min ( dlPos . x , width - DL _W - 6 ) ) ;
downloadsPop . setBounds ( { x , y : dlPos . y , width : DL _W , height : dlH } ) ;
}
function showDownloads ( show ) {
if ( ! downloadsPop ) return ;
if ( show ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
loadOverlays ( ) ;
2026-08-02 11:39:00 +02:00
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 ; }
}
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
function positionAddressPicker ( ) {
if ( ! addressPicker ) 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 ) 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 } ) ;
}
2026-08-29 15:49:30 +02:00
function positionLinkStatus ( ) {
if ( ! linkStatus || ! win ) return ;
const { width , height } = win . getContentBounds ( ) ;
feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill
- Link-status pill: it measured its own width inside a view already
capped at 100 px, so it could never grow and long hrefs were cut short.
An off-screen twin now reports the natural width; main caps it to the
tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
pushed the registry chips and the star out past the bar. #url now has
min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
icon (spiral + tail) at the end of the bar: acid when served from BCDN,
blue for ICANN, caret when the name exists on both. Click opens a
native menu (registry-menu-popup): switch registry, remember per name /
per TLD, forget choices, collision policy, and a jump to the Plug-ins
settings section. Reuses the existing switch / remember / policy paths
(collision-switch body extracted to switchRegistry, open-settings to
openSettingsTab). preload's openSettings now forwards a section slug.
2026-09-09 11:40:46 +02:00
// 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 ) ) ;
2026-08-29 15:49:30 +02:00
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 ) 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 { }
}
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// 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 ) ; }
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 { }
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
try { chrome ? . webContents . send ( "sidebar-state" , { visible : true , active : sidebarActivePanelId , panels , toolbarMenus : addonHost ? addonHost . getToolbarMenus ( ) : [ ] } ) ; } catch { }
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
} else {
sidebarVisible = false ;
sidebar . setVisible ( false ) ;
layout ( ) ;
try { sidebar . webContents . send ( "sidebar-visibility" , false ) ; } catch { }
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
try { chrome ? . webContents . send ( "sidebar-state" , { visible : false , active : sidebarActivePanelId , panels , toolbarMenus : addonHost ? addonHost . getToolbarMenus ( ) : [ ] } ) ; } catch { }
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
}
}
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
function showPwFill ( show , matches ) {
if ( ! pwFillPop ) return ;
if ( show ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
loadOverlays ( ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
loadOverlays ( ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ; }
}
2026-08-02 11:39:00 +02:00
// 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 ( ) {
2026-09-07 01:52:49 +02:00
session . defaultSession . on ( "will-download" , ( _e , item , wc ) => {
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
const url = item . getURL ( ) ;
2026-09-07 01:52:49 +02:00
// 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 { }
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
// 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 ) => {
Theseus 0.3.31 rewrite — UI improvements + defensive hash-verify, spawn flags unchanged
Same 0.3.31 version, new binary. Rebuilds the shipped 0.3.31 with the
salvageable content from the reverted 0.3.32-0.3.34 track:
chrome.html
- light-mode chrome strip: --bg #e6e8ec, inactive tab #f2f4f7,
active tab #ffffff. Fixes the "tabs disappear into the light
Windows title bar" report.
- bookmark chips shrunk: 130px max-width, 11px text, 12px favicon,
22px row (was 26). ~40% more chips fit in the same width.
- bookmark chips draggable with the tab-strip's left/right-half
drop convention; new .dropbefore/.dropafter accent.
- light-mode .tor + .logo + .upchip chips: from illegible white-
on-#253A49 (at 12-13px) to #eef1f5 with #253A49 ink. Both readable
now. .tor.connecting/.on keep amber/purple hue in light fills.
main.js
- will-download update handler now streams the saved setup .exe
through crypto.createHash("sha256"), compares to the manifest's
updateAvailable.setupHash before marking ready. Rejects and
deletes the file on mismatch or on empty manifest hash. Test C
in the previous session proved this catches truncated payloads
Electron reports as "completed" (a real class of failure the
Ariadne addon updater has always guarded against here).
- new bookmark-move IPC: splices the list, no-ops on self-drop
or missing entry.
preload.js
- moveBookmark(fromUrl, targetUrl, place) exposed for chrome.
Deliberately NOT changed: install-update-now still spawns setup with
["/S"] alone. The 0.3.32 --updated /S --force-run change was proven
in the previous session's real-install E2E to not address the actual
"browser vanished on D:\Program Files install" symptom — every flag
combination (/S alone, --updated /S --force-run, /S /currentuser,
/S /D=<install>) exits 0 without upgrading anything on that specific
install path. That's a separate open bug; not touched here.
Version stays 0.3.31 — this is a binary rewrite of 0.3.31, not a new
release. Existing 0.3.31 installs won't see an update chip (version
compare returns false), which is intentional given the auto-update
path is still broken for non-default install locations.
2026-09-08 21:26:42 +02:00
if ( state !== "completed" ) {
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
updateDownloadState = "failed" ;
console . warn ( ` [update] silent fetch ${ state } ` ) ;
Theseus 0.3.31 rewrite — UI improvements + defensive hash-verify, spawn flags unchanged
Same 0.3.31 version, new binary. Rebuilds the shipped 0.3.31 with the
salvageable content from the reverted 0.3.32-0.3.34 track:
chrome.html
- light-mode chrome strip: --bg #e6e8ec, inactive tab #f2f4f7,
active tab #ffffff. Fixes the "tabs disappear into the light
Windows title bar" report.
- bookmark chips shrunk: 130px max-width, 11px text, 12px favicon,
22px row (was 26). ~40% more chips fit in the same width.
- bookmark chips draggable with the tab-strip's left/right-half
drop convention; new .dropbefore/.dropafter accent.
- light-mode .tor + .logo + .upchip chips: from illegible white-
on-#253A49 (at 12-13px) to #eef1f5 with #253A49 ink. Both readable
now. .tor.connecting/.on keep amber/purple hue in light fills.
main.js
- will-download update handler now streams the saved setup .exe
through crypto.createHash("sha256"), compares to the manifest's
updateAvailable.setupHash before marking ready. Rejects and
deletes the file on mismatch or on empty manifest hash. Test C
in the previous session proved this catches truncated payloads
Electron reports as "completed" (a real class of failure the
Ariadne addon updater has always guarded against here).
- new bookmark-move IPC: splices the list, no-ops on self-drop
or missing entry.
preload.js
- moveBookmark(fromUrl, targetUrl, place) exposed for chrome.
Deliberately NOT changed: install-update-now still spawns setup with
["/S"] alone. The 0.3.32 --updated /S --force-run change was proven
in the previous session's real-install E2E to not address the actual
"browser vanished on D:\Program Files install" symptom — every flag
combination (/S alone, --updated /S --force-run, /S /currentuser,
/S /D=<install>) exits 0 without upgrading anything on that specific
install path. That's a separate open bug; not touched here.
Version stays 0.3.31 — this is a binary rewrite of 0.3.31, not a new
release. Existing 0.3.31 installs won't see an update chip (version
compare returns false), which is intentional given the auto-update
path is still broken for non-default install locations.
2026-09-08 21:26:42 +02:00
emitUpdateAvailable ( ) ;
return ;
fix(theseus/updater): verify manifest SHA-256 before arming install
The in-app updater fetched the setup .exe via
session.defaultSession.downloadURL and marked updateDownloadState="ready"
on any DownloadItem `done` with state === "completed", then handed
that path to install-update-now to spawn. No hash check against the
manifest — the same manifest that already carries a SHA-256 per file
and that the Ariadne addon updater verifies at ariadneDownloadInstaller
in this same file.
Consequence: a mid-stream truncation the runtime swallowed as
"completed" (a wrong Content-Length, a CDN cache truncation, an
interrupted TLS session, a corrupted mirror) armed install of a
half-file. install-update-now then ran the corrupt setup silently,
NSIS integrity check failed, uninstaller wiped the app first, and
Theseus was gone with nothing to click.
Now the completion handler streams the saved file through
crypto.createHash("sha256"), compares against updateAvailable.setupHash
from the manifest (already captured in checkForUpdate), and refuses to
arm install on mismatch — deletes the corrupt file and marks the
download failed so the retry loop can pick a fresh one up.
Companion fix to 0.3.32's --updated /S --force-run flags. Both
symptoms landed users in the same "browser vanished" state; 0.3.32
covered the spawn-side, this covers the download-side.
2026-09-08 19:52:15 +02:00
}
Theseus 0.3.31 rewrite — UI improvements + defensive hash-verify, spawn flags unchanged
Same 0.3.31 version, new binary. Rebuilds the shipped 0.3.31 with the
salvageable content from the reverted 0.3.32-0.3.34 track:
chrome.html
- light-mode chrome strip: --bg #e6e8ec, inactive tab #f2f4f7,
active tab #ffffff. Fixes the "tabs disappear into the light
Windows title bar" report.
- bookmark chips shrunk: 130px max-width, 11px text, 12px favicon,
22px row (was 26). ~40% more chips fit in the same width.
- bookmark chips draggable with the tab-strip's left/right-half
drop convention; new .dropbefore/.dropafter accent.
- light-mode .tor + .logo + .upchip chips: from illegible white-
on-#253A49 (at 12-13px) to #eef1f5 with #253A49 ink. Both readable
now. .tor.connecting/.on keep amber/purple hue in light fills.
main.js
- will-download update handler now streams the saved setup .exe
through crypto.createHash("sha256"), compares to the manifest's
updateAvailable.setupHash before marking ready. Rejects and
deletes the file on mismatch or on empty manifest hash. Test C
in the previous session proved this catches truncated payloads
Electron reports as "completed" (a real class of failure the
Ariadne addon updater has always guarded against here).
- new bookmark-move IPC: splices the list, no-ops on self-drop
or missing entry.
preload.js
- moveBookmark(fromUrl, targetUrl, place) exposed for chrome.
Deliberately NOT changed: install-update-now still spawns setup with
["/S"] alone. The 0.3.32 --updated /S --force-run change was proven
in the previous session's real-install E2E to not address the actual
"browser vanished on D:\Program Files install" symptom — every flag
combination (/S alone, --updated /S --force-run, /S /currentuser,
/S /D=<install>) exits 0 without upgrading anything on that specific
install path. That's a separate open bug; not touched here.
Version stays 0.3.31 — this is a binary rewrite of 0.3.31, not a new
release. Existing 0.3.31 installs won't see an update chip (version
compare returns false), which is intentional given the auto-update
path is still broken for non-default install locations.
2026-09-08 21:26:42 +02:00
// 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" ;
fix(theseus/updater): run the installer only after the app has exited, via a detached batch helper with self-heal
A 0.3.44 → 0.3.45 auto-update on 2026-09-11 left the install without
app.asar and ffmpeg.dll ("ffmpeg.dll not found" at launch). The setup was
hash-verified; the old-version uninstaller had moved the whole old install
into its temp folder when both NSIS processes died ~8 s after the spawn,
and the install step never wrote a file. The killer was not identified, so
every overlap with the app's own lifetime is removed instead:
- install-update-now no longer spawns the setup; it records the path and
quits. will-quit writes <userData>\update-helper.cmd and starts it as a
detached cmd.exe (verified to outlive the app; not a child of ours).
- The helper waits for our PID to be gone (child powershell Wait-Process),
gives Chromium's children a grace period, runs the setup directly, and
runs it once more if resources\app.asar is missing afterwards — the
installer is idempotent, so a second pass repairs a torn install. The
helper deletes itself.
- Zone.Identifier is stripped from the verified download so nothing that
starts it through the shell raises a mark-of-the-web prompt.
Console-less cmd.exe traps discovered and designed around (see the module):
child console programs' redirected stdout is empty (no tasklist|find
probing), `start /wait` on a .cmd hangs, a detached powershell.exe
started straight from Node does nothing, `timeout` needs a console.
Scenario tests: setup starts only after the process exits, once with
app.asar present, twice without, helper gone afterwards.
2026-09-12 00:31:46 +02:00
// 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 { }
Theseus 0.3.31 rewrite — UI improvements + defensive hash-verify, spawn flags unchanged
Same 0.3.31 version, new binary. Rebuilds the shipped 0.3.31 with the
salvageable content from the reverted 0.3.32-0.3.34 track:
chrome.html
- light-mode chrome strip: --bg #e6e8ec, inactive tab #f2f4f7,
active tab #ffffff. Fixes the "tabs disappear into the light
Windows title bar" report.
- bookmark chips shrunk: 130px max-width, 11px text, 12px favicon,
22px row (was 26). ~40% more chips fit in the same width.
- bookmark chips draggable with the tab-strip's left/right-half
drop convention; new .dropbefore/.dropafter accent.
- light-mode .tor + .logo + .upchip chips: from illegible white-
on-#253A49 (at 12-13px) to #eef1f5 with #253A49 ink. Both readable
now. .tor.connecting/.on keep amber/purple hue in light fills.
main.js
- will-download update handler now streams the saved setup .exe
through crypto.createHash("sha256"), compares to the manifest's
updateAvailable.setupHash before marking ready. Rejects and
deletes the file on mismatch or on empty manifest hash. Test C
in the previous session proved this catches truncated payloads
Electron reports as "completed" (a real class of failure the
Ariadne addon updater has always guarded against here).
- new bookmark-move IPC: splices the list, no-ops on self-drop
or missing entry.
preload.js
- moveBookmark(fromUrl, targetUrl, place) exposed for chrome.
Deliberately NOT changed: install-update-now still spawns setup with
["/S"] alone. The 0.3.32 --updated /S --force-run change was proven
in the previous session's real-install E2E to not address the actual
"browser vanished on D:\Program Files install" symptom — every flag
combination (/S alone, --updated /S --force-run, /S /currentuser,
/S /D=<install>) exits 0 without upgrading anything on that specific
install path. That's a separate open bug; not touched here.
Version stays 0.3.31 — this is a binary rewrite of 0.3.31, not a new
release. Existing 0.3.31 installs won't see an update chip (version
compare returns false), which is intentional given the auto-update
path is still broken for non-default install locations.
2026-09-08 21:26:42 +02:00
console . log ( ` [update] silent fetch complete + verified: ${ savedPath } ` ) ;
emitUpdateAvailable ( ) ;
} ) ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
} ) ;
return ;
}
2026-08-02 11:39:00 +02:00
const id = nextDlId ++ ;
const rec = {
id ,
filename : item . getFilename ( ) ,
url : item . getURL ( ) ,
mime : item . getMimeType ( ) ,
total : item . getTotalBytes ( ) || 0 ,
received : 0 ,
state : "progressing" , // progressing | paused | completed | cancelled | interrupted
savePath : "" ,
startedAt : Date . now ( ) ,
} ;
downloads . unshift ( rec ) ;
dlItems . set ( id , item ) ;
emitDownloads ( ) ;
item . on ( "updated" , ( _ev , state ) => {
rec . state = state ; // "progressing" | "interrupted"
rec . received = item . getReceivedBytes ( ) ;
rec . total = item . getTotalBytes ( ) || rec . total ;
rec . savePath = item . getSavePath ( ) || rec . savePath ;
emitDownloads ( ) ;
} ) ;
item . once ( "done" , ( _ev , state ) => {
rec . state = state ; // "completed" | "cancelled" | "interrupted"
rec . received = item . getReceivedBytes ( ) ;
rec . savePath = item . getSavePath ( ) || rec . savePath ;
dlItems . delete ( id ) ;
emitDownloads ( ) ;
} ) ;
} ) ;
}
2026-07-29 13:54:34 +02:00
function setActive ( id ) {
2026-09-09 02:38:32 +02:00
const switching = id !== activeId ;
2026-07-29 13:54:34 +02:00
activeId = id ;
2026-07-30 08:16:55 +02:00
if ( popVisible ) showPopover ( false ) ; // don't carry a stale popover across tabs
2026-07-30 22:55:52 +02:00
if ( epVisible ) showEnginePicker ( false ) ;
2026-08-29 15:49:30 +02:00
if ( linkStatusVisible ) showLinkStatus ( "" ) ; // clear any lingering hover pill
2026-09-09 02:38:32 +02:00
// 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 ) ;
2026-09-07 22:15:47 +02:00
// 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 ) ;
2026-07-29 13:54:34 +02:00
const t = activeTab ( ) ;
2026-09-08 19:14:29 +02:00
// 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 ;
2026-07-29 13:54:34 +02:00
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" , {
Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup 09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07
Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.
Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).
Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.
Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.
Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.
Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +02:00
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 || "" } ) ) ,
2026-08-31 21:29:14 +02:00
collapsedGroups : [ ... tabGroupCollapsed ] ,
2026-07-29 13:54:34 +02:00
url : t ? . url || "" ,
2026-07-30 19:29:32 +02:00
loading : ! ! t ? . loading ,
2026-07-29 13:54:34 +02:00
canBack : wc ? wc . navigationHistory . canGoBack ( ) : false ,
canForward : wc ? wc . navigationHistory . canGoForward ( ) : false ,
2026-09-09 23:04:22 +02:00
zoom : zoomPercent ( t ) ,
2026-07-29 13:54:34 +02:00
} ) ;
}
2026-07-30 19:29:32 +02:00
function setLoading ( tab , on ) { if ( tab && tab . loading !== on ) { tab . loading = on ; emitTabs ( ) ; } }
2026-09-09 23:04:22 +02:00
// ---- 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 ) ) ;
2026-09-15 22:30:29 +02:00
// ---- 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 ( ) ) ;
} ) ;
2026-09-15 22:33:07 +02:00
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
// navigateTab / the collision switcher — and everything else sticks on the parent.
// Internal bns:// → https:// for display, matching navigateTab's convention that
2026-08-06 01:41:31 +02:00
// 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 ( ) ;
2026-09-15 01:36:10 +02:00
// 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://" ) ;
2026-08-06 01:41:31 +02:00
} catch { }
}
2026-07-29 13:54:34 +02:00
function loadHome ( id ) {
const t = tabById ( id ) ; if ( ! t ) return ;
t . url = "" ; t . title = "Theseus" ; t . prov = { host : "" , kind : "home" } ;
t . view . webContents . loadFile ( "home.html" ) ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
// 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" ;
2026-09-15 01:36:10 +02:00
t . prov = { host , kind : "error" , code , desc , local : failedUrl . startsWith ( "file:" ) } ;
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
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 ( ) ;
}
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
// 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 { }
}
2026-07-29 13:54:34 +02:00
function createTab ( initial , opts = { } ) {
const id = ++ tabSeq ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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.
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
// 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 } } ) ;
2026-09-07 22:15:47 +02:00
// 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 { }
2026-07-29 13:54:34 +02:00
const wc = view . webContents ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( wc ) ;
2026-07-29 13:54:34 +02:00
try { wc . setWebRTCIPHandlingPolicy ( webrtcPolicy ( ) ) ; } catch { }
try { wc . setBackgroundThrottling ( settings . backgroundThrottle ) ; } catch { }
applyFingerprint ( wc ) ;
Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup 09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07
Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.
Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).
Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.
Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.
Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.
Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +02:00
const tab = { id , view , title : opts . settings ? "Settings" : "New Tab" , url : "" , favicon : null , prov : null , settings : ! ! opts . settings , muted : false , group : null } ;
2026-07-29 13:54:34 +02:00
tabs . push ( tab ) ;
win . contentView . addChildView ( view ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ( ) ; }
} ) ;
2026-08-29 23:29:18 +02:00
// 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 ( ) ; }
} ) ;
2026-09-08 07:53:48 +02:00
// 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 { }
} ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ) ; } ) ;
2026-09-09 23:04:22 +02:00
// 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 ) ) ;
2026-08-01 01:37:18 +02:00
wc . on ( "did-start-loading" , ( ) => setLoading ( tab , true ) ) ;
2026-07-30 19:29:32 +02:00
wc . on ( "did-stop-loading" , ( ) => setLoading ( tab , false ) ) ;
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
// 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 } ) ;
} ) ;
2026-08-29 15:49:30 +02:00
// 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 ) ; } ) ;
2026-07-29 13:54:34 +02:00
wc . on ( "will-navigate" , ( e , u ) => {
try {
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// Skip our own programmatic loads. fallbackToWeb calls loadURL("https://<host>/")
// and that host is often a BCNR-registered dotted name — without this guard,
// isBnsHost() would send us right back into navigateTab, canceling the
// fallback (blank-page bug 2026-08-02).
if ( tab . internalNav ) return ;
2026-07-29 13:54:34 +02:00
const parsed = new URL ( u ) ;
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
// Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns
// doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync.
if ( parsed . protocol === "bns:" && parsed . hostname === "collision-choose" ) {
e . preventDefault ( ) ;
const p = parsed . searchParams ;
const target = String ( p . get ( "host" ) || "" ) . toLowerCase ( ) ;
const cTld = String ( p . get ( "tld" ) || "" ) . toLowerCase ( ) ;
const cChoice = p . get ( "choice" ) ;
const cRem = p . get ( "remember" ) || "no" ;
const rest = p . get ( "resturl" ) || "/" ;
if ( ! target ) return ;
if ( cChoice === "bcnr" || cChoice === "icann" ) {
rememberCollision ( target , cTld , cChoice , cRem ) ;
tab . collisionOverride = cChoice ; // one-shot, consumed by loadBns
}
return navigateTab ( id , target + rest ) ;
}
2026-07-29 13:54:34 +02:00
if ( parsed . protocol === "bns:" ) return ;
Theseus: preserve query on link-navigation intercept + immediate address-bar reflect
Two related navigation bugs, both surfacing when a page inside a tab
tries to submit a search:
1. will-navigate rewriter dropped the query string and fragment.
Every dotted host went through
navigateTab(id, parsed.hostname + parsed.pathname)
which stripped ?q=... The classic-form-GET search engines
(Google, Brave, Bing, Startpage, Yandex, Ecosia, Mojeek, ...) all
silently landed on their /search endpoint with no query, so no
results ever showed. DuckDuckGo only appeared to work because its
in-page search uses history.pushState + XHR and never triggered
will-navigate to begin with.
Fix: pass the whole URL (minus scheme) so query + fragment survive.
2. Address bar showed the previous page's URL until the new page's
did-navigate fired. navigateTab called setLoading() -> emitTabs()
BEFORE assigning t.url, so the chrome renderer received the stale
URL and painted it (goURL() blurs the input on Enter, so the
"don't clobber typed text" guard didn't skip the write).
Fix: assign t.url from host+rest immediately, before setLoading.
2026-08-29 22:22:47 +02:00
if ( isBnsHost ( parsed . hostname ) ) {
2026-08-31 05:13:20 +02:00
// 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 ;
Theseus: preserve query on link-navigation intercept + immediate address-bar reflect
Two related navigation bugs, both surfacing when a page inside a tab
tries to submit a search:
1. will-navigate rewriter dropped the query string and fragment.
Every dotted host went through
navigateTab(id, parsed.hostname + parsed.pathname)
which stripped ?q=... The classic-form-GET search engines
(Google, Brave, Bing, Startpage, Yandex, Ecosia, Mojeek, ...) all
silently landed on their /search endpoint with no query, so no
results ever showed. DuckDuckGo only appeared to work because its
in-page search uses history.pushState + XHR and never triggered
will-navigate to begin with.
Fix: pass the whole URL (minus scheme) so query + fragment survive.
2. Address bar showed the previous page's URL until the new page's
did-navigate fired. navigateTab called setLoading() -> emitTabs()
BEFORE assigning t.url, so the chrome renderer received the stale
URL and painted it (goURL() blurs the input on Enter, so the
"don't clobber typed text" guard didn't skip the write).
Fix: assign t.url from host+rest immediately, before setLoading.
2026-08-29 22:22:47 +02:00
// Preserve query + fragment. Dropping them broke every search engine
// that submits via a classic form GET (Google's /search?q=foo lost
2026-08-31 05:13:20 +02:00
// the ?q=, so the results page opened blank).
Theseus: preserve query on link-navigation intercept + immediate address-bar reflect
Two related navigation bugs, both surfacing when a page inside a tab
tries to submit a search:
1. will-navigate rewriter dropped the query string and fragment.
Every dotted host went through
navigateTab(id, parsed.hostname + parsed.pathname)
which stripped ?q=... The classic-form-GET search engines
(Google, Brave, Bing, Startpage, Yandex, Ecosia, Mojeek, ...) all
silently landed on their /search endpoint with no query, so no
results ever showed. DuckDuckGo only appeared to work because its
in-page search uses history.pushState + XHR and never triggered
will-navigate to begin with.
Fix: pass the whole URL (minus scheme) so query + fragment survive.
2. Address bar showed the previous page's URL until the new page's
did-navigate fired. navigateTab called setLoading() -> emitTabs()
BEFORE assigning t.url, so the chrome renderer received the stale
URL and painted it (goURL() blurs the input on Enter, so the
"don't clobber typed text" guard didn't skip the write).
Fix: assign t.url from host+rest immediately, before setLoading.
2026-08-29 22:22:47 +02:00
e . preventDefault ( ) ;
navigateTab ( id , u . replace ( /^[a-z]+:\/\//i , "" ) ) ;
}
2026-07-29 13:54:34 +02:00
} catch { }
} ) ;
2026-08-02 18:50:58 +02:00
// "You have unsaved changes" confirmation: fires when the page's beforeunload
// handler is trying to keep the user on the page (e.g. an unsent form draft,
// an editor with a dirty document). Show a native confirm; on "Leave", call
// preventDefault to override the block. Applies to both link clicks AND our
// programmatic loads (chip switcher, address-bar navigation).
wc . on ( "will-prevent-unload" , ( e ) => {
const parent = BrowserWindow . getFocusedWindow ( ) || win ;
const choice = dialog . showMessageBoxSync ( parent , {
type : "question" ,
buttons : [ "Stay on page" , "Leave anyway" ] ,
defaultId : 0 ,
cancelId : 0 ,
title : "Unsaved changes" ,
message : "This page is asking you to stay." ,
detail : "You may have unsaved changes that will be lost if you leave." ,
} ) ;
if ( choice === 1 ) e . preventDefault ( ) ; // Leave anyway -> override the beforeunload
} ) ;
2026-07-29 13:54:34 +02:00
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc . setWindowOpenHandler ( ( { url , disposition } ) => {
if ( url && url !== "about:blank" ) createTab ( url , { background : disposition === "background-tab" } ) ;
return { action : "deny" } ;
} ) ;
// Right-click context menu.
wc . on ( "context-menu" , ( _e , p ) => {
const items = [ ] ;
if ( p . linkURL ) {
items . push (
{ label : "Open link in new tab" , click : ( ) => createTab ( p . linkURL ) } ,
{ label : "Open link in new background tab" , click : ( ) => createTab ( p . linkURL , { background : true } ) } ,
2026-09-10 22:25:19 +02:00
{ label : "Open link in new window" , click : ( ) => openLinkWindow ( p . linkURL ) } ,
2026-07-29 13:54:34 +02:00
{ label : "Copy link address" , click : ( ) => clipboard . writeText ( p . linkURL ) } ,
{ type : "separator" } ,
) ;
}
2026-08-29 23:25:22 +02:00
// 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 ) } ,
{ 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" } ,
) ;
}
2026-07-29 13:54:34 +02:00
if ( p . isEditable ) items . push ( { role : "cut" } , { role : "copy" } , { role : "paste" } , { type : "separator" } ) ;
else if ( p . selectionText ) items . push ( { role : "copy" } , { type : "separator" } ) ;
2026-09-06 12:24:41 +02:00
// "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 ) ) } ,
{ type : "separator" } ,
) ;
}
}
2026-07-29 13:54:34 +02:00
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" } ;
2026-09-09 02:05:46 +02:00
const settingsOpts = opts . settingsSection ? { hash : opts . settingsSection } : undefined ;
wc . loadFile ( "settings.html" , settingsOpts ) ;
2026-07-29 13:54:34 +02:00
if ( id === activeId ) pushNav ( tab . prov ) ;
emitTabs ( ) ;
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
} 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 ( ) ;
2026-07-29 13:54:34 +02:00
} 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 ( ) {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
chromeReadyDone = false ;
overlaysLoaded = false ;
// 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" ;
2026-08-31 16:32:16 +02:00
win = new BrowserWindow ( {
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
width : 1220 , height : 840 , title : "Theseus Navigator" , backgroundColor : uiBg ,
2026-08-31 16:32:16 +02:00
// 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" ) ,
} ) ;
2026-07-29 13:54:34 +02:00
chrome = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "preload.js" ) } } ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
try { chrome . setBackgroundColor ( uiBg ) ; } catch { }
2026-07-29 13:54:34 +02:00
win . contentView . addChildView ( chrome ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( chrome . webContents ) ;
2026-07-29 13:54:34 +02:00
chrome . webContents . loadFile ( "chrome.html" ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// The overlays below are created now (cheap) but their pages load via
// deferOverlayLoad → loadOverlays() after chrome has painted.
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 ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( popover . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( popover , "popover.html" ) ;
2026-07-30 08:16:55 +02:00
popover . setVisible ( false ) ;
2026-07-30 22:55:52 +02:00
// Floating engine-picker overlay (custom dropdown with real favicons).
enginePicker = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "engine-picker-preload.js" ) } } ) ;
try { enginePicker . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( enginePicker ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( enginePicker . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( enginePicker , "engine-picker.html" ) ;
2026-07-30 22:55:52 +02:00
enginePicker . setVisible ( false ) ;
2026-08-02 11:39:00 +02:00
// Floating downloads panel — shows active + recent downloads.
downloadsPop = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "downloads-preload.js" ) } } ) ;
try { downloadsPop . setBackgroundColor ( "#00000000" ) ; } catch { }
win . contentView . addChildView ( downloadsPop ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( downloadsPop . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( downloadsPop , "downloads.html" ) ;
2026-08-02 11:39:00 +02:00
downloadsPop . setVisible ( false ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( addressPicker . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( addressPicker , "address-picker.html" ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( pwFillPop . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( pwFillPop , "pw-fill.html" ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
pwFillPop . setVisible ( false ) ;
2026-08-29 15:49:30 +02:00
// 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 ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( linkStatus . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( linkStatus , "link-status.html" ) ;
2026-08-29 15:49:30 +02:00
linkStatus . setVisible ( false ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
// 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.
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
sidebar = new WebContentsView ( { webPreferences : { preload : path . join ( _ _dirname , "sidebar-preload.js" ) } } ) ;
win . contentView . addChildView ( sidebar ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( sidebar . webContents ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
sidebar . setVisible ( false ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// 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 ) ;
0.3.38: brand-green scrollbars everywhere + captureTab widens the viewport when sidebar shrinks the tab
Two changes shipped together (main.js touched by both this session and a
parallel session in different regions):
Scrollbars — from the "empty white space should be grey, thumb should
be Bitcoin Cash green" ask:
* new SCROLLBAR_CSS constant + styleScrollbars(wc) helper injects the
theme on every dom-ready
* thumb #0AC18E (BCH primary), track rgba(120,130,150,0.18) subtle
neutral grey so it works on both dark and light surfaces without
hardcoding either; 6px radius, 2px inset via background-clip:padding-box
* modern scrollbar-color on <html> for Chromium 121+; ::-webkit- rules
with !important as the fallback / override for sites that theme
their own scrollbars — scrollbar-width intentionally left alone so
a page that hides scrollbars entirely keeps that behaviour
* hooked into every wc we own: createTab, chrome, popover, enginePicker,
downloadsPop, addressPicker, pwFillPop, linkStatus, sidebar (so every
add-on panel like Aegis picks it up), approvalPop
* fires once immediately if the wc is already past dom-ready when we
attach — fixed views load fast during startup, we'd otherwise miss
captureTab full-page — from the parallel session's screenshot work:
* before Page.captureScreenshot with captureBeyondViewport we now
override Emulation.setDeviceMetricsOverride to the window's full
content width so an open sidebar (or other on-screen chrome that
narrowed the tab view) doesn't clip the shot — capture comes back at
the page's natural full width, not the visible width
* attach the debugger for the call if it isn't attached, detach on
return; clear the metrics override in finally so the tab returns to
its normal layout regardless of success
2026-09-09 00:42:38 +02:00
styleScrollbars ( approvalPop . webContents ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
deferOverlayLoad ( approvalPop , "approval.html" ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
approvalPop . setVisible ( false ) ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +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 ) ;
2026-07-29 13:54:34 +02:00
win . on ( "resize" , layout ) ;
layout ( ) ;
}
async function navigateTab ( id , input ) {
const t = tabById ( id ) ; if ( ! t ) return ;
let q = String ( input ) . trim ( ) ;
if ( ! q ) return ;
2026-09-15 22:33:07 +02:00
// Local paths open as files — never BCNR, never a search.
const fileUrl = localFileUrl ( q ) ;
if ( fileUrl ) return loadLocalFile ( t , id , fileUrl ) ;
2026-07-29 13:54:34 +02:00
// Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search.
if ( ! looksLikeUrl ( q ) ) q = SEARCH ( q ) ;
const raw = q . replace ( /^[a-z]+:\/\//i , "" ) ;
const host = raw . split ( "/" ) [ 0 ] . toLowerCase ( ) ;
const rest = raw . slice ( host . length ) || "/" ;
Theseus: preserve query on link-navigation intercept + immediate address-bar reflect
Two related navigation bugs, both surfacing when a page inside a tab
tries to submit a search:
1. will-navigate rewriter dropped the query string and fragment.
Every dotted host went through
navigateTab(id, parsed.hostname + parsed.pathname)
which stripped ?q=... The classic-form-GET search engines
(Google, Brave, Bing, Startpage, Yandex, Ecosia, Mojeek, ...) all
silently landed on their /search endpoint with no query, so no
results ever showed. DuckDuckGo only appeared to work because its
in-page search uses history.pushState + XHR and never triggered
will-navigate to begin with.
Fix: pass the whole URL (minus scheme) so query + fragment survive.
2. Address bar showed the previous page's URL until the new page's
did-navigate fired. navigateTab called setLoading() -> emitTabs()
BEFORE assigning t.url, so the chrome renderer received the stale
URL and painted it (goURL() blurs the input on Enter, so the
"don't clobber typed text" guard didn't skip the write).
Fix: assign t.url from host+rest immediately, before setLoading.
2026-08-29 22:22:47 +02:00
// 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)
2026-07-29 13:54:34 +02:00
t . nav = ( t . nav || 0 ) + 1 ;
2026-07-30 22:55:52 +02:00
// Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
2026-07-29 13:54:34 +02:00
t . bcnrOffer = null ;
if ( id === activeId ) chrome . webContents . send ( "bcnr-offer" , null ) ;
2026-07-30 22:55:52 +02:00
// BCNR-first for every dotted host. loadBns falls through to https://<host>
// on NXDOMAIN or resolver failure, so clearnet still works.
if ( isBnsHost ( host ) ) return loadBns ( t , id , host , rest , tldOf ( host ) ) ;
2026-07-29 13:54:34 +02:00
2026-07-30 22:55:52 +02:00
// Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
2026-07-29 13:54:34 +02:00
t . url = q . includes ( "://" ) ? q : "https://" + q ;
await t . view . webContents . loadURL ( t . url ) ;
t . prov = { host , kind : "web" } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
}
2026-09-15 01:36:10 +02:00
// 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 ) ; }
}
2026-09-15 22:33:07 +02:00
// Load a name from BCNR into a tab. Called for every dotted host — BCNR is
// tried first; on NXDOMAIN or resolver failure we always fall through to the
// clearnet (https://<host><rest>) so the user isn't stranded when the chain
// is down or the name isn't registered.
async function loadBns ( t , id , host , rest , tld ) {
2026-07-29 13:54:34 +02:00
const registry = registryOf ( tld ) ;
if ( id === activeId ) pushNav ( { host , kind : "resolving" , tld , registry } ) ;
2026-07-30 22:09:26 +02:00
const fallbackToWeb = async ( reason ) => {
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
Theseus: fix collision blank-page + broken-BCDN-open bugs
Two related bugs both caused by the tab's will-navigate handler racing with
programmatic loads:
Bug A (chip switch BCDN -> ICANN shows blank page, BCDN-priority mode):
fallbackToWeb() calls webContents.loadURL('https://<host>/') to serve the
ICANN version. That fired will-navigate, which saw a dotted host, ran
isBnsHost() -> true, and RE-INVOKED navigateTab() recursively — but the
transient='icann' override had already been consumed, so the recursive call
fell back to bcnr-first, canceling the fallback mid-flight. Tab showed blank
because both loads collided.
Fix: mark programmatic loads with t.internalNav so will-navigate skips them.
Bug B ('Ask each time' -> Open BCDN doesn't load):
Was going through a meta-refresh from bns://collision-choose/ to
bns://<host>/?_collision=bcnr. The meta-refresh bypassed navigateTab, so the
chrome/prov state was never updated (address bar, badges stayed stale).
Fix: will-navigate now catches bns://collision-choose/ FIRST, applies the
remember flag, sets t.collisionOverride, and routes via navigateTab so
chrome + prov update correctly.
Removed the serveBns collision-choose handler + the ?_collision URL-param
path in loadBns (both dead now that will-navigate handles it).
Result:
- Soft-mode 'Open with...' -> Open BCDN loads the BCDN site cleanly.
- Chip switcher flips BCDN <-> ICANN with no blank flash, correct chrome.
2026-08-02 15:21:21 +02:00
t . internalNav = true ;
try { await t . view . webContents . loadURL ( t . url ) ; }
catch ( e ) { console . warn ( "fallback loadURL failed:" , e ? . message ) ; }
finally { t . internalNav = false ; }
2026-07-30 22:09:26 +02:00
t . prov = { host , kind : "web" , note : ` fallback: ${ reason } ` , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
} ;
2026-08-02 14:43:31 +02:00
// Strip and capture the one-shot ?_collision=bcnr param that collision-choose
// adds when redirecting back after the user picked BCDN in soft mode. Ignored
// (harmless) if absent.
let urlChoice = null ;
try {
const u = new URL ( rest , ` bns:// ${ host } / ` ) ;
if ( u . searchParams . has ( "_collision" ) ) {
urlChoice = u . searchParams . get ( "_collision" ) ;
u . searchParams . delete ( "_collision" ) ;
rest = u . pathname + ( u . search ? u . search : "" ) ;
}
} catch { }
2026-07-29 13:54:34 +02:00
let entry ;
try { entry = await resolveHost ( host ) ; }
2026-07-30 22:55:52 +02:00
catch { setLoading ( t , false ) ; return fallbackToWeb ( "BCNR unreachable" ) ; }
2026-08-02 19:26:47 +02:00
// Address-bar display: show https:// even for BCDN sites. Rationale — BCNR
// replaces DNS (name resolution), NOT HTTP (transport). For s3/ip/p records
// the actual delivery IS HTTPS under the hood; for h records the content is
// on-chain (no HTTP at all, but https:// is the least surprising display).
// The BCDN/ICANN badge is the source-of-truth for which registry served us;
// the URL scheme is a convention, kept consistent so users' muscle memory
// holds. (bns:// is an internal Electron protocol implementation detail.)
t . url = "https://" + host + ( rest === "/" ? "" : rest ) ;
2026-07-30 22:55:52 +02:00
if ( ! entry ) { setLoading ( t , false ) ; return fallbackToWeb ( "no BCNR record" ) ; }
2026-08-02 11:39:00 +02:00
// ---- Collision policy (BCNR ↔ ICANN) --------------------------------------
// BCNR has the name; if the TLD is BCNR-native we're done (whole TLD is BCNR's,
// no collision possible). Otherwise it's a collision candidate — the same name
// *might* also exist on ICANN; the policy decides which to load.
// Full model: SilentMode/Argus/DESIGN-collision-modes.md.
if ( ! isBcnrNativeTld ( tld ) ) {
2026-08-02 14:43:31 +02:00
// Transient per-tab override (from the chip switcher) — one-shot, consumed here.
const transient = t . collisionOverride ; t . collisionOverride = null ;
2026-08-02 11:39:00 +02:00
const policy = settings . collisionPolicy || "bcnr-first" ;
2026-08-02 14:43:31 +02:00
// Precedence: urlChoice (one-shot from collision-choose) > transient (chip
// switcher) > persistent per-name/per-TLD > global policy.
let choice = urlChoice || transient || overrideFor ( host , tld ) ;
2026-08-02 11:39:00 +02:00
if ( ! choice ) {
if ( policy === "icann-first" ) choice = "icann" ;
else if ( policy === "soft" ) {
2026-08-02 14:43:31 +02:00
// In-tab full-page "Open with…" prompt (loaded from disk; buttons post
// back through bns://collision-choose/ which serveBns handles above).
setLoading ( t , false ) ;
const q = new URLSearchParams ( { host , tld , resturl : rest } ) . toString ( ) ;
await t . view . webContents . loadFile ( path . join ( _ _dirname , "collision.html" ) , { search : q } ) ;
t . prov = { host , kind : "resolving" , tld , registry } ;
if ( id === activeId ) pushNav ( t . prov ) ;
emitTabs ( ) ;
return ;
2026-08-02 11:39:00 +02:00
} else choice = "bcnr" ; // bcnr-first — the default
}
if ( choice === "icann" ) { setLoading ( t , false ) ; return fallbackToWeb ( "collision → ICANN" ) ; }
}
2026-07-29 13:54:34 +02:00
await t . view . webContents . loadURL ( ` bns:// ${ host } ${ rest } ` ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Source badge must mirror what serveBns actually picks — subdomain-with-ip
// routes via the parent's server, not via Sia. See serveBns for the rule.
const _isSub = host !== entry . name ;
const src = ( _isSub && entry . records . ip ) ? "direct server"
: entry . records . h ? "on-chain (chain)"
: entry . records . s3 ? "Sia network"
: entry . records . ip ? "direct server"
2026-09-07 00:18:07 +02:00
: ( ! _isSub && entry . records . p ) ? "mirror"
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
: entry . records . u ? "redirect"
: "record" ;
2026-09-16 00:53:13 +02:00
t . prov = { host , kind : "ok" , source : src , category : entry . category , records : Object . keys ( entry . records ) , dns : dnsRecordKinds ( entry ) , tld , registry } ;
2026-07-29 13:54:34 +02:00
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 ) ) ;
Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup 09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07
Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.
Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).
Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.
Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.
Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.
Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +02:00
// 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" ] ) ;
2026-08-31 21:29:14 +02:00
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 ) ;
}
}
Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup 09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07
Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.
Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).
Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.
Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.
Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.
Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +02:00
emitTabs ( ) ;
return t . group ;
} ) ;
2026-08-31 21:29:14 +02:00
// 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 ) ;
} ) ;
Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup 09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07
Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.
Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).
Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.
Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.
Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.
Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +02:00
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 ;
} ) ;
2026-09-09 02:05:46 +02:00
// 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 ) ; else createTab ( ) ; } } ,
{ 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 ;
} ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 ( ) ;
} ) ;
2026-07-29 13:54:34 +02:00
ipcMain . handle ( "go-home" , ( ) => loadHome ( activeId ) ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// --- 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 ; } ) ;
2026-08-31 16:00:34 +02:00
// 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 ;
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable
User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
2026-09-08 22:18:41 +02:00
// 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 { }
}
2026-08-31 16:00:34 +02:00
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 ;
} ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
ipcMain . handle ( "sidebar-open" , ( _e , panelId ) => { setSidebar ( true , panelId ) ; return sidebarVisible ; } ) ;
ipcMain . handle ( "sidebar-close" , ( ) => { setSidebar ( false ) ; return false ; } ) ;
feat(theseus/screenshot): 0.4.0 — editor lives inside the sidebar, maximizable
User report: the sidebar preview lands correctly, but the moment the editor
opens in its own tab the picture is blank. Rather than chase that class of
handoff race again, put the editor in the same webContents as the panel:
the sidebar view navigates panel.html ↔ editor.html in place. Same
document object, same silentmode.storage surface, no cross-tab __pending
transfer at all.
- panel.html "Edit" button now calls silentmode.invoke("arm", …) — the
add-on rewrites __pending with the currently-previewed capture's bytes,
and the panel does location.href = "editor.html?name=…". Sidebar view
loads the editor with the same preload; editor.js's storage-based load
path pulls the pending entry out and paints.
- editor.html gains a "Back" arrow (returns to panel.html) and a
maximize / restore icon.
- discard() now navigates to panel.html instead of closeTab() — there is
no tab to close.
- Manifest drops the "open-tab" capability entirely (no more full-tab
editor); keeps sidebar-panel + capture-tab.
Framework: new silentmode.sidebar.{maximize, restore, toggleMax, isMax,
onMaxChange}. main.js honours them via new sidebar-maximize / -restore /
-toggle-max / -is-max IPCs, remembering the pre-maximize width so a
restore drops back exactly. The sidebar drag-grip auto-exits maximize
mode on any user drag, so pulling the edge always lands on the pre-max
value plus/minus the delta. sidebar-preload exposes the surface;
chrome.html renderer is untouched — this is a per-panel affordance.
Editor tools (crop / arrow / rect / ellipse / pen / text / mosaic /
undo / redo / copy / save) unchanged. Save still goes through Chromium's
<a download> path, so the file lands in Downloads and appears in the
download chip like any other save.
Bundled but not shipped — leaving version bump + deploy to parent session.
2026-09-08 22:18:41 +02:00
// 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 ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
ipcMain . handle ( "sidebar-state" , ( ) => ( {
visible : sidebarVisible ,
active : sidebarActivePanelId ,
panels : addonHost ? addonHost . getSidebarPanels ( ) : [ ] ,
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
toolbarMenus : addonHost ? addonHost . getToolbarMenus ( ) : [ ] ,
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
} ) ) ;
feat(theseus/screenshot): full-tab editor + toolbar-menu + open-tab capabilities
Reworks the screenshot addon into the flow the user asked for: the
dock icon opens a small dropdown menu (Visible viewport / Full page /
Region…) instead of the sidebar picker, and each capture opens a
full browser tab hosting an editor.
Two new addon-host capabilities land alongside:
- toolbar-menu: the addon declares an icon + item list in its manifest;
the chrome dock renders a button that, on click, opens a small menu
and dispatches the selection to the addon via addon-menu-select IPC.
- open-tab: api.openTab(path) opens a browser tab whose URL is the
addon's local file. Origin-gated per addon; the editor uses a
dedicated addon-tab-preload for its main → renderer bridge.
Editor page (editor.html/js/css):
- Crop, arrow, rectangle, circle, freehand pen, text, blur
- Colour swatches (red / yellow / acid / white / black), 3 stroke widths
- Undo/redo command stack, zoom controls
- Save PNG (goes through the download pipeline, chip picks it up)
- Copy to clipboard via ClipboardItem
2026-09-07 00:56:57 +02:00
// 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" } ) ;
} ) ;
2026-09-07 01:52:49 +02:00
// 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 } " ` ) ;
2026-09-07 23:03:57 +02:00
// 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 ;
2026-09-07 01:52:49 +02:00
const template = menu . items . map ( ( it ) => ( {
label : ( it . icon ? String ( it . icon ) + " " : "" ) + String ( it . label || it . id ) ,
2026-09-07 23:03:57 +02:00
click : ( ) => { picked = it . id ; } ,
2026-09-07 01:52:49 +02:00
} ) ) ;
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 { }
2026-09-07 23:03:57 +02:00
if ( ! picked ) return ; // user hit Escape or clicked outside
2026-09-08 02:27:36 +02:00
// 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.
2026-09-07 23:03:57 +02:00
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 ) ) ;
2026-09-08 02:27:36 +02:00
} , 250 ) ;
2026-09-07 01:52:49 +02:00
} } ) ;
return true ;
} ) ;
2026-09-08 02:27:36 +02:00
// 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 ;
} ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// Read-side of Settings' Add-ons tab.
2026-09-08 13:12:55 +02:00
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 ;
} ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// 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.
ipcMain . handle ( "addons-set-enabled" , ( _e , 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 ) ;
}
return true ;
} ) ;
// 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 ; }
} ) ;
2026-09-08 02:27:36 +02:00
// 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".
ipcMain . handle ( "addons-check-updates" , async ( ) => {
const stagedDir = addonsStagedDir ( ) ;
2026-09-08 18:14:07 +02:00
let report = [ ] ;
let skipped = null ;
2026-09-08 02:27:36 +02:00
try {
2026-09-08 18:14:07 +02:00
const result = await addonUpdater . checkAndStageUpdates ( {
2026-09-08 02:27:36 +02:00
addonsDir : addonsUserDir ( ) ,
stagedDir ,
pubkeysHex : ADDON _UPDATE _PUBKEYS ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) ;
2026-09-08 18:14:07 +02:00
report = result ? . report || [ ] ;
skipped = result ? . skipped || null ;
2026-09-08 02:27:36 +02:00
} catch ( e ) { console . warn ( "[addons] check-updates failed:" , e ? . message || e ) ; }
2026-09-08 18:14:07 +02:00
return { report , skipped , staged : listStagedAddons ( stagedDir ) } ;
2026-09-08 02:27:36 +02:00
} ) ;
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 ;
}
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
ipcMain . handle ( "addons-reload" , ( ) => {
if ( ! addonHost ) return false ;
addonHost . discoverAndActivate ( ) ;
return true ;
} ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// --- 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 ; }
}
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.
2026-09-06 02:56:34 +02:00
// Assigning event.returnValue sends the reply at once, so it is set exactly
// once at the end.
function injectionsForSender ( e , href ) {
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
const tab = tabForSender ( e . sender ) ;
2026-09-06 02:56:34 +02:00
if ( ! tab || ! addonHost ) return [ ] ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
const url = e . sender . getURL ( ) ;
2026-09-06 02:56:34 +02:00
if ( ! url || url . startsWith ( "file:" ) ) return [ ] ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
if ( href && href !== url ) console . log ( ` [addons] inject: preload href ${ href } ≠ committed ${ url } ` ) ;
const origin = pageOriginOf ( url ) ;
2026-09-06 02:56:34 +02:00
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 ) ; } ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// 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.
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 ;
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// 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 ) ;
}
} ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
}
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 ,
2026-09-06 12:43:17 +02:00
// 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 ,
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
} ;
return new Promise ( ( resolve ) => {
approvalQueue . push ( { req , resolve } ) ;
pumpApproval ( ) ;
} ) ;
}
2026-09-06 12:43:17 +02:00
ipcMain . handle ( "approval-pick" , ( e , reqId , action , checked , extra ) => {
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
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 ;
2026-09-06 12:43:17 +02:00
if ( result !== "cancel" && cur . req . select && extra && cur . req . select . options . some ( ( o ) => o . value === extra ) ) {
result += "+" + cur . req . select . id + "=" + extra ;
}
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
cur . resolve ( result ) ;
pumpApproval ( ) ;
return true ;
} ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
// --- 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 { } ; }
} ) ;
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
// 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 ;
} ) ;
2026-09-04 20:21:30 +02:00
// "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 ) ;
} ) ;
Theseus 0.1.3: branded error page for load failures (BUILT, NOT DEPLOYED)
Setup f2afc14efc63008cbb9dad44176e94146386db4c0afda4459f1d4eb929172b6d
Portable 5d08b1415526934db8de780949a610896064fe9567aa0e5e1702ebabd7eb7df2
Chromium's default 'This site can't be reached' replaced with a Theseus-
themed error page. did-fail-load on every tab's webContents (main frame
only, non-ignorable code) routes the tab to error.html with the
attempt URL, host, error code, and description as query params. The
page keeps t.url pointing at the failed URL so the address bar shows
what the user typed and they can edit + retry - refreshTabUrl's
existing file:// skip means the error page's own path never leaks
back into the bar.
Five kinds, chosen by pickErrorKind(code, host):
name-not-registered BCNR-eligible host + ERR_NAME_NOT_RESOLVED.
Says "no BCDN record on chain, no clearnet host
either." Offers Register on Sirius + Search +
Retry + Home.
name-unreachable ERR_NAME_NOT_RESOLVED on a non-BCNR host. DNS
failed - offers Retry + Search + Register +
Home.
unreachable CONN_REFUSED/RESET/TIMED_OUT/CLOSED/NETWORK_CHANGED.
Offers Retry + Tor guide + Home.
tls ERR_CERT_* range (-200..-299). Offers Retry +
Home.
generic Everything else.
home-preload.js gains `window.errorpage` alongside `window.home`. Both
APIs are sender-URL-gated in main - a random page seeing the shape
can't invoke them (isErrorPageSender / isHomePageSender). The external-
open handler additionally allowlists Silent Mode domains only.
package.json build.files gets error.html + error-preload.js so
electron-builder actually bundles them (GOTCHAS rule: an unlisted
runtime-loaded file silently opens blank).
Ship pages (releases-manifest.json, tools/index.html, releases/index.html,
site-theseus-x/index.html) updated to 0.1.3 with the new hashes.
DEPLOY STATUS - blocked on VPS SSH: my IP was hit with a full-port ban
mid-turn (likely fail2ban from the burst of scp during the 0.1.0-0.1.2
iterations). Site pages/manifest/installers are committed locally but
NOT yet on dl.silentmode.st or the Sia mirror. Live still reads 0.1.2.
User needs to unban 195.184.247.106 on their end, or wait for the ban
to expire, before the ship pages match reality.
2026-08-31 13:38:05 +02:00
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 ; }
} ) ;
2026-08-31 15:35:47 +02:00
// 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.
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
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 ;
2026-08-31 15:35:47 +02:00
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 ; }
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
} ) ;
ipcMain . handle ( "dismiss-update" , ( ) => { updateDismissedThisSession = true ; emitUpdateAvailable ( ) ; return true ; } ) ;
2026-09-08 07:53:48 +02:00
// 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 ; }
} ) ;
2026-09-08 12:36:21 +02:00
// 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 ( ) ) ;
2026-09-08 18:09:55 +02:00
// 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" , ( ) => { try { app . relaunch ( ) ; } catch { } app . quit ( ) ; } ) ;
2026-09-08 00:43:26 +02:00
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 ( ) } ;
} ) ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
// One-click "Install & restart". Requires the silent pre-fetch to have
2026-09-09 00:58:26 +02:00
// 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.
fix(theseus/updater): run the installer only after the app has exited, via a detached batch helper with self-heal
A 0.3.44 → 0.3.45 auto-update on 2026-09-11 left the install without
app.asar and ffmpeg.dll ("ffmpeg.dll not found" at launch). The setup was
hash-verified; the old-version uninstaller had moved the whole old install
into its temp folder when both NSIS processes died ~8 s after the spawn,
and the install step never wrote a file. The killer was not identified, so
every overlap with the app's own lifetime is removed instead:
- install-update-now no longer spawns the setup; it records the path and
quits. will-quit writes <userData>\update-helper.cmd and starts it as a
detached cmd.exe (verified to outlive the app; not a child of ours).
- The helper waits for our PID to be gone (child powershell Wait-Process),
gives Chromium's children a grace period, runs the setup directly, and
runs it once more if resources\app.asar is missing afterwards — the
installer is idempotent, so a second pass repairs a torn install. The
helper deletes itself.
- Zone.Identifier is stripped from the verified download so nothing that
starts it through the shell raises a mark-of-the-web prompt.
Console-less cmd.exe traps discovered and designed around (see the module):
child console programs' redirected stdout is empty (no tasklist|find
probing), `start /wait` on a .cmd hangs, a detached powershell.exe
started straight from Node does nothing, `timeout` needs a console.
Scenario tests: setup starts only after the process exits, once with
app.asar present, twice without, helper gone afterwards.
2026-09-12 00:31:46 +02:00
//
// 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 ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
ipcMain . handle ( "install-update-now" , ( ) => {
if ( updateDownloadState !== "ready" || ! updateDownloadPath ) return false ;
fix(theseus/updater): run the installer only after the app has exited, via a detached batch helper with self-heal
A 0.3.44 → 0.3.45 auto-update on 2026-09-11 left the install without
app.asar and ffmpeg.dll ("ffmpeg.dll not found" at launch). The setup was
hash-verified; the old-version uninstaller had moved the whole old install
into its temp folder when both NSIS processes died ~8 s after the spawn,
and the install step never wrote a file. The killer was not identified, so
every overlap with the app's own lifetime is removed instead:
- install-update-now no longer spawns the setup; it records the path and
quits. will-quit writes <userData>\update-helper.cmd and starts it as a
detached cmd.exe (verified to outlive the app; not a child of ours).
- The helper waits for our PID to be gone (child powershell Wait-Process),
gives Chromium's children a grace period, runs the setup directly, and
runs it once more if resources\app.asar is missing afterwards — the
installer is idempotent, so a second pass repairs a torn install. The
helper deletes itself.
- Zone.Identifier is stripped from the verified download so nothing that
starts it through the shell raises a mark-of-the-web prompt.
Console-less cmd.exe traps discovered and designed around (see the module):
child console programs' redirected stdout is empty (no tasklist|find
probing), `start /wait` on a .cmd hangs, a detached powershell.exe
started straight from Node does nothing, `timeout` needs a console.
Scenario tests: setup starts only after the process exits, once with
app.asar present, twice without, helper gone afterwards.
2026-09-12 00:31:46 +02:00
pendingInstallerPath = updateDownloadPath ;
app . quit ( ) ;
Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)
Setup fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53
The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.
Chip states:
idle first render before the fetch starts — clickable to
trigger the manual download (kept as a fallback).
downloading "↓ 42% — 0.3.2" — no click, just progress.
ready "✓ Install 0.3.2 & restart" — one click.
failed fall back to the pre-0.3.1 explicit-download click.
install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.
The one-click flow eliminates two long-standing sources of confusion:
- "Download opens a different browser" — Theseus's default session
fetches the installer itself, not a URL handoff to shell.
- "Update requires multiple wizard clicks" — /S skips them.
Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.
Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
2026-08-31 18:47:50 +02:00
return true ;
} ) ;
fix(theseus/updater): run the installer only after the app has exited, via a detached batch helper with self-heal
A 0.3.44 → 0.3.45 auto-update on 2026-09-11 left the install without
app.asar and ffmpeg.dll ("ffmpeg.dll not found" at launch). The setup was
hash-verified; the old-version uninstaller had moved the whole old install
into its temp folder when both NSIS processes died ~8 s after the spawn,
and the install step never wrote a file. The killer was not identified, so
every overlap with the app's own lifetime is removed instead:
- install-update-now no longer spawns the setup; it records the path and
quits. will-quit writes <userData>\update-helper.cmd and starts it as a
detached cmd.exe (verified to outlive the app; not a child of ours).
- The helper waits for our PID to be gone (child powershell Wait-Process),
gives Chromium's children a grace period, runs the setup directly, and
runs it once more if resources\app.asar is missing afterwards — the
installer is idempotent, so a second pass repairs a torn install. The
helper deletes itself.
- Zone.Identifier is stripped from the verified download so nothing that
starts it through the shell raises a mark-of-the-web prompt.
Console-less cmd.exe traps discovered and designed around (see the module):
child console programs' redirected stdout is empty (no tasklist|find
probing), `start /wait` on a .cmd hangs, a detached powershell.exe
started straight from Node does nothing, `timeout` needs a console.
Scenario tests: setup starts only after the process exits, once with
app.asar present, twice without, helper gone afterwards.
2026-09-12 00:31:46 +02:00
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 ) ; }
} ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ; } ) ;
2026-07-29 13:54:34 +02:00
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 ( ) ; } ) ;
2026-08-31 02:50:57 +02:00
ipcMain . handle ( "reload" , ( _e , hard ) => {
const wc = activeTab ( ) ? . view . webContents ;
if ( ! wc ) return ;
try { hard ? wc . reloadIgnoringCache ( ) : wc . reload ( ) ; } catch { }
} ) ;
2026-07-30 19:29:32 +02:00
ipcMain . handle ( "stop" , ( ) => { const t = activeTab ( ) ; try { t ? . view . webContents . stop ( ) ; } catch { } setLoading ( t , false ) ; } ) ;
2026-07-29 13:54:34 +02:00
ipcMain . handle ( "toggle-tor" , ( ) => { torState === "off" ? startTor ( ) : stopTor ( ) ; } ) ;
feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill
- Link-status pill: it measured its own width inside a view already
capped at 100 px, so it could never grow and long hrefs were cut short.
An off-screen twin now reports the natural width; main caps it to the
tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
pushed the registry chips and the star out past the bar. #url now has
min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
icon (spiral + tail) at the end of the bar: acid when served from BCDN,
blue for ICANN, caret when the name exists on both. Click opens a
native menu (registry-menu-popup): switch registry, remember per name /
per TLD, forget choices, collision policy, and a jump to the Plug-ins
settings section. Reuses the existing switch / remember / policy paths
(collision-switch body extracted to switchRegistry, open-settings to
openSettingsTab). preload's openSettings now forwards a section slug.
2026-09-09 11:40:46 +02:00
// 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 ) {
2026-09-09 02:05:46 +02:00
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 } ) ;
feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill
- Link-status pill: it measured its own width inside a view already
capped at 100 px, so it could never grow and long hrefs were cut short.
An off-screen twin now reports the natural width; main caps it to the
tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
pushed the registry chips and the star out past the bar. #url now has
min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
icon (spiral + tail) at the end of the bar: acid when served from BCDN,
blue for ICANN, caret when the name exists on both. Click opens a
native menu (registry-menu-popup): switch registry, remember per name /
per TLD, forget choices, collision policy, and a jump to the Plug-ins
settings section. Reuses the existing switch / remember / policy paths
(collision-switch body extracted to switchRegistry, open-settings to
openSettingsTab). preload's openSettings now forwards a section slug.
2026-09-09 11:40:46 +02:00
}
ipcMain . handle ( "open-settings" , ( _e , section ) => openSettingsTab ( section ) ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "toggle-site-info" , ( _e , rect ) => {
if ( popVisible ) return showPopover ( false ) ;
if ( rect ) popPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showPopover ( true ) ;
} ) ;
ipcMain . handle ( "close-site-info" , ( ) => showPopover ( false ) ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "popover-resize" , ( _e , h ) => { popH = Math . max ( 90 , Math . min ( 380 , Math . round ( h ) || 210 ) ) ; if ( popVisible ) positionPopover ( ) ; } ) ;
2026-08-02 11:39:00 +02:00
// ---- Collision-mode: per-tab live switcher + policy control -----------------
// Flip the active tab between BCNR and ICANN for its current host, optionally
// remembering the choice per-name / per-TLD (like the OS "Open with…" flow).
feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill
- Link-status pill: it measured its own width inside a view already
capped at 100 px, so it could never grow and long hrefs were cut short.
An off-screen twin now reports the natural width; main caps it to the
tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
pushed the registry chips and the star out past the bar. #url now has
min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
icon (spiral + tail) at the end of the bar: acid when served from BCDN,
blue for ICANN, caret when the name exists on both. Click opens a
native menu (registry-menu-popup): switch registry, remember per name /
per TLD, forget choices, collision policy, and a jump to the Plug-ins
settings section. Reuses the existing switch / remember / policy paths
(collision-switch body extracted to switchRegistry, open-settings to
openSettingsTab). preload's openSettings now forwards a section slug.
2026-09-09 11:40:46 +02:00
ipcMain . handle ( "collision-switch" , ( _e , arg ) => switchRegistry ( arg ) ) ;
async function switchRegistry ( arg ) {
2026-08-02 11:39:00 +02:00
const t = activeTab ( ) ; if ( ! t ? . prov ? . host ) return null ;
const host = t . prov . host , tld = tldOf ( host ) ;
2026-08-02 14:43:31 +02:00
// Accept both "bcdn" (client-facing product name) and "bcnr" (internal key).
const raw = arg ? . choice ;
const choice = ( raw === "bcdn" || raw === "bcnr" ) ? "bcnr"
: ( raw === "icann" ) ? "icann"
: ( t . prov . kind === "ok" ? "icann" : "bcnr" ) ; // flip the current one
// Optional persistent memory ("Always use…" from the popover switcher).
2026-08-02 11:39:00 +02:00
rememberCollision ( host , tld , choice , arg ? . remember || "no" ) ;
2026-08-02 18:40:38 +02:00
const registry = registryOf ( tld ) ;
// DIRECT navigation — bypass navigateTab/loadBns entirely so nothing in the
// collision-decision path can trigger a re-prompt on an explicit user switch.
// The user clicked the switcher; they've made their choice. Just load it.
setLoading ( t , true ) ;
t . internalNav = true ;
try {
if ( choice === "icann" ) {
t . url = "https://" + host + "/" ;
await t . view . webContents . loadURL ( t . url ) ;
t . prov = { host , kind : "web" , note : "switched to ICANN" , tld , registry } ;
} else {
2026-08-02 19:26:47 +02:00
t . url = "https://" + host + "/" ; // display: https://; internal fetch: bns://
2026-08-02 18:40:38 +02:00
await t . view . webContents . loadURL ( ` bns:// ${ host } / ` ) ;
const rec = entries . get ( host ) ;
const r = rec ? . entry ? . records || { } ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Mirror serveBns's subdomain-first-ip rule so the badge does not lie.
const _isSub = rec ? . entry ? . name && host !== rec . entry . name ;
const src = ( _isSub && r . ip ) ? "direct server"
: r . h ? "on-chain (chain)"
: r . s3 ? "Sia network"
: r . ip ? "direct server"
2026-09-07 00:18:07 +02:00
: ( ! _isSub && r . p ) ? "mirror"
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
: r . u ? "redirect"
: "record" ;
2026-09-16 00:53:13 +02:00
t . prov = { host , kind : "ok" , source : src , category : rec ? . entry ? . category , records : Object . keys ( r ) , dns : dnsRecordKinds ( rec ? . entry ) , tld , registry } ;
2026-08-02 18:40:38 +02:00
}
} 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 ( ) ;
feat(theseus/chrome): Ariadne's Thread registry menu, address-bar overflow fix, full-width link pill
- Link-status pill: it measured its own width inside a view already
capped at 100 px, so it could never grow and long hrefs were cut short.
An off-screen twin now reports the natural width; main caps it to the
tab area (never under the sidebar) and the pill ellipsises past that.
- Address bar at narrow widths: the URL input's intrinsic minimum width
pushed the registry chips and the star out past the bar. #url now has
min-width: 0 and the trailing controls are fixed-size flex items.
- The BCDN/ICANN segmented chips are replaced by one Ariadne's Thread
icon (spiral + tail) at the end of the bar: acid when served from BCDN,
blue for ICANN, caret when the name exists on both. Click opens a
native menu (registry-menu-popup): switch registry, remember per name /
per TLD, forget choices, collision policy, and a jump to the Plug-ins
settings section. Reuses the existing switch / remember / policy paths
(collision-switch body extracted to switchRegistry, open-settings to
openSettingsTab). preload's openSettings now forwards a section slug.
2026-09-09 11:40:46 +02:00
}
// 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 } ) ;
2026-08-02 11:39:00 +02:00
} ) ;
ipcMain . handle ( "collision-state" , ( ) => ( {
policy : settings . collisionPolicy ,
byName : collisions . byName ,
byTld : collisions . byTld ,
bcnrTlds ,
} ) ) ;
ipcMain . handle ( "collision-set-policy" , ( _e , p ) => {
if ( [ "bcnr-first" , "icann-first" , "soft" ] . includes ( p ) ) { settings . collisionPolicy = p ; saveSettings ( ) ; }
return settings . collisionPolicy ;
} ) ;
ipcMain . handle ( "collision-reset" , ( ) => { collisions = { byName : { } , byTld : { } } ; saveCollisions ( ) ; return true ; } ) ;
2026-09-07 00:31:16 +02:00
// 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.
2026-09-08 01:27:51 +02:00
//
// 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.
2026-09-07 00:31:16 +02:00
const ARIADNE _TASKS = [ "BNS Resolver Daemon" , "BNS Sia Bridge" ] ;
2026-09-07 23:03:57 +02:00
// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both
// native and WOW6432 in case Inno installed either way.
const ARIADNE _UNINSTALL _KEYS = [
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1' ,
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1' ,
] ;
2026-09-09 23:04:23 +02:00
// 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" ;
2026-09-08 01:27:51 +02:00
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 ( ) ;
} ) ;
2026-09-07 23:03:57 +02:00
}
2026-09-08 01:27:51 +02:00
// 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 ( ) ;
} ) ;
}
2026-09-07 00:31:16 +02:00
function ariadneQueryState ( ) {
return new Promise ( ( resolve ) => {
const { spawn } = require ( "child_process" ) ;
2026-09-07 23:03:57 +02:00
// 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 key
2026-09-07 00:31:16 +02:00
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-NonInteractive" , "-Command" ,
"$names=@('BNS Resolver Daemon','BNS Sia Bridge');" +
"$names | ForEach-Object { $t = Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue;" +
2026-09-07 23:03:57 +02:00
" if ($t) { \"$_=$($t.State)\" } else { \"$_=MISSING\" } };" +
"$keys=@(" + ARIADNE _UNINSTALL _KEYS . map ( ( k ) => "'" + k + "'" ) . join ( "," ) + ");" +
"foreach ($k in $keys) { if (Test-Path $k) { $r=Get-ItemProperty $k; \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\"; break } }"
] , { windowsHide : true } ) ;
2026-09-07 00:31:16 +02:00
let out = "" ;
ps . stdout . on ( "data" , ( d ) => { out += d ; } ) ;
2026-09-08 01:27:51 +02:00
ps . on ( "close" , async ( ) => {
2026-09-07 00:31:16 +02:00
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" ] ;
2026-09-07 23:03:57 +02:00
const installedVersion = map . _ _VER _ _ || null ;
const quietUninstall = map . _ _UNINSTALL _ _ || null ;
2026-09-08 01:27:51 +02:00
const latest = await ariadneManifestFetch ( ) ;
const latestVersion = latest ? latest . version : null ;
const canUpdate = ! ! ( installedVersion && latestVersion && cmpVersions ( latestVersion , installedVersion ) > 0 ) ;
2026-09-07 23:03:57 +02:00
let state ;
if ( ! primary || primary === "MISSING" ) state = installedVersion ? "stopped" : "not-installed" ;
else if ( primary === "Running" ) state = "running" ;
else state = "stopped" ;
2026-09-08 01:27:51 +02:00
// 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.
resolve ( { state , installedVersion , bundledVersion : latestVersion , canUpdate , hasUninstaller : ! ! quietUninstall } ) ;
2026-09-07 00:31:16 +02:00
} ) ;
2026-09-07 23:03:57 +02:00
ps . on ( "error" , ( ) => resolve ( { state : "not-installed" , installedVersion : null , bundledVersion : null , canUpdate : false , hasUninstaller : false } ) ) ;
2026-09-07 00:31:16 +02:00
} ) ;
}
2026-09-07 23:03:57 +02:00
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 ;
}
2026-09-07 00:31:16 +02:00
function ariadneSetState ( on ) {
return new Promise ( ( resolve , reject ) => {
const { spawn } = require ( "child_process" ) ;
2026-09-09 23:04:23 +02:00
// 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.
2026-09-07 00:31:16 +02:00
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-Command" ,
2026-09-09 23:04:23 +02:00
` $ 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 + ")" ) ) ;
} ) ;
2026-09-07 00:31:16 +02:00
ps . on ( "error" , ( e ) => reject ( e ) ) ;
} ) ;
}
2026-09-08 01:27:51 +02:00
// 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 ) ;
2026-09-07 23:03:57 +02:00
const { spawn } = require ( "child_process" ) ;
return new Promise ( ( resolve , reject ) => {
2026-09-09 23:04:23 +02:00
// -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.
2026-09-07 23:03:57 +02:00
const ps = spawn ( "powershell.exe" , [ "-NoProfile" , "-Command" ,
2026-09-09 23:04:23 +02:00
` $ p = Start-Process -FilePath ' ${ exePath . replace ( /'/g , "''" ) } ' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $ p.ExitCode `
2026-09-07 23:03:57 +02:00
] , { windowsHide : true } ) ;
2026-09-08 01:27:51 +02:00
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 ) ; } ) ;
2026-09-07 23:03:57 +02:00
} ) ;
}
// 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 = ` $ keys=@( ${ ARIADNE _UNINSTALL _KEYS . map ( ( k ) => "'" + k + "'" ) . join ( "," ) } );foreach ( $ k in $ keys){if(Test-Path $ k){ $ r=Get-ItemProperty $ k;Write-Host $ r.QuietUninstallString;break}} ` ;
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.
2026-09-09 23:04:23 +02:00
const runCmd = ` $ p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${ uninstallCmd . replace ( /'/g , "''" ) } /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $ p.ExitCode ` ;
2026-09-07 23:03:57 +02:00
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 } ) ) ) ;
2026-09-07 00:31:16 +02:00
ipcMain . handle ( "ariadne-toggle" , ( _e , on ) => ariadneSetState ( ! ! on ) . catch ( ( e ) => ( { ok : false , error : e ? . message || String ( e ) } ) ) ) ;
2026-09-07 23:03:57 +02:00
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 ) } ) ) ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
// Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is
// held in this main-process closure only — never sent to a renderer except
// in the explicit response to password-get(id). Cleared on quit alongside
// the other storage clears (see before-quit hook).
const vaultFile = ( ) => path . join ( app . getPath ( "userData" ) , "passwords.vault" ) ;
let vaultState = null ; // { key, purposeRoot, entries, _salt, _iters }
2026-09-09 03:27:41 +02:00
// 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
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const 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 ( ) ;
2026-08-19 00:38:52 +02:00
let purposeRootHex , messengerRootHex ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
if ( seedSource && seedSource . kind === "mnemonic" && seedSource . mnemonic ) {
2026-08-19 00:38:52 +02:00
// 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.
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const seed = await v . bip39ToSeed ( String ( seedSource . mnemonic ) ) ;
2026-08-19 00:38:52 +02:00
purposeRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "passwords/0" ) ) ;
messengerRootHex = v . bytesToHex ( await v . seedToPurposeRoot ( seed , "messenger/0" ) ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
} else {
2026-08-19 00:38:52 +02:00
// Independent random seed — 32 bytes of purposeRoot directly. No mnemonic
// means no messenger root; Hermes will fall back to its own mnemonic entry.
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
const root = require ( "node:crypto" ) . webcrypto . getRandomValues ( new Uint8Array ( 32 ) ) ;
purposeRootHex = v . bytesToHex ( root ) ;
}
2026-08-19 00:38:52 +02:00
vaultState = await v . createVault ( vaultFile ( ) , masterPassword , purposeRootHex ,
messengerRootHex ? { messengerRootHex } : { } ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
emitPwAvailability ( ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return 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 ) ;
2026-09-09 03:27:41 +02:00
// 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 ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
emitPwAvailability ( ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
return { ok : true , entries : v . listMetadata ( vaultState ) } ;
} catch ( e ) { return vaultErr ( e ? . message || e ) ; }
} ) ;
2026-09-09 03:27:41 +02:00
ipcMain . handle ( "password-lock" , ( ) => {
vaultState = null ;
importsState = null ;
importsUnlockPw = null ;
emitPwAvailability ( ) ;
return true ;
} ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
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 ) ; }
} ) ;
2026-09-09 03:27:41 +02:00
// ---- 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 ) ; }
} ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
ipcMain . handle ( "clear-browsing-data" , async ( _e , opts ) => {
const o = opts || { } ;
await clearBrowsingData ( { cookies : ! ! o . cookies , cache : ! ! o . cache , storage : ! ! o . storage } ) ;
if ( o . history ) await clearHistoryNow ( ) ;
return true ;
} ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "search-engines" , ( ) => ( { engines : allEngines ( ) , current : settings . searchEngine } ) ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "set-search-engine" , ( _e , id ) => {
2026-07-30 20:58:45 +02:00
if ( allEngines ( ) . some ( ( e ) => e . id === id ) ) { settings . searchEngine = id ; saveSettings ( ) ; emitEngines ( ) ; }
2026-07-30 08:16:55 +02:00
return settings . searchEngine ;
} ) ;
2026-07-30 20:58:45 +02:00
ipcMain . handle ( "add-engine" , ( _e , eng ) => {
2026-08-06 01:41:31 +02:00
// A custom engine needs a name and a URL template containing "%s". Adding
// installs it in both the settings list AND the toolbar dropdown.
2026-07-30 20:58:45 +02:00
if ( eng && eng . name && eng . url && String ( eng . url ) . includes ( "%s" ) ) {
const id = "custom-" + Date . now ( ) . toString ( 36 ) ;
settings . customEngines = [ ... ( settings . customEngines || [ ] ) ,
2026-07-30 22:09:26 +02:00
{ id , name : String ( eng . name ) . slice ( 0 , 40 ) , sym : String ( eng . sym || "🔍" ) . slice ( 0 , 4 ) , url : String ( eng . url ) . slice ( 0 , 400 ) } ] ;
2026-08-06 01:41:31 +02:00
// Custom engines are auto-installed and enabled.
settings . enabledEngines = [ ... new Set ( [ ... ( settings . enabledEngines || DEFAULT _ENABLED ) , id ] ) ] ;
2026-07-30 20:58:45 +02:00
settings . searchEngine = id ; // select the one just added
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
ipcMain . handle ( "remove-engine" , ( _e , id ) => {
2026-08-06 01:41:31 +02:00
// Drop a custom engine entirely — from customEngines and any list that
// referenced it.
2026-07-30 20:58:45 +02:00
settings . customEngines = ( settings . customEngines || [ ] ) . filter ( ( e ) => e . id !== id ) ;
2026-08-06 01:41:31 +02:00
settings . enabledEngines = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( settings . searchEngine === id ) settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
// Right-click "Remove from list": drops a built-in from installedEngines AND
// enabledEngines so it goes back to the catalog. For custom engines this
// aliases to remove-engine (they don't live in installedEngines).
ipcMain . handle ( "remove-from-list" , ( _e , id ) => {
if ( ( settings . customEngines || [ ] ) . some ( ( e ) => e . id === id ) ) {
settings . customEngines = ( settings . customEngines || [ ] ) . filter ( ( e ) => e . id !== id ) ;
} else if ( SEARCH _ENGINES [ id ] ) {
settings . installedEngines = ( settings . installedEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
} else {
return { engines : allEngines ( ) , current : settings . searchEngine } ;
}
settings . enabledEngines = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( settings . enabledEngines . length === 0 ) settings . enabledEngines = [ "duckduckgo" ] ; // never empty
2026-07-30 22:55:52 +02:00
if ( settings . searchEngine === id ) settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
2026-07-30 20:58:45 +02:00
saveSettings ( ) ; emitEngines ( ) ;
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
2026-08-06 01:41:31 +02:00
// Enable/disable a built-in engine. Enabling from the catalog also INSTALLS it
// (adds to installedEngines). Disabling only removes it from enabledEngines —
// it stays in installedEngines so the row remains visible with the toggle off.
2026-07-30 22:55:52 +02:00
ipcMain . handle ( "set-engine-enabled" , ( _e , id , on ) => {
if ( SEARCH _ENGINES [ id ] ) {
2026-08-06 01:41:31 +02:00
let installed = ( settings . installedEngines || DEFAULT _ENABLED ) . slice ( ) ;
let enabled = ( settings . enabledEngines || DEFAULT _ENABLED ) . filter ( ( x ) => x !== id ) ;
if ( on ) {
if ( ! installed . includes ( id ) ) installed . push ( id ) ;
enabled . push ( id ) ;
}
settings . installedEngines = installed ;
settings . enabledEngines = enabled . length ? enabled : [ "duckduckgo" ] ; // never empty
2026-07-30 22:55:52 +02:00
if ( ! enabledEnginesList ( ) . some ( ( e ) => e . id === settings . searchEngine ) )
settings . searchEngine = enabledEnginesList ( ) [ 0 ] ? . id || "duckduckgo" ;
saveSettings ( ) ; emitEngines ( ) ;
}
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
2026-07-30 23:26:00 +02:00
ipcMain . handle ( "set-engine-order" , ( _e , ids ) => {
if ( Array . isArray ( ids ) ) { settings . engineOrder = ids . filter ( ( x ) => typeof x === "string" ) ; saveSettings ( ) ; emitEngines ( ) ; }
return { engines : allEngines ( ) , current : settings . searchEngine } ;
} ) ;
2026-07-30 22:55:52 +02:00
// The custom dropdown overlay (real favicons).
ipcMain . handle ( "toggle-engine-picker" , ( _e , rect ) => {
if ( epVisible ) return showEnginePicker ( false ) ;
if ( rect ) epPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showEnginePicker ( true ) ;
} ) ;
ipcMain . handle ( "close-engine-picker" , ( ) => showEnginePicker ( false ) ) ;
ipcMain . handle ( "ep-resize" , ( _e , h ) => { epH = Math . max ( 80 , Math . min ( 440 , Math . round ( h ) || 320 ) ) ; if ( epVisible ) positionEnginePicker ( ) ; } ) ;
ipcMain . handle ( "pick-engine" , ( _e , id ) => {
if ( enabledEnginesList ( ) . some ( ( e ) => e . id === id ) ) { settings . searchEngine = id ; saveSettings ( ) ; emitEngines ( ) ; }
showEnginePicker ( false ) ;
} ) ;
2026-08-06 01:41:31 +02:00
ipcMain . handle ( "picker-open-settings" , ( ) => {
showEnginePicker ( false ) ;
const focus = ( t ) => { try { t . view . webContents . send ( "focus-section" , "search" ) ; } catch { } } ;
const ex = tabs . find ( ( t ) => t . settings ) ;
if ( ex ) { setActive ( ex . id ) ; focus ( ex ) ; return ; }
const id = createTab ( null , { settings : true } ) ;
const t = tabById ( id ) ;
if ( t ) t . view . webContents . once ( "did-finish-load" , ( ) => focus ( t ) ) ;
} ) ;
2026-08-02 11:39:00 +02:00
// ---- Downloads --------------------------------------------------------------
ipcMain . handle ( "downloads-get" , ( ) => downloadsPublic ( ) ) ;
ipcMain . handle ( "toggle-downloads" , ( _e , rect ) => {
if ( dlVisible ) return showDownloads ( false ) ;
if ( rect ) dlPos = { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) } ;
showDownloads ( true ) ;
} ) ;
ipcMain . handle ( "close-downloads" , ( ) => showDownloads ( false ) ) ;
ipcMain . handle ( "downloads-resize" , ( _e , h ) => { dlH = Math . max ( 80 , Math . min ( 480 , Math . round ( h ) || 240 ) ) ; if ( dlVisible ) positionDownloads ( ) ; } ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ( ) ;
} ) ;
2026-09-02 04:28:54 +02:00
// 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 ;
} ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
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 ) ;
} ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
// 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 ) ) ;
2026-08-29 15:49:30 +02:00
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 ( ) ;
} ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
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 { }
} ) ;
2026-08-02 11:39:00 +02:00
ipcMain . handle ( "download-open" , ( _e , id ) => {
const d = downloads . find ( ( x ) => x . id === id ) ;
if ( d ? . state === "completed" && d . savePath ) shell . openPath ( d . savePath ) . catch ( ( ) => { } ) ;
} ) ;
ipcMain . handle ( "download-show" , ( _e , id ) => {
const d = downloads . find ( ( x ) => x . id === id ) ;
if ( d ? . savePath ) { try { shell . showItemInFolder ( d . savePath ) ; } catch { } }
} ) ;
ipcMain . handle ( "download-cancel" , ( _e , id ) => {
const item = dlItems . get ( id ) ; if ( item ) { try { item . cancel ( ) ; } catch { } }
} ) ;
// Only clear finished downloads; a progressing one is cancelled first.
ipcMain . handle ( "download-clear" , ( _e , id ) => {
const i = downloads . findIndex ( ( x ) => x . id === id ) ;
if ( i < 0 ) return ;
if ( downloads [ i ] . state === "progressing" ) { const item = dlItems . get ( id ) ; if ( item ) { try { item . cancel ( ) ; } catch { } } }
downloads . splice ( i , 1 ) ; dlItems . delete ( id ) ;
emitDownloads ( ) ;
} ) ;
ipcMain . handle ( "downloads-clear-all" , ( ) => {
// Keep any still-progressing ones; drop everything else.
for ( let i = downloads . length - 1 ; i >= 0 ; i -- ) if ( downloads [ i ] . state !== "progressing" ) downloads . splice ( i , 1 ) ;
emitDownloads ( ) ;
} ) ;
2026-07-30 22:55:52 +02:00
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain . handle ( "add-detected-engine" , ( ) => {
const d = activeTab ( ) ? . detected ;
if ( d && d . url && d . url . includes ( "%s" ) ) {
const id = "custom-" + Date . now ( ) . toString ( 36 ) ;
settings . customEngines = [ ... ( settings . customEngines || [ ] ) , { id , name : d . name . slice ( 0 , 40 ) , sym : "🔍" , url : d . url . slice ( 0 , 400 ) } ] ;
settings . searchEngine = id ;
saveSettings ( ) ; emitEngines ( ) ;
}
showEnginePicker ( false ) ;
} ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "bookmarks-get" , ( ) => bookmarks ) ;
ipcMain . handle ( "bookmark-add" , ( _e , bm ) => {
if ( bm && bm . url && ! bookmarks . some ( ( b ) => b . url === bm . url ) ) {
2026-09-04 22:12:03 +02:00
const entry = { title : bm . title || bm . url , url : bm . url } ;
if ( bm . favicon ) entry . favicon = String ( bm . favicon ) . slice ( 0 , 2048 ) ;
bookmarks . push ( entry ) ;
2026-07-30 08:16:55 +02:00
saveBookmarks ( ) ; emitBookmarks ( ) ;
}
return bookmarks ;
} ) ;
2026-09-04 22:12:03 +02:00
// 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 ;
} ) ;
2026-07-30 08:16:55 +02:00
ipcMain . handle ( "bookmark-remove" , ( _e , url ) => {
bookmarks = bookmarks . filter ( ( b ) => b . url !== url ) ;
saveBookmarks ( ) ; emitBookmarks ( ) ;
return bookmarks ;
} ) ;
Theseus 0.3.31 rewrite — UI improvements + defensive hash-verify, spawn flags unchanged
Same 0.3.31 version, new binary. Rebuilds the shipped 0.3.31 with the
salvageable content from the reverted 0.3.32-0.3.34 track:
chrome.html
- light-mode chrome strip: --bg #e6e8ec, inactive tab #f2f4f7,
active tab #ffffff. Fixes the "tabs disappear into the light
Windows title bar" report.
- bookmark chips shrunk: 130px max-width, 11px text, 12px favicon,
22px row (was 26). ~40% more chips fit in the same width.
- bookmark chips draggable with the tab-strip's left/right-half
drop convention; new .dropbefore/.dropafter accent.
- light-mode .tor + .logo + .upchip chips: from illegible white-
on-#253A49 (at 12-13px) to #eef1f5 with #253A49 ink. Both readable
now. .tor.connecting/.on keep amber/purple hue in light fills.
main.js
- will-download update handler now streams the saved setup .exe
through crypto.createHash("sha256"), compares to the manifest's
updateAvailable.setupHash before marking ready. Rejects and
deletes the file on mismatch or on empty manifest hash. Test C
in the previous session proved this catches truncated payloads
Electron reports as "completed" (a real class of failure the
Ariadne addon updater has always guarded against here).
- new bookmark-move IPC: splices the list, no-ops on self-drop
or missing entry.
preload.js
- moveBookmark(fromUrl, targetUrl, place) exposed for chrome.
Deliberately NOT changed: install-update-now still spawns setup with
["/S"] alone. The 0.3.32 --updated /S --force-run change was proven
in the previous session's real-install E2E to not address the actual
"browser vanished on D:\Program Files install" symptom — every flag
combination (/S alone, --updated /S --force-run, /S /currentuser,
/S /D=<install>) exits 0 without upgrading anything on that specific
install path. That's a separate open bug; not touched here.
Version stays 0.3.31 — this is a binary rewrite of 0.3.31, not a new
release. Existing 0.3.31 installs won't see an update chip (version
compare returns false), which is intentional given the auto-update
path is still broken for non-default install locations.
2026-09-08 21:26:42 +02:00
// 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 ;
} ) ;
2026-07-29 13:54:34 +02:00
ipcMain . handle ( "settings-get" , ( ) => settings ) ;
ipcMain . handle ( "settings-set" , ( _e , key , val ) => {
if ( key in SETTINGS _DEFAULTS ) { settings [ key ] = val ; saveSettings ( ) ; }
2026-07-30 22:00:01 +02:00
if ( key === "webrtcMode" ) applyWebRTCPolicy ( ) ;
if ( key === "theme" ) applyTheme ( ) ;
2026-07-29 13:54:34 +02:00
if ( key === "backgroundThrottle" ) applyThrottle ( ) ;
2026-07-30 23:11:28 +02:00
if ( [ "timezoneMode" , "timezoneValue" , "languageMode" , "languageSpoof" , "languageValue" ,
"locationMode" , "locationRegion" , "locationLat" , "locationLon" , "hideMediaDevices" ] . includes ( key ) ) { applyFingerprintAll ( ) ; applyAcceptLanguage ( ) ; }
2026-09-06 17:14:57 +02:00
// 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 { }
2026-07-29 13:54:34 +02:00
return settings ;
} ) ;
ipcMain . handle ( "set-chrome-height" , ( _e , h ) => {
const next = Math . max ( 74 , Math . min ( 260 , Math . round ( h ) || 84 ) ) ;
if ( next !== CHROME _H ) { CHROME _H = next ; layout ( ) ; }
} ) ;
ipcMain . handle ( "switch-to-bcnr" , ( ) => {
const t = activeTab ( ) ; if ( ! t || ! t . bcnrOffer ) return ;
const { host , rest , tld } = t . bcnrOffer ;
t . bcnrOffer = null ;
chrome . webContents . send ( "bcnr-offer" , null ) ;
return loadBns ( t , activeId , host , rest || "/" , tld ) ;
} ) ;
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
// ---- 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 ,
} ) ;
} ) ;
2026-08-19 00:38:52 +02:00
// 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 = { } ) => {
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
try {
hermesTeardown ( ) ;
const H = await loadHermesLib ( ) ;
2026-08-19 00:38:52 +02:00
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 ( ) ) ) ;
}
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
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 ++ ;
2026-08-19 00:38:52 +02:00
return hOk ( { npub , pkHex , source : opts . useVault ? "vault" : "mnemonic" ,
relaysConnected : connected , relaysTotal : hermesState . relays . size } ) ;
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
} catch ( e ) { hermesTeardown ( ) ; return hErr ( e ) ; }
} ) ;
2026-08-19 00:38:52 +02:00
// 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 ) ,
} ) ) ;
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
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 ) ; }
} ) ;
2026-09-10 22:25:19 +02:00
// ---- "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" } ) ;
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 ;
}
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
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 } ; } ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
// --- 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 ,
2026-09-16 00:53:13 +02:00
// 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 ,
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
} ;
}
ipcMain . handle ( "bcnr:resolveName" , async ( _e , name ) => {
if ( typeof name !== "string" || ! name ) return null ;
try { return serializeEntry ( await resolveHost ( name ) ) ; }
catch { return null ; }
} ) ;
2026-09-16 00:53:13 +02:00
// 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 ; }
} ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
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 ; }
} ) ;
2026-08-31 02:50:57 +02:00
// 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.
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
app . on ( "web-contents-created" , ( _event , wc ) => {
wc . on ( "before-input-event" , ( e , input ) => {
if ( input . type !== "keyDown" ) return ;
2026-08-31 02:50:57 +02:00
// Ctrl+Shift+M -> Messages panel
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
if ( input . control && input . shift && ( input . key === "M" || input . key === "m" ) ) {
openHermesWindow ( ) ;
2026-08-31 02:50:57 +02:00
return e . preventDefault ( ) ;
}
2026-08-31 15:35:47 +02:00
// 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 ( ) ;
}
2026-09-09 23:04:22 +02:00
// 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 ( ) ; }
}
2026-09-08 07:53:48 +02:00
// 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 ( ) ;
}
2026-09-06 16:50:50 +02:00
// DevTools: F12 or Ctrl+Shift+I toggles Chromium DevTools on the active
2026-09-09 00:51:05 +02:00
// 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.
2026-09-06 16:50:50 +02:00
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 ( ) ;
2026-09-09 00:51:05 +02:00
else {
const dock = settings . devToolsDock === "sidebar" ? "right"
: settings . devToolsDock === "two-sidebars" ? "right"
: "bottom" ;
twc . openDevTools ( { mode : dock } ) ;
}
2026-09-06 16:50:50 +02:00
} catch { }
}
return e . preventDefault ( ) ;
}
2026-08-31 02:50:57 +02:00
// 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 ( ) ;
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
}
} ) ;
} ) ;
2026-07-29 13:54:34 +02:00
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
if ( ! process . env . THESEUS _NO _AUTOSTART ) {
app . whenReady ( ) . then ( ( ) => {
2026-07-30 08:16:55 +02:00
Menu . setApplicationMenu ( null ) ; // drop the native File/Edit/View/Help menu bar
2026-07-29 13:54:34 +02:00
loadSettings ( ) ;
2026-07-30 22:00:01 +02:00
applyTheme ( ) ;
2026-07-30 08:16:55 +02:00
loadBookmarks ( ) ;
Theseus UX batch: tabs, address history, password autofill MVP, home cards
Six user-visible improvements + supporting infra, all uncommitted from
the earlier session-in-progress state. Ships together in one release.
Chrome / tabs
- Same-size tabs: flex 1 1 0 with max 200px, min 60px. Container gets
overflow: hidden so many tabs shrink evenly instead of scrolling out.
- Drag-and-drop tab reordering. HTML5 drag events on each .tab; drop
side chosen by pointer x within target (Chrome UX). New move-tab IPC
splices the tabs array + re-emits.
Address bar
- Persistent history at userData/history.json capped at 500 LRU. Ranked
by host-prefix > url-prefix > contains > title-contains > recency.
- Floating suggestions dropdown (addressPicker WebContentsView) anchored
under the URL bar. Debounced 80ms input; ArrowUp/Down forward to the
picker via address-cursor IPC; Enter fires goURL; blur closes after
160ms so click-through registers. New files address-picker.html +
address-picker-preload.js. Cleared by existing clearHistoryOnQuit.
Password autofill (A.2 MVP)
- Green key chip in the address bar appears when the vault is UNLOCKED
and the active tab's host has matching credentials (exact hostname
match for phase 1; eTLD+1 upgrade queued as A.2.5).
- Click chip → floating picker of usernames. Click a match → main.js
runs a small script in the active tab: finds first visible
input[type=password]:not([disabled]), walks the same form for a
visible text/email/tel/url/search input whose name/id/autocomplete
matches /username|user|email|login|account|id/, fills both via the
native value setter + dispatches input/change so React/Vue-controlled
inputs update. New files pw-fill.html + pw-fill-preload.js.
- emitPwAvailability fires from pushNav + vault setup/unlock/lock so
the chip's visibility + count stays accurate.
Bookmarks bar
- Right-click context menu on the favorites bar. On empty area:
"Add current page" (or "Remove current page" if already saved). On a
specific bookmark: "Open", "Edit title…" (prompt), "Remove", plus
the add/remove-current entry. Uses a shared .ctxmenu style mirroring
the settings ctxmenu (dark/light aware).
- Empty-state text updated to mention right-click.
Home page
- Larger responsive card grid: auto-fill minmax(260-280px, 1fr) with
breakpoints at 600/900/1200. Cards have a subtitle line, a colored
badge (on-chain / Sia / server / custom), and edit affordances that
reveal only in Edit mode.
- User-editable set: Edit toggle reveals per-card ✎/✕ + a dashed "+ Add
card" tile. Modal for add/edit with title / URL / subtitle / badge.
Reset-to-defaults button.
- Persisted at userData/home-cards.json. New home-preload.js exposes
window.home = { getCards, setCards, resetCards, navigate }. IPC
handlers in main.js validate sender.getURL() matches our own
home.html — third-party pages see the API shape via the preload but
can't act on the user's local cards.
- Fallback set of 2 cards renders when window.home is unavailable
(e.g. opening home.html directly outside Electron for preview) so
the grid is never blank.
Docs
- TheseusNavigator/ROADMAP-identity-wallet.md — the phased plan for
the two independent strands (password manager A.2/3, browser wallet
B.1-6). Committed earlier this session; re-listed here for context.
- TheseusNavigator/SESSION-PROMPT-identity-wallet.md — pastable
kickoff for the next session picking up either strand.
Files added to build.files: address-picker.html,
address-picker-preload.js, pw-fill.html, pw-fill-preload.js,
home-preload.js.
2026-08-17 02:17:12 +02:00
loadHistory ( ) ;
2026-08-02 11:39:00 +02:00
loadCollisions ( ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
loadWalletPermissions ( ) ;
2026-07-29 13:54:34 +02:00
applyPermissions ( ) ;
2026-08-05 18:38:38 +02:00
applyEmbedCookieShim ( ) ;
2026-07-29 13:54:34 +02:00
applyAcceptLanguage ( ) ;
2026-09-09 03:27:41 +02:00
applyClientHintsSpoof ( ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
// 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" ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
// 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" ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
const existing = session . defaultSession . getPreloads ( ) ;
feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:
- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
vault is unlocked with a 32-byte HKDF child of the vault root under
"silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
preload asks main (sync, against the committed URL) which add-on bridges
apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
checkbox}) shows a consent overlay over the tab area (approval.html);
resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00
const wanted = [ bcnrPreload , injectPreload ] . filter ( ( p ) => ! existing . includes ( p ) ) ;
if ( wanted . length ) session . defaultSession . setPreloads ( [ ... existing , ... wanted ] ) ;
Ship Theseus 0.0.8: window.bcnr dApp API + eTLD+1 permission origins
Merges a parallel session's work with the multi-source BNS story from 0.0.7.
The dApp side (parallel session)
--------------------------------
* bcnr-preload.js — installs `window.bcnr` on every page via contextBridge.
Read-only surface: resolveName(name), isRegistered(name), getBcnrTlds(),
getRecordVersion(name), plus getPermissionOrigin() for diagnostics. All
Promises; a missing name returns null (not throw). No signing, no wallet
unlock — that surface is designed but deliberately out of scope for 0.0.8
(see TheseusNavigator/DESIGN-integrated-wallet.md).
* bcnr-origin.js — pure function that computes the eTLD+1 permission origin
for a URL. ICANN suffixes via `psl` (same PSL Chromium uses, handles
.co.uk / .github.io / etc); BNS names key off the on-chain TLD list so
foo.wallet becomes a public suffix as soon as `wallet` appears there.
Match browser cookie / MetaMask semantics: a grant on pay.merchant.com
covers account.merchant.com but not evil.com.
* dev/bcnr-selftest.js, dev/origin-selftest.mjs — self-tests, no I/O.
* main.js wires bcnr-preload.js into session.defaultSession.setPreloads() so
it runs BEFORE per-WebContentsView preloads; adds bcnr:* IPC handlers.
* preload.js + chrome.html — small hooks so the shell picks up window.bcnr
the same way regular content does.
* package.json — psl dep, bcnr-preload.js/bcnr-origin.js in `files`.
Also included
-------------
* AriadneResolver/mobile/.../UpdateCheck.java — in-app update-check for the
Android app; already active in the shipped 0.11 APK (build.ps1 -Recurse
picked it up), formalising the source now.
* TheseusNavigator/snapshots/bns-name-snapshot.json — refreshed bundled
starter (73 beacon txs, root c37b8596…c54e414ba).
* Site pages + manifest updated to point at 0.0.8.
TheseusNavigator-Setup-0.0.8.exe 95.4 MB
21939743eafdfe8742a6b7c4b987bd2782384d7bc41289cb80a7e08019dc9f02
TheseusNavigator-0.0.8-portable.exe 92.7 MB
2aa429fe39dc0fa4ac040fc6d6eb31b0f890c8a83175c49fcb50f052c480d39d
2026-08-31 01:38:55 +02:00
} catch ( err ) { console . warn ( "[bcnr] setPreloads failed:" , err ? . message ? ? err ) ; }
2026-07-29 13:54:34 +02:00
protocol . handle ( "bns" , serveBns ) ;
2026-08-02 11:39:00 +02:00
installDownloadTracker ( ) ;
Theseus: add-on framework MVP + Notepad reference add-on
New subsystem for extending Theseus with folders on disk. Each add-on
lives at <userData>/addons/<id>/ with an addon.json manifest and a
CommonJS entry that exports activate(api). Nothing about a private
add-on ships in the public installer - drop the folder, restart, it's
live. Bundled reference add-ons ride in the packaged app under
resources/bundled-addons/ and are seeded into <userData>/addons/ on
first boot; the framework treats seeded and drop-in add-ons the same.
Files:
- addons-host.js Loader + api.registerSidebarPanel() + per-
addon storage on <userData>/addons-data/.
Kept at the CommonJS-scoped top level (lib/
is ESM-scoped via its own package.json).
- sidebar-preload.js Runs in every sidebar panel. Exposes
window.silentmode.storage.{get,set,all} +
onVisibility. Main-side handlers derive the
add-on id from the sender file:// URL, so a
panel can only touch its own store.
- bundled-addons/notepad/ Reference add-on: addon.json, index.js,
note.html. Autosaving textarea with char /
word count.
main.js:
- Extension point: sidebar-panel. One right-anchored WebContentsView
(SIDEBAR_W=340) hosts the current panel; layout() shrinks the tab
views by the sidebar width when visible. First registered panel
wins for MVP; picker for multiple panels lands later.
- initAddons() at app.whenReady(): seedBundledAddons, then
AddonHost.discoverAndActivate.
- IPC surface: sidebar-toggle / sidebar-open / sidebar-close /
sidebar-state, addons-list / addons-set-enabled / addons-reveal /
addons-open-dir / addons-reload, and origin-gated
addon-storage-get/set/all.
- Settings gains `disabledAddons: []` — off-toggled ids persist and
the loader honours them without a restart (discoverAndActivate
runs again on toggle).
chrome.html: toolbar sidebar-toggle button, hidden until at least one
add-on has registered a sidebar panel.
settings.html: new "Add-ons" section under privacy. Lists installed
add-ons with icon / name / version / description / capabilities;
per-add-on enable/disable toggle + Show folder button; page-level
Reload and Open add-ons folder buttons; warning note about the trust
model.
package.json: build.files gains sidebar-preload.js + addons-host.js.
extraResources gains bundled-addons/ so the packaged app carries the
reference notepad for the first-boot seed.
Verified: `npm start` boots, addons-host discovers the notepad,
activates it, registers one sidebar panel. Log confirms
"1 installed, 1 enabled, 1 sidebar panels". Actual sidebar rendering
+ notepad UI need clicked-through validation on a real install.
Not shipped yet - deploy still blocked on the fail2ban VPS SSH ban.
Ships as 0.2.0 once SSH clears (this is a new subsystem, not a fix).
2026-08-31 13:51:08 +02:00
initAddons ( ) ;
feat(theseus/addons): signed add-on update endpoint, à la Firefox XPI
Decouples bundled-add-on updates from Theseus releases. An add-on
whose addon.json declares an updateURL can be republished at any time
without shipping a new Theseus installer; existing installs pick it up
on the next boot's +30 s background check.
Client flow (main-process only, no UI touchpoints in this commit):
initAddons()
├── promoteStagedUpdates() # promote signed stage if newer
├── seedBundledAddons() # bundle wins over on-disk if newer
└── AddonHost.discoverAndActivate()
30 s later:
└── checkAndStageUpdates() # fetch, verify, download, stage
Signature: Ed25519 over
"silentmode.addon-update-v1|<id>|<version>|<tarball-sha256>",
verified against a hardcoded set of operator pubkeys living in
addon-update-pubkeys.js. Domain-separated so the operator key can't
be tricked into signing a message with a different purpose. Empty
pubkey array is the shipping default — checkAndStageUpdates() then
short-circuits and no outbound requests are made, which is the safe
posture until the operator ceremonies a key in.
Payload: gzipped tar, extracted with the system tar (present on
Win10 1803+, macOS, Linux). Path traversal defended by tar's default
refusal of `..` entries; the extracted manifest's id + version are
re-checked against the signed values before staging.
Staged updates go to <userData>/addons-updates-staged/<id>-<version>/.
Promotion into <userData>/addons/<id>/ reuses seedBundledAddons's
backup dance: existing folder moves to
<userData>/addons-backups/<id>-<oldver>-<timestamp>/ so any local
edits survive.
New files:
- addon-updater.js — client
- addon-update-pubkeys.js — hardcoded pubkeys (empty; edit + rebuild to rotate)
- scripts/generate-update-keypair.mjs — one-time keygen
- scripts/sign-addon-update.mjs — operator packager+signer
- docs/ADDON-UPDATES.md — operator brief + threat model
Wired into main.js at boot; screenshot add-on's addon.json advertises
the reference updateURL for when the endpoint goes live.
2026-09-07 21:58:30 +02:00
// 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.
setTimeout ( ( ) => {
addonUpdater . checkAndStageUpdates ( {
addonsDir : addonsUserDir ( ) ,
stagedDir : addonsStagedDir ( ) ,
pubkeysHex : ADDON _UPDATE _PUBKEYS ,
logger : ( ... a ) => console . log ( "[addons]" , ... a ) ,
} ) . catch ( ( ) => { } ) ;
} , 30_000 ) ;
2026-07-29 13:54:34 +02:00
createWindow ( ) ;
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 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
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// 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.
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 1) sync: load the on-disk snapshot (user cache > bundled).
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// sharedIndex is set BEFORE the first restored tab navigates.
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
// 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.
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
bnsWarm = warmFromSnapshot ( ) . catch ( ( ) => null ) ;
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
// Cheap update check: fetch the releases manifest and, if a newer
// version is out, surface a chip in the toolbar. No auto-install —
fix(theseus/boot): paint the toolbar first — stop gating startup on chrome.html's load event
Users saw a blank window with a white strip across the top for seconds
on launch. Root cause: every part of startup, including session restore,
waited for chrome.html's did-finish-load. That event also waits for the
page's subresources, and the bookmarks bar loads its favicons over
bns:// — a BNS lookup plus a network fetch each — so a slow link held the
whole boot. On top of that, seven hidden overlay renderers, every restored
tab, the BNS index build and three network fetches all started in the
same tick and stalled the main thread ~1 s while the toolbar tried to
paint.
- Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC
listeners exist) instead of did-finish-load; 8 s fallback timer.
- Window and chrome view get the toolbar's --bg for the active theme so
the pre-paint frame is never white.
- Overlay pages (site info, engine picker, downloads, suggestions,
password fill, link status, approval) load 250 ms after the toolbar or
on first use; the approval modal awaits its page so a dapp request
can't hang.
- Session restore is staggered: active tab first, then one background
tab per 150 ms slotted into its saved strip position. Session file v2
records the active index; v1 arrays still load (active = last, as the
old loop effectively did).
- AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy
dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect)
behind it.
- BNS snapshot warm-up still starts right after createWindow (bookmark
favicons need it); Sia refresh, update check and home-card fetch move
to the post-paint phase.
Measured on a clone of the real profile with nine restored tabs: toolbar
usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load
down from ~1.1 s to ~0.2 s.
2026-09-09 11:40:45 +02:00
// 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.
Theseus 0.0.4: cheap in-app update-check chip
Bumps version so the chip actually surfaces itself on 0.0.3 installs
(the version-newer check requires a strict semver bump — same-version
rebuilds don't trigger the chip). From this release on, whenever the
manifest names a newer Theseus, users get a one-click download.
Mechanism
- main.js checkForUpdate() fetches https://dl.silentmode.st/releases-
manifest.json on startup (5s timeout, cache: no-store) + every 6h.
Finds the theseus-navigator release, compares version to
app.getVersion() with a numeric a.b.c comparator that handles
"0.10.0 > 0.9.9" correctly.
- On a match → stores { version, setupUrl, portableUrl, setupHash,
portableHash, date } and emits update-available to chrome. Cleared
after the user upgrades + relaunches (same-version → null).
- Re-emits on chrome's did-finish-load in case the fetch beats the
chrome view.
Chip UI (chrome.html)
- Acid-yellow pill between the downloads button and the Tor toggle:
"↓ Update to X.Y.Z" + a ✕. Main body opens setupUrl in the system
browser via shell.openExternal (origin-validated to
https://dl.silentmode.st/ or https://silentmode.st/). ✕ dismisses
for the current session — you'll see it again next launch if still
behind.
Trust anchor
- No signing / no cryptographic verification of the download in this
phase. releases.silentmode.bch publishes the SAME manifest URL, so
users who want to verify can cross-check the manifest hash against
what BCNR returns. The proper auto-updater with signature checks is
the follow-on to this cheap version.
Non-goals in phase 1
- No delta downloads; the user clicks and gets a full installer.
- No auto-install; download → user runs the installer themselves.
- No "check now" button in Settings; the periodic timer suffices.
- No portable-vs-installed detection; the chip prefers setupUrl (the
installer upgrades in place). Right-click for portable is future work.
2026-08-28 20:21:44 +02:00
setInterval ( ( ) => checkForUpdate ( ) . catch ( ( ) => { } ) , 6 * 60 * 60 * 1000 ) ;
2026-08-31 18:38:09 +02:00
// 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 ) ;
2026-07-29 13:54:34 +02:00
app . on ( "activate" , ( ) => { if ( BrowserWindow . getAllWindows ( ) . length === 0 ) createWindow ( ) ; } ) ;
} ) ;
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
app . on ( "before-quit" , async ( e ) => {
// Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank.
saveSession ( ) ;
stopTor ( ) ;
Theseus 0.0.7: multi-source BNS with continuous delta refresh
The first .bch page now opens with zero user-visible latency and stays fresh
for as long as the browser runs. Four sources conspire in parallel — none of
them can block a navigation:
1. Warm start from disk (sync)
warmFromSnapshot loads the user-cache snapshot first, then falls back to
the copy bundled with the build. sharedIndex is set BEFORE the first
navigation can even fire.
2. Continuous background delta refresh (every 30 s)
startBnsPolling opens one electrum connection, fetches the beacon
history (a single call), and only pulls verbose tx bodies for txids we
don't already have — mergeFreshHistory-style. Merges into memory,
rebuilds the index locally, persists the enlarged snapshot to disk.
Turns a ~60 s full walk into ~1 s per new event.
3. Sia snapshot pull on boot (one-shot, wins the NEXT boot)
refreshSnapshotFromSia downloads the operator's published snapshot from
s3.silentmode.st for the next launch. After a long idle period the
browser resumes from that fresher snapshot instead of walking days of
events.
4. ensureIndex full-walk fallback
Still runs on boot for the case where there's no bundled snapshot AND
the poll hasn't landed yet — very first launch, offline install, etc.
resolveHost now prefers sharedIndex (which the poll keeps live) and, on a
miss with a stale index, triggers pollAndMerge (delta fetch) instead of
ensureIndex (full walk). The old full-walk fallback is only taken if the
delta primitives aren't available (running against an older resolver-web).
Argus/src/lib/resolver-web.js exports connectElectrum so the Theseus poll
can drive its own ad-hoc queries against the pool without duplicating the
Electrum wrapper.
Bundled starter snapshot refreshed: 73 beacon txs, root c37b859682…54e414ba.
Artifacts:
dist-public/TheseusNavigator-Setup-0.0.7.exe (95.4 MB)
sha256 1b0dabcd2b13067aa1fd89c3271708b35ba22dfc7a620e248b35e0487b7a104f
dist-public/TheseusNavigator-0.0.7-portable.exe (92.7 MB)
sha256 706dc01b21a88d3c2fbc3eeced0cab6ace873630bf3edc2d40915ca5be5c8cae
2026-08-30 15:57:01 +02:00
stopBnsPolling ( ) ; // silence the background delta refresh before exit
Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint:
- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
(+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
2026-08-14 23:17:18 +02:00
vaultState = null ; // drop the in-memory vault key + purposeRoot
try {
await clearBrowsingData ( {
cookies : settings . clearCookiesOnQuit ,
cache : settings . clearCacheOnQuit ,
storage : settings . clearStorageOnQuit ,
} ) ;
if ( settings . clearHistoryOnQuit ) {
try { fs . unlinkSync ( sessionFile ( ) ) ; } catch { }
}
} catch ( err ) { console . error ( "before-quit clear failed:" , err ? . message ) ; }
} ) ;
2026-07-29 13:54:34 +02:00
app . on ( "window-all-closed" , ( ) => { stopTor ( ) ; if ( process . platform !== "darwin" ) app . quit ( ) ; } ) ;
}
2026-09-10 22:25:19 +02:00
module . exports = { serveBns , resolveHost , isBnsHost , nativeTld , dualTld , registryOf , openLinkWindow } ;