The startup check shared the main thread with snapshot parsing, tab restore
and add-on activation, under a 5 s abort timer started before the request.
Measured on an empty profile: 3.2 s for the manifest fetch, 1.6 s of it the
event loop being busy; a real profile went past 5 s, the abort won, the
failure was swallowed and nothing retried before the 6-hourly recheck. The
update only appeared after a manual "Check for updates".
- the startup check runs 8 s after the toolbar is ready and retries with
backoff (30 s, 2 min, 10 min, 30 min) when it fails
- 20 s timeout via AbortSignal.timeout
- a failed installer download is retried on the next check instead of
staying failed until the next release
- failures are logged
- Resolved names are re-resolved when a newer index lands and evicted when
they drop out of it; an edited ip/s3/tls record, a transfer or an expiry
used to keep serving the old target until restart. The signed-DNS A
fallback follows its 30 s TTL instead of the first answer it ever saw.
- A cross-host navigation to a host the warm index knows is unregistered is
left to Chromium: replaying it via loadURL turned form POSTs (OAuth
form_post, SAML, 3-D Secure) into bodyless GETs. The site badge follows
navigations Chromium makes on its own.
- A background tab's alert/confirm no longer pulls its tab to the front; it
waits, marked in the tab strip, until the user switches to it. Dialogs in
other windows use the async box, so they no longer freeze every tab.
- Messages resolves sender keys from the browser's own index (one map per
index generation) instead of a full chain walk per unknown sender; the
dedupe set is bounded.
- Ariadne uninstall reads HKLM only and runs nothing but unins###.exe from
Program Files, elevated directly rather than via cmd /c.
- Tor and an add-on proxy no longer wipe each other's settings: Tor wins
while on, the add-on's rules come back when it goes off.
- Profile migration copies beside the target and renames it into place;
a failed copy keeps the old, complete profile instead of a partial one.
- Reload/DevTools/zoom shortcuts in app and link windows act on that window;
Ctrl+B stays with web pages (bold) and toggles the sidebar elsewhere.
- quickPanel comment corrected: it shares the default session on purpose.
A preload belongs to the WebContents, not the page: a website loaded into
the Settings tab kept window.cfg and could read every vault password, flip
settings and install extensions without consent. Settings and add-on tabs
now never load web content, and the channels behind settings-preload check
their sender. `navigate` no longer accepts calls from web pages.
Add-on updates trusted any publisherSig, whatever name it carried, even for
bundled add-ons. The trust root is now the installed addon.json (publisher,
or the operator key when there is none); versions must be plain dotted
numbers; a community install can't take over a bundled or foreign id.
Also:
- autofill matches and fills against the live URL, not a stale prov.host
- bns:// forwards the raw request path (..%2F escaped the name's bucket)
- clipboard-read denied, openExternal asks; forged collision choices ignored
- web pages can't window.open file:/chrome:/theseus:; data:/blob: no longer
go to the search engine; the quick-links panel loses home-preload
- clear-history-on-quit is awaited and removes history.json too
- update helper takes its paths from the environment (non-ASCII profiles)
- electrum poll has a deadline; misses wait at most 2.5 s
- p records go through Tor; add-on proxy credentials are actually used
- whole-folder require-cache bust on add-on version change; failed
activate() no longer leaks its request filter
- approvals released when the window closes; web-app ids stay on-origin
Clicking an icon on the left strip now opens the service inside a dedicated
380-px mini-view (quickPanel) next to the strip, Opera-style, instead of a
new full-sized tab. Click the active icon again to close the panel; click a
different one to switch. If the panel is already pointed at the same host,
we skip the loadURL so scroll position, open chat and login state survive
a close+reopen round-trip.
Icons now paint as real brand SVGs (Messenger, WhatsApp, Telegram, X,
YouTube, Spotify) with their official colours, bundled inside quicklinks.html
so no external favicon fetch leaks the fact that the strip is loaded.
Unknown ids fall back to a letter chip. The strip vertically centres the
icons between two flex spacers to match Opera's layout.
Defaults ship Messenger, WhatsApp, Telegram, X, YouTube and Spotify. The
Settings › General › Quick links section still lists / adds / removes
entries and toggles the strip.
New thin vertical column (44 px) on the left side of every page, Opera-style.
Click an icon to open its web app in a new tab; if a tab is already open on
that host, we focus it instead of stacking another one. Hidden in HTML
fullscreen so a video still fills the window; toggle via Settings › General ›
Quick links › Show the strip.
Defaults ship X, Telegram and WhatsApp. The Settings › General › Quick links
section lists the current entries with a Remove button each and a Title + URL
+ Add row that auto-prefixes https:// and auto-fills the title from the
hostname when empty. Edits write the whole settings.quickLinks array; the
strip view and the window layout react through settings-set, so no restart is
needed.
Settings › General › Updates panel also now runs standalone (no longer gated
by anything in the shared cfg.get().then() init), so a thrown exception in an
unrelated feature can't leave it stuck on "Loading…" any more — the version
line reads immediately and Check for Updates stays functional.
Session restore now paints the full strip from the saved titles + favicons and
loads only the ACTIVE tab's page; every other restored tab lives as a dormant
WebContentsView and navigates for the first time when the user clicks it. For
a 20-tab user that drops cold start from 20 renderer loads racing chrome.html
to one, so launch is roughly flat whatever the tab count — fixes the "not
responding" freeze on a session with many restored tabs. session.json is now
v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still
parse (their tabs restore lazy without a cached title, which arrives on first
activation). Reload on a dormant tab materialises it.
Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system
language) and Manual — matching the General › Website language row and the
URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone
from the UI; legacy saved values auto-migrate to Automatic on first open. The
Manual list is the same 24 languages the General row uses, kept in one place
(WEB_LANG_LIST), so all three surfaces stay in sync.
Changing the language via the globe chip or either settings row now reloads
the active tab — the server picked the response body from Accept-Language on
the original request, so an already-rendered page can't adopt the new language
on its own. A reload is what a user clicking a one-click language switch
expects.
The Location row's country dropdown now stacks under the mode dropdown on its
own line when Manual is picked, so an open menu above it can't visually cover
it (the row's flex-row max-60% layout could wrap it where another dropdown's
overlay sat).
Also: the settings-update broadcast now reaches every open settings tab, not
only the chrome — so changing the chip updates both the General Website-
language row and the Privacy Anti-fingerprinting Language row live without a
Settings refresh.
Intl.DisplayNames.of("en-US") returns "American English", which spells out a
distinction the picker doesn't make — one row per language, with English the
UK original. Pass the base code to Intl so the chip tooltip, the "Automatic
(…)" label and the Settings hint all read as the plain language name
(English, Russian, Portuguese, Chinese) regardless of which regional variant
the OS or the saved setting happens to be.
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
Sits on top of the earlier 0.3.61 commit (bundled into the same shipped build):
Spanish and Portuguese collapse to their originals — es-ES and pt-PT — and the
Mexican / Brazilian variants come off the picker (same 2-letter chip, roughly
the same text). Ordering is now global-speakers ranking with European
languages first, so the languages a European desktop is most likely to want
sit at the top: English, Español, Français, Português, Русский, Deutsch,
Italiano, Türkçe, Polski, Nederlands, Ελληνικά, Čeština, Svenska, Suomi —
then the non-European tier led by 中文, हिन्दी, العربية and so on.
Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.
Added to the plugins-ariadne sub-page (after Status, before Remove):
Collision policy -- radio group (BCNR-first / ICANN-first) writes
C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
by the daemon within 5 s.
Sources -- 3-column grid, one row per source (snapshotHttps,
electrumWss, perQueryLookup, diskCache, localApi):
enable checkbox + last-state summary
(last success / last error / hit-miss counters /
disk-cache size+mtime). Toggle writes
policy.json.sources.<name>.enabled and re-polls after
the 5-s hot-reload tick so the state text catches up.
Status report -- <pre> JSON dump of GET http://127.0.0.1/api/status
with Copy report + Refresh report buttons. This is
the paste-me-into-support artefact for any diagnosis.
IPC wiring:
main.js
ariadne-get-status -> GET http://127.0.0.1/api/status ({ok, status|error})
ariadne-get-policy -> read C:\ProgramData\Ariadne\policy.json (or {})
ariadne-set-policy -> merge {policy}, write back (validates enum)
ariadne-set-source -> merge {sources.<name>.enabled}, write back
(validates against the known 5 names)
settings-preload.js
ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource
All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).
Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).
Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
Privacy › Location is three modes now: Show real, Hide, Manual. Manual reveals a
50-country dropdown whose pick becomes the coordinates navigator.geolocation
returns to pages — country-capital granularity, no regions or free-form cities.
Old profiles on the retired "Spoof (region)" auto-migrate to Manual + the
region's representative country on first open, so nothing breaks.
VPN row in Privacy stops opening the wrong add-on: the sidebar now no-ops on a
specific panelId that isn't registered (used to silently substitute panels[0],
which surfaced Aegis whenever the VPN add-on was disabled), and the row hides
itself when vpn:main isn't in the sidebar panel list.
Language picker (globe chip menu + Settings › General › Website language) drops
the BCP-47 tag from every visible label — the tag surfaces only as the 2-letter
chip in the URL bar once picked. "English" is the UK original; the US variant
row is retired (same 2-letter chip, ~same text). Ukrainian dropped from the
quick list too. "Automatic" reads as the OS language name (Intl.DisplayNames)
instead of a raw en-US style tag.
A globe chip next to the URL-bar star shows the language sites see you in
(Accept-Language + navigator.language) — "AUTO" while following the OS locale,
the two-letter code once you pin one. Click opens a 23-language menu; the same
setting has a friendly row at the top of Settings › General. Both write to the
existing languageMode/languageValue and stay in sync with the Anti-fingerprinting
Language row through a settings-update broadcast (settings.html and chrome.html
both react live).
Settings › Plug-ins is now two compact rows — one per plug-in — with the on/off
toggle on the right and the update controls beside it. Clicking a plug-in's title
opens its own sub-page (plugins/ariadne, plugins/aegis) with the full description
and the Uninstall button, so the main list stays scannable and dangerous actions
stop travelling with the everyday ones. The Ariadne toggle and its sub-page
mirror the same scheduled-task state.
Nothing handled HTML fullscreen. Electron put the window in fullscreen for a
page (a video player) with the toolbar still on top, and when the page left
fullscreen while its tab was hidden, or the tab was switched away from or
closed, the window stayed fullscreen: no title-bar buttons, the taskbar
covered, and no key to get out. Tabs now report entering and leaving
fullscreen; the toolbar and sidebar make way for the page; switching or
closing the tab ends it and tells the page; F11 toggles a fullscreen with
the toolbar kept and doubles as the way out. A page's own exit is left to
Electron, which has already taken the window out by the time it tells us;
exiting again during that transition brought the window back maximized.
It was a native <select>, which can only show text, so each option carried
an emoji in front of the name; after the engine icons moved into the build
that was the one place still showing emojis. The control is now drawn by
Settings with the same icons as the rows below, grouped like the toolbar
picker, with arrow-key and Escape handling. Choosing a default there also
repaints the toolbar at once: the generic setting write never told it.
Every engine icon was an <img> pointing at Google's favicon service, fetched
again each time the picker, the toolbar or Settings rendered. Offline the
whole list collapsed to the emoji fallbacks, and each open told Google
which engines the user has configured. The catalog's icons now live in
engine-icons/<id>.png inside the app; a custom engine's icon is fetched
once (its own /favicon.ico first, the favicon service as fallback), cached
under the profile, and removed with the engine. Settings no longer falls
through to DuckDuckGo's icon service either. Phind ships no icon: its site
serves none through the bot wall.
The gateway checks a name's host rules before it decides what to serve, so a
blocked or redirected subdomain behaves the same whatever record the name
carries. Theseus only inherited that for names it proxies through the
gateway's /bns/ mount. A name with both s3 and ip — the shape that caused
the 2026-08-13 subdomain bug — would have had its blocked subdomain answer
anyway, because Theseus talks straight to the IP.
It now asks the gateway for the host's verified rule before taking either of
the paths it serves itself, and only for those paths, so an ordinary
subdomain navigation gains no round trip. Verification stays in one place:
the client reads a decision, it does not re-derive one.
The spec catches up with what is implemented — it still described v1 and
called hosts a future idea.
DNS over HTTPS through Chromium's secure DNS (app.configureHostResolver),
under Privacy › Network: Default protection (encrypted via the chosen
provider, plain if that fails — the default), Increased protection
(always the provider, never plain) or Off, with Quad9, Cloudflare,
Mullvad, AdGuard or a custom resolver URL. Any DoH mode also turns on
Chromium's built-in resolver, as Chrome does. Silent Mode names never
touch DNS, and Tor resolves remotely through the SOCKS proxy, so
neither path goes around it.
Global Privacy Control, on by default, under Tracking protection: the
Sec-GPC header on every request (added in the one request-header hook
beside the client hints) and navigator.globalPrivacyControl in pages.
Navigation base, the way Firefox does about:preferences#privacy: the
address bar follows the Settings page (theseus://settings/privacy) and
the hash mirrors it, so every page has a link; a page can have
sub-pages (theseus://settings/privacy/exceptions) with a breadcrumb and
a back arrow; open-settings and theseus:// links accept the two-level
slug.
Privacy now reads top-down: a "Theseus is on guard" card (Shield and
its running total, cookie pop-ups answered, Tor state, version), then
Tracking protection with the Shield and Cookie Pop-ups cards moved here
from Performance and a Manage exceptions sub-page listing the sites
each add-on was told to leave alone (remove to protect again), then
Device access, Anti-fingerprinting, Network (Tor switch and the VPN
panel) and Browsing data. Performance is about resources again.
Every client hard-coded the chipnet beacons, address prefix and electrum
servers on its own: resolver, registrar config, wallets, gateway, indexer,
mirror scripts, the browser bundle and the mobile Java. A mainnet launch would
have meant finding all of them and hoping none was missed.
The table now lives in resolver-web.js, the one file every client already
shares, so it stays a single-file drop-in. BNS_NETWORK selects the record;
unset means chipnet, so nothing changes today: the live index resolves the
same 60 names and 20 TLDs, the 67 offline tests pass, and the dashboard,
market and studio load the same values through BNS.NETWORK.
The mainnet record carries the verified public servers, the prefix and its
own Sia bucket, but its beacons, start height and operator address are
deliberately null: requireBeacons() refuses to scan until they are pinned in
the order ROADMAP-MAINNET.md §6 requires. Bns.java reads a generated
BnsNetwork.java so the phone cannot drift from the desktop clients.
NETWORK-CONFIG.md records what reads the table and what a launch still pins.
autoHideQuietDock ran only when the sidebar state was pushed; on a
fresh boot the chrome pulls it, so Shield and Cookie Pop-ups showed
in the dock until something re-emitted. Run it on the pull path too.
Shield and Cookie Pop-ups are settings more than tools, so their
switches, the cookie mode, the counters and "Update rules" now sit in
Settings › Performance under a Protections heading, driven through the
add-ons' own message handlers (Settings-only IPC). Each card opens the
add-on's panel for the per-site details, and each panel links back to
Settings. The two add-ons start hidden from the toolbar's extension
row (manifest dock:"hidden", honoured once so a user who shows them
keeps them); "Show hidden" on the row brings them back.
theseus://settings and theseus://settings/<section> are now addresses,
so any page or note can link to a Settings page.
Also: a Settings or add-on tab that the user navigates elsewhere stops
counting as that tab, otherwise "open Settings" kept focusing a tab
that no longer showed Settings.
Install-this-app, remove-app, the extension install flow (install,
already installed, installed, not found, failed) and the add-on restart
question were still bare OS message boxes titled "theseus-navigator"
after page dialogs moved to the sheet. askSheet() is a drop-in for
dialog.showMessageBox with the same options and result: title as the
headline, detail under it, the caller's buttons with the default first,
an optional checkbox, and the app's or extension's icon when there is
one. Tone follows the box type (error, warning) or the wording. The
native box stays as the fallback when the browser window is not there,
and for the two synchronous cases (beforeunload, app windows).
Theseus had no content blocking at all. Shield blocks requests to known
tracking and advertising hosts on every site, using EasyList and
EasyPrivacy through Ghostery's adblocker engine (the matcher those lists
are written for). The lists ship inside the add-on so blocking works
from the first launch, offline; the compiled engine is cached under the
add-on's data dir (a 22 ms load instead of a 500 ms parse), and the
lists refresh from their publishers about once a day.
The panel shows what was stopped on the current page, a one-click
allow for the site, the global switch, the running total and the rule
versions with an "Update now". Network filters only for now: a blocked
request never leaves the browser, but leftover empty ad boxes are not
hidden yet.
Host side: a "request-filter" capability. Chromium allows one
onBeforeRequest listener per session, so main owns it and consults the
add-ons' filters; a top-level navigation is never blocked, only http(s)
subresources are offered. api.tabs (active tab and a change event) lets
the panel show per-site numbers without seeing page content.
The sheet now follows the notification-card pattern: a tone icon in a
tinted circle, the message in bold under a "who says" caption, a
tinted primary action and a quiet Cancel, close in the corner. The tone
is read from the message — delete/remove/error reads as destructive
(red, and the confirm button says Yes), unsaved/required/leave as a
warning, saved/completed as success, anything else as plain info —
since a page hands over only a sentence.
Also decide "who says" from the sender's current URL rather than the
tab's prov, which lags a navigation: a tab that had just left the home
page for a site was still labelled Theseus.
alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.
The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
0.3.56 was cut from the Aegis line (WizardConnect auto-detection, Aegis
0.8.x, PDF Editor and VPN updates) on top of 0.3.55; 0.3.57 carries that
plus the install-as-app feature and the two main-process crash fixes.
Tabs keep emitting events while the window is torn down: a hovered
link fires update-target-url, which positioned the link-status pill
against win.getContentBounds() on a destroyed window ("Object has been
destroyed", 2026-09-27). With installed web apps the browser window can
now close while their windows keep the process alive, so this stops
being a quit-time blip and becomes a normal state.
The overlay helpers and layout() now check the window is alive, the
session is captured on close (the quit-time save no longer overwrites it
with an empty list once the tabs are gone), and "closed" drops every
reference to the window's views. A later createWindow() starts from a
clean tab list, so a page opened from an app window after the browser
window was closed brings the window back with the restored session.
Node's ws aborts the handshake when close() is called on a CONNECTING
socket and emits an error on the next tick; with no listener that is an
uncaught exception, and Electron answers with the modal "A JavaScript
error occurred in the main process". nostr-tools drops its onerror
handler right before closing, which is what the WizardConnect relay
teardown in Aegis runs on every wallet disconnect while a relay is
still connecting. Browser WebSockets ignore the same sequence, which is
why the library gets away with it elsewhere.
Every consumer in the main process shares the one ws module, so
close() now adds a no-op error listener to a connecting socket before
aborting it. Anything else that still escapes to the top of the process
is logged to <userData>/main-errors.log instead of raising the modal;
Electron continued after that dialog anyway, so only the interruption
goes.
Electron ships Chromium's renderer without the browser-side web-app
install machinery, so beforeinstallprompt never fires and every site's
own "Install our app" chip (coin-spectrum.com's, for one) stays hidden
in Theseus. The browser side now exists:
- webapps.js reads a page's <link rel="manifest">, accepts it when it
names an app with a standalone-style display mode and a start_url on
the page's origin, and records the descriptor on the tab.
- The address bar shows an install chip for such pages (filled once the
app is installed: click then opens or removes it); the page context
menu carries the same entry.
- Pages get a synthetic beforeinstallprompt whose prompt() routes to the
Theseus install dialog and resolves userChoice like Chrome, and an
appinstalled event afterwards, so sites' own chips appear and work.
- Installing stores the app under <userData>/webapps/, wraps the
manifest icon into an .ico, writes a Start Menu (optionally desktop)
shortcut that launches Theseus with --app=<start_url>, and opens the
app in a chromeless window with its own taskbar identity. The window
shares the session, BCNR resolution, fingerprint and add-on bridges
with tabs; popups and "open in Theseus" go to the browser window,
Alt+arrows / F5 / Ctrl+R cover navigation without a toolbar.
- Theseus takes the single-instance lock so a shortcut launch lands in
the running browser (second-instance) instead of a second profile
owner; launched cold, --app= opens only the app window and a later
plain launch brings the browser window back.
Electron hands every page an empty window.chrome. Real Chrome's carries
app, csi, loadTimes and runtime, and bot checks — Google's sign-in
botguard among them — look for exactly those to tell Chrome from an
embedded Chromium. The per-tab identity script now fills the object
with the same shapes and return types; a site learns nothing it would
not also learn from stock Chrome.
pws=0 (personalisation off) is the parameter rank-tracking scrapers put on
every query, and Google weighs it when deciding to serve the "unusual
traffic" check. With cookies cleared on quit the personalisation it turned
off was already minimal, so the flag bought nothing and made every search
from Theseus look like a bot's. hl and gl stay: they keep Google from
answering with a consent redirect or a region-detect start page.
Completes the three detection paths. The injected provider shipped in
0.8.8; these two needed host support, because nothing in the add-on API
could reach the active tab's content (captureTab is pixels, not DOM).
wiz:// links (main.js)
A click on a wiz:// anchor is intercepted in will-navigate and in the
window-open handler (target="_blank" lands there instead), and routed
to the wallet with the offering page's origin attached, so the
approval names the real site. The tab never navigates. This needs
nothing from the dapp beyond rendering the URI as a link, so it works
for third-party dapps that will never adopt a Silent Mode API.
scan-page capability (addons-host.js + main.js)
New capability backing api.scanActiveTabForUris({scheme, limit}).
Deliberately NOT a "read the page" API: the host runs the match and
returns only the URIs found, so an add-on holding this still cannot
see page text, markup or form values. It sits well below page-inject
on the trust ladder — it learns that a page offers a wiz:// code and
nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the
result count is capped.
The matcher also accepts WizardConnect's QR-alphanumeric spelling
(WIZ://%3FP%3D…), which is frequently the only form present when a
dapp renders its pairing code as a QR, and decodes it. Verified
against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and
QR-decoded alike.
Regex sources are built host-side and passed as JSON rather than
assembled inside the injected string — hand-escaping backslashes and
quotes through two levels of literal was both wrong on the first
attempt and unreviewable.
Aegis
Declares scan-page, adds the wcScanPage handler and a "Scan page"
button next to Connect. A scan fills the URI field and stops there
rather than pairing outright: the user still chooses which wallet
signs and still presses Connect, because a scan that silently paired
would carry far more consequence than the button implies. Older hosts
without the capability get a clear "update Theseus" message instead of
a dead button.
Launched from a shell, the main process inherits that shell's stdout. When
the shell exits the pipe breaks, and the next console.log from the add-on
host raises EPIPE — which Electron reports to the user as a fatal uncaught
exception, over a diagnostic line nobody was left to read.
Aegis relaunches Theseus after staging its own update; seen twice in a
dev instance, the whole browser restarted with no warning, mid-session.
The add-on API's restartApp now asks the user in a native dialog
("Aegis Wallet wants to restart Theseus" — Restart now / Later, Later
is the default) and resolves { restarted, deferred }. Declining loses
nothing: a staged update applies on the next normal launch. The guard
lives in the host, so it covers every Aegis version on the channel and
any future add-on.
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
The extension buttons sat in registration order with no way to change
it, hide one, or switch an add-on off without opening Settings. Buttons
are now draggable (drop side follows the pointer, same feedback as tabs
and bookmark chips) and the order is persisted per profile. Right-click
opens a native menu: open/close the panel, move left/right, hide from
the toolbar, turn the add-on off, and Manage extensions; the dock's own
right-click offers Show hidden. Main owns the prefs (dockOrder,
dockHidden) and the actions, so the chrome only renders. Add-on
rediscovery now pushes a fresh dock state to the chrome, which it never
did before — turning an add-on off or installing one from a page
updates the toolbar at once.
0.3.50's relocateProfile checked for %APPDATA%\Theseus Navigator\, but
Electron's userData path is derived from app.getName(), which reads
package.json's top-level "name" ("theseus-navigator") because there is
no top-level productName — the "productName": "Theseus Navigator" in
this file lives under "build", where electron-builder reads it for the
installer, not where Electron reads it for the runtime path. So the
folder the user's Theseus writes to is %APPDATA%\theseus-navigator\,
never %APPDATA%\Theseus Navigator\.
On 0.3.50 that meant relocateProfile found nothing at its search path,
returned the new Theseus\ location, and Electron happily created a
fresh empty profile there. The user's addons, vault, bookmarks and
settings stayed in theseus-navigator\ but the running Theseus was no
longer looking at them. Losing the vault is not something the user
can recover from.
Check both candidate names — the one the code was written for and the
one that actually exists — and migrate whichever is present. If the
new Theseus\ already exists (Windows fresh installs after 0.3.51), we
leave it alone.
The extensions page could only hand out tarballs; installing meant going
to Settings › Extensions › Community and finding the entry again. Pages
now get window.bcnr.installExtension(id) and Theseus intercepts
theseus://extensions/install/<id> links (page clicks, target=_blank and
the address bar). The page only names a catalog id: Theseus fetches the
catalog and package itself, asks in a native dialog the page cannot draw
over, verifies the publisher signature against the name's current owner
and activates the add-on — the same path a Settings install takes. One
prompt at a time; an already-installed version says so instead of
offering a no-op update.
The site shows the button inside Theseus (feature-detected on the
bridge), a "update Theseus" hint on older builds and a download hint in
other browsers.
The profile folder was Electron's default from the product name
("Theseus Navigator") and add-ons lived in addons\ under it. Now:
%APPDATA%\Theseus\extensions\ installed extensions
%APPDATA%\Theseus\extensions-data\ per-extension storage + scratch
%APPDATA%\Theseus\extensions-backups\ replaced copies
%APPDATA%\Theseus\extensions-staged\ staged updates
Both moves are one-time migrations on the first start that finds the old
layout: the profile folder is renamed (same volume, instant) or copied
when a rename is refused, with the old folder left in place in that case;
the four sub-folders are renamed before the extension host first reads
them. Nothing is deleted. THESEUS_USER_DATA still overrides everything.
The host now hands each extension its data folder as api.dataDir; the
Screenshot and PDF editor add-ons used to rebuild the old path from their
own folder for scratch files (so they recreated addons-data\ after the
move) and now use the field, with versions bumped so the bundles reseed.
Adds a new "context-menu-item" capability. Add-ons declare a
"context-menu-items" array in their manifest:
{
"capabilities": ["context-menu-item", ...],
"context-menu-items": [
{ "id": "translate-selection", "label": "Translate selection",
"when": "selectionText", "icon": "🌐" }
]
}
The `when` filter is one of selectionText | linkURL | editable | image
| always. Right-click on a page, and items whose `when` matches the
current context get merged into the native menu after the built-in
Search-for entry, before Back/Forward/Reload. Both context-menu
handlers (main tab area + detached link windows) share the same
merging logic.
Picking an item dispatches "context-menu" to the add-on's onMessage
handler with the full context (selectionText, linkURL, mediaType,
srcURL, pageURL, host). The add-on decides what to do — the
translate add-on stashes the selection to storage and calls
api.revealSidebar("main") which surfaces its own sidebar panel.
api.revealSidebar(panelId) is the paired hook. Ownership is enforced
by the host — an add-on can only reveal panels it registered —
before routing to main's setSidebar path.
Unknown capabilities were already silently dropped by
validateManifest, so older Theseus builds that don't understand
"context-menu-item" just ignore it, and the manifest still loads.
Add-ons that also declare "sidebar-panel" keep working; the new
capability doesn't require it.
This is the wiring that pairs with the translate/ add-on landed in
4498fbb — right-click "Translate selection" is live once this ships.
Holding Back or Forward for 450 ms (or right-clicking it) pops a native
menu of the tab's history entries in that direction — nearest first, up to
15, titled with the page title and host — and picking one jumps straight
to it. A hold swallows the click that would otherwise fire on release, so
a long press never also goes back one page.
Anyone who owns a BCDN name can now publish a Theseus extension, and every
Theseus can install it with the publisher's signature verified locally.
Gateway (Argus/src/gateway/public-gateway.mjs):
PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH
message signatures against the name's current NFT owner (one authorises
the upload, one is stored in the channel), inspects the package
(addon.json at the root, id/version/main match, 8 MB cap), enforces
first-publisher ownership of an id and monotonic versions, and writes the
tarball, the extension's updates.json and community/catalog.json to Sia.
GET /api/ext/catalog reads the catalog back with CORS.
Theseus:
lib/publisher-sig.mjs recovers the signer of a channel entry; main.js
compares it with the publisher name's owner from Theseus's own chain
index before installing or updating, so neither the relay nor a tampered
catalog can pass off code under a trusted name. addon-updater.js gains
installCommunity() and accepts publisher-signed entries in the regular
update check (operator Ed25519 entries unchanged). Settings › Extensions
shows the community catalog with Install / Update; Settings › Plug-ins
links to theseus.x/plug-ins.
theseus.x:
/plug-ins/ is a separate page for the first-party plug-ins (Aegis,
Ariadne's Thread) with live versions and hashes; /extensions/ lists the
bundled extensions, the community catalog, and how to build and publish;
/extensions/publish/ signs and uploads a package in the browser with the
wallet that holds the publisher's name (session helper + wallet bundle
copied alongside).
A year-old engine is now a bot signal in itself: DataDome blocked
estore.asus.com for Theseus on Chromium 130 while the same request claiming
Chrome 152 went through, and Chromium 130 carries a year of unpatched
renderer bugs. Electron 44 boots the app unchanged; verified on the new
engine: local files, HTTP auth prompt, tab strip in the title bar, BNS
sites and window.bcnr, all bundled add-ons, the Tor toggle
(check.torproject.org via the SOCKS agent), and a full NSIS + portable
build (artifacts grow from ~99 MB to ~132 MB with the larger engine).
session.setPreloads is deprecated from 35 on; preloads are registered
with registerPreloadScript when available, with the old call as fallback.
estore.asus.com (DataDome, "AI Threats Detection") served its block page to
Theseus while a plain Chromium on the same connection got the product page.
Three things in our identity were wrong:
- The client-hint brand list was hand-written with "Google Chrome" first —
a permutation real Chrome never sends. It is now computed the way Chromium
does it (GREASE brand from the major version, per-major brand order).
- The page-side navigator.userAgentData still said "Chromium" only, so
headers and JS disagreed. The same metadata is now installed per tab via
Emulation.setUserAgentOverride, so both sides match.
- Accept-Language went out as "en-US,en;q=0.8;q=0.9": we appended a q-value
and Chromium appended another. Chromium now gets a plain language list.
That makes the identity self-consistent, but DataDome still blocks on the
version: Chromium 130 (Electron 33) is a year old, and claiming Chrome 152
(THESEUS_CHROME_VERSION, added here for exactly this test) loads the page.
The real fix is a current Electron; this commit removes the other tells.
Three tab-strip changes from use:
- A tab opened from a link (target=_blank, middle-click, the context menu,
Duplicate) now goes right after the tab it came from — and after any
siblings that tab already opened — instead of at the end of the strip.
The + button, session restore and add-on requests still append.
- The selected tab gets an accent stripe and outline on top of its brighter
fill; with a dozen same-size tabs the fill alone was easy to lose. A
grouped tab keeps its group colour on the stripe.
- On Windows the tab row is the title bar: the native frame is hidden, the
minimise/maximise/close buttons are drawn as an overlay over the chrome
(colours follow the theme), the row is a drag region with every control
in it opted out, and 140px (or the overlay's real width when the API is
exposed) is kept clear on the right. The page gains the old title bar's
height. Other platforms keep the native frame.
Two reasons the Plug-ins card looked dead ("only a Refresh button"):
1. The state check ran Get-ScheduledTask, whose module import took 8–10 s
cold, and only then fetched the release manifest. Every button is hidden
during "checking…", so for 10–15 s the card showed nothing but Refresh.
Task state now comes from the Task Scheduler COM object (numeric, locale-
independent — schtasks.exe prints localized words on non-English
Windows) and the manifest fetch runs in parallel: ~2 s.
2. The Inno installer's AppId is written as {{…}}, which Inno registers as
{…}}_is1 (doubled closing brace). Theseus looked for the single-brace
key, never found it, and so never knew the installed version — no Update
button, no Uninstall button. The entry is now found by DisplayName.
Owners can now publish a signed _records.json (A/AAAA/MX/TXT/CNAME/NS)
beside their Sia content; the gateway verifies it against the current NFT
holder and serves it as GET /api/dns/<name>. Every BCDN resolution now
starts a background fetch of that answer (3 s cap, 30 s cache, seq rollback
guard) and attaches it to the entry as entry.dns. Navigation never waits
for it — on-chain h/s3/ip/p/u stay authoritative — except when a name has
no content record at all and a signed A is the only way to reach it. Only
registered names are looked up, so ICANN hosts never reach the gateway.
Exposed as window.bcnr.dnsRecords(name) for add-ons (TXT verification, MX
for mail bridges), on resolveName() as .dns, and as a "Signed DNS" row in
the site-info popover.