Compare commits

...

10 commits

Author SHA1 Message Date
Silent Mode
e8317fef16 Theseus: translator defaults — libre.silentmode.st and libre.x
translate.silentmode.st had "translate" in the subdomain and in the
LibreTranslate path, which read awkwardly on both the chip tooltip and
the Settings list. The shipped defaults rename the primary peers to
libre.silentmode.st and libre.x (plus lingua.x registered server-side
as an alias — same ip record, so it's a URL users can also remember
without being another independent peer in the client's fallback list).
2026-10-03 15:30:54 +02:00
Silent Mode
0b36532922 Theseus: translator peers list — fall back when a mirror is down
The chip ran against a single endpoint, which is the fastest way to go
dark: libretranslate.com's public tier moved behind an API key in late
2026, and most of the historical public mirrors (libretranslate.de,
argosopentech, lt.vern.cc, translate.terraprint.co) either 502 at any
given time, serve a parked page, or started requiring a key of their
own. One URL in settings meant one of those going down meant the chip
stopped working.

Settings.translateEndpoints is now an ordered list. The translator tries
each peer in order and returns the first non-error answer; a dead peer
is logged and skipped. Order is preserved — the first entry is the
primary. Shipped defaults put Silent Mode's own instances
(translate.silentmode.st, the BNS name translate.x) at the top and keep
libretranslate.com as the last-resort entry; neither Silent Mode URL
answers today, but a user's chip starts working as soon as either goes
live without a browser release.

Settings › General › Translate pages grew a list editor (same shape as
the quick-links one): PRIMARY tag on row 0, add a peer, remove any row;
bns:// URLs are accepted so a BNS translator doesn't have to be fronted
by an https host. The single-URL `translateEndpoint` setting carried
over from the earlier draft is migrated on load — a custom URL goes to
the front of the list, the historical default is dropped.
2026-10-03 15:19:09 +02:00
Silent Mode
f3c8998ecc Theseus: in-page translator (LibreTranslate client)
The Website-language setting only tells servers what the user prefers via
Accept-Language — many static sites (including names on BCDN) serve one
language and ignore it, so e.g. hello.bch loads in English for every user,
in every language. This adds a translator that converts the page's visible
text in place, so a Lithuanian user reads hello.bch in Lithuanian without
asking the server for anything.

The URL-bar grows a translate chip next to the website-language globe. The
chip lights up when the page's declared `<html lang>` differs from the
user's preferred language. Click it once to translate in place; click again
to revert — originals are kept in a renderer-local state slot and swapped
back without a reload. Right-click opens the chip menu (change target /
translator settings).

The engine lives behind a swappable adapter in main — this ships with the
LibreTranslate backend (POST /translate with {q, source, target, format}).
The endpoint defaults to the LibreTranslate public tier but is settable in
Settings › General › Translate pages, so a user with a self-hosted
LibreTranslate (or Silent Mode's own translate.silentmode.st once it is
up) swaps it there without a code change. On-device Bergamot (the WASM
engine Firefox Translations uses) will plug into the same adapter in a
later release — same contract (array of texts in, array of translations
out), the chip and revert path are already engine-agnostic.

The fetch goes through session.defaultSession.fetch so Tor and add-on
proxy rules apply uniformly, chunks the batch at ~3.8 KB per POST so a
large page spreads across several requests, times each one out at 45 s,
and reports a failure to the chip's tooltip so a dead endpoint reads as
such and not as a silent no-op. The injected walker skips SCRIPT / STYLE
/ CODE / PRE / NOSCRIPT / TEXTAREA and contentEditable subtrees, keeps a
reference to each text node and the original text, and reverts by
restoring from that pair.
2026-10-03 15:01:40 +02:00
Local Dev
8b0cc00290 BNS indexer design: Theseus's own index is a standby behind Ariadne's
Theseus keeps serving bns:// itself but does not run its own indexer
process while Ariadne's is healthy (pipe answers, passes the server check,
heartbeats arrive, snapshot fresh). On failure it takes over warm from
Ariadne's last snapshot, and hands back once Ariadne has been healthy for a
while. Until the Ariadne pipe exists, Theseus's indexer stays always on.
2026-10-03 14:40:30 +02:00
Local Dev
adcea19f8a Theseus: background tabs stop unless kept running; popups close on click-away
A tab you switch away from is frozen (page lifecycle "frozen": no JS,
timers, network callbacks or media) one second later and thawed the moment
it is shown again. Right-click a tab → "Keep running in background" exempts
it (music, calls, dashboards); the strip marks it ▶. Dormant restored tabs
and tabs waiting on a page dialog are never frozen. Settings › Performance ›
"Stop tabs in the background" (on by default) turns it off. Freezing uses
the per-tab debugger applyFingerprint already keeps attached; Chromium only
freezes hidden pages.

The downloads, site-info and engine-picker popups close when focus moves
elsewhere in the window or to another app; the toolbar click that caused
that does not reopen them. Focus only moves into a popup while the window
is active — focusing it from the background blurs the window and closed the
popup it had just opened.

Also fixes a bug in the lazy overlays: isLoading() is still true while
did-finish-load is delivered, so a first show waited out the 4 s timeout
before appearing. Finished loads are now recorded explicitly.
2026-10-03 14:14:47 +02:00
Local Dev
b531c89f82 Theseus boot tracer: measure a working launch, and say when it did not
With a script as the Electron entry, the app path is the script's folder;
main.js loads chrome.html, home.html and every overlay by relative name, so
all of them failed with ERR_FILE_NOT_FOUND and every traced run measured a
broken launch (it also left a window showing the error page). The tracer now
sets app.setAppPath to TheseusNavigator, records failed main-frame loads, and
run.mjs marks such a run INVALID.

Re-measured (warm runs, fresh profile): the per-overlay lazy loading in
e524c12 saves 5 processes and ~100 MB private memory at 15 s — not ~30 MB
with an unchanged process count as its message says; that figure came from
the broken runs. The BNS indexer utilityProcess costs ~60-75 MB.
2026-10-03 14:14:46 +02:00
Local Dev
a57d20751b Theseus: boot tracer under scripts/boot-trace
Measures dev-tree launches against a throwaway profile, without changing
main.js: time to app ready, toolbar and first page; main-thread blocks; add-on
activation; overlay pages loaded; main-process fetches; process count and
private memory at 15 s. One row per run, so a startup change can be compared
with the numbers before it.

  node scripts/boot-trace/run.mjs [--runs 3] [--seconds 25] [--fresh] [--profile <dir>]
2026-10-03 13:26:44 +02:00
Local Dev
c5b2383df8 Theseus: load each overlay page on first use, prewarm only the common ones
All nine overlay pages (site info, engine picker, downloads, address
suggestions, password fill, link pill, approvals, page dialogs) loaded 250 ms
after the toolbar, whether or not the session would ever open them. Each now
loads on its first use; the three used in nearly every session (address
suggestions, link pill, site info) are prewarmed one at a time after the
first page. Measured: 8 -> 3 overlay pages loaded at startup, ~30 MB less
private memory at 15 s (they share one renderer, so the process count is
unchanged); launch timing unchanged within noise.

The show functions send their data right after showing, which a page still
loading drops, so a first show waits for its page and then runs; a hide in
the meantime cancels it.

Also: the indexer's restart notice is logged when the restart happens,
not when the child exits — on app.exit() the timer never fires, so a
forced exit no longer prints a restart that does not happen.
2026-10-03 13:26:43 +02:00
Local Dev
1f1bde6e60 Theseus: BNS index in its own process; a name never waits for the download
The snapshot parse, index builds, electrum sync and snapshot refresh ran on
the browser's main thread at launch. They now run in bns-indexer.js, a
utilityProcess, in two phases: the local snapshot first (no network), then
— once the first page has loaded — the published snapshot (one download)
and the electrum poll. Main keeps a mirror of the name map for its
synchronous lookups; tabs no longer wait for the index to restore.

With no local index yet, a name under a known BCNR TLD gets one lookup of
just that name on the gateway and opens; the full snapshot and the electrum
check follow in the background, and every quick answer is compared with the
verified index when it lands (a mismatch reloads the affected tabs). Plain
web hosts are never sent to the gateway, and the extension-publisher check
only accepts verified data.

DESIGN-bns-indexer-service.md: Ariadne's Thread as the owner of the one
shared indexer (scope x mode, launch at start, power, and a resolver that
never depends on the indexer).
2026-10-03 13:08:28 +02:00
Silent Mode
c2ec0f0d02 Theseus 0.3.70: fully-lazy session restore, quick-links strip reordered
Session restore no longer loads any page on launch. In 0.3.63 the strip was
built from saved titles + favicons and only the previously-active tab's URL
was navigated at startup, so a 20-tab session cost one renderer load instead
of twenty — but that one load is still a real page, often the heaviest one
in the whole session, and it fought every other startup task for the main
thread while the window sat not-responding. Now every restored tab, the
previously-active one included, comes up dormant: zero page renderers at
launch, no page starts loading until the user asks for a specific tab
(clicks the chip, hits reload, types in the URL bar). The previously-active
tab stays highlighted in the strip so one click brings it back; the content
area sits with the tab's own background colour until that click. RAM-at-
launch is now just the chrome, the overlays and the strip — a 500 MB saved
page never materialises as a renderer the user did not even ask to see.

A pending tab that is navigated explicitly (URL bar, link, search) drops
its saved URL at the top of navigateTab, so a later reload or chip click
can't snap it back.

Quick-links strip default set is now Telegram, WhatsApp, X, YouTube — in
that order. Messenger and Spotify are out of the default; users who want
them can still add them via Settings › General › Quick links. Existing
installs whose list still matches the previous untouched default (same six
ids in the same order) migrate on next launch; any customisation (reorder,
add, remove) is left alone.
2026-10-03 11:03:53 +02:00
9 changed files with 1709 additions and 382 deletions

View file

@ -0,0 +1,369 @@
# BNS indexing: Ariadne's Thread owns it, everyone reads it
Status: design, revised 2026-10-03.
Already implemented:
- the public raw snapshot, with two copies (see the server tier);
- Theseus's indexer process `bns-indexer.js`, which becomes the last-resort
fallback here;
- Theseus's quick single-name lookup while no index exists.
## The model
```
server this machine readers
────── ────────────────────────────────────────── ───────
gateway index ─┬─► public Ariadne's Thread (scope: All users | Just me)
(electrum) │ snapshot ──► indexer ──writes──► local snapshot copies Theseus
│ ×2 copies (sync, may stop, (read-only for readers) ──┐
│ read-only pause, crash) │ │ same
│ │ pipe (live, optional) │ │ source
└─► public BNS ▼ ▼ │ chain
indexer resolver (All browsers mode only) ◄──────────────┘
/api/name/<n> DNS :53 + NRPT + gateway (All users)
(single names) PAC proxy + gateway (Just me) other browsers
```
Ariadne's Thread is the parent of everything BNS on a machine. It has two
processes with different jobs:
- **The indexer** builds and maintains the index from the raw beacon
evidence and writes the local snapshot copies. It is heavy-ish (electrum
sync), and it is allowed to stop: Economy, "Launch at start" off, a crash,
an update.
- **The resolver** answers names: for other browsers in All browsers mode,
through system DNS or the per-user proxy. It is small and does no syncing
of its own beyond its fallbacks.
**Resolution never depends on the indexer.** The resolver and Theseus read
names through the same fallback chain (below), in which the live indexer is
only the first and freshest source. If it is gone, the answers come from the
local snapshot copies, the public snapshots or the public BNS indexer. The
only thing lost is freshness, and only until a source with newer data
answers.
## Tiers
### Server (done)
- **Live index:** the gateway keeps it over electrum, and serves single names
at `/api/name/<n>`. This is the **public BNS indexer**. Its answers carry
records and owner, interpreted by the operator; clients treat them as
provisional (see below).
- **Raw snapshot:** `cdn-name-snapshot` (VPS, every 10 min) publishes it as
**two public, read-only copies on two storage backends**:
- `https://dl.silentmode.st/bns-name-snapshot.json` (the dl vhost's disk)
- `https://navigate.st/bns/silentmode.bch/bns-name-snapshot.json` (the Sia
bucket, served by the gateway)
Both are static, with CORS `*` and no credentials. Each contains the
history plus the verbose transactions, so clients rebuild the index from
the evidence themselves. A failed upload of the second copy is retried on
the next run. Clients try them in that order (`nameListUrl`,
`nameListMirrorUrl`).
- **Android** keeps using the interpreted `bns-name-index.json`.
### The source chain (the resolver and Theseus alike)
Fastest first. A lookup is answered by the first source that has the name;
the slower sources refresh in the background.
1. **In memory:** whatever the reader has already loaded (normally all of
it).
2. **The live indexer over the pipe:** `snapshot` on connect, then
`subscribe` for changes. This is the freshest source when it is running.
3. **The local snapshot copies:** a disk read of milliseconds. There are two,
so a deleted, moved or half-written file still leaves one:
- Ariadne's: `C:\ProgramData\Ariadne\` (All users) or
`%LOCALAPPDATA%\Ariadne\` (Just me), written only by the indexer.
- The reader's own last-known-good copy. For Theseus that is its profile;
for the resolver it is beside Ariadne's file.
4. **The public snapshots,** either copy: one download of about 450 KB,
rebuilt locally from the transactions. It always runs in the background;
nothing waits for it.
5. **The public BNS indexer** (`/api/name/<n>`): a single-name lookup of
about 0.2 s. Used when sources 1–3 have no data yet, or the name is
missing and the data is stale. The answer is **provisional**:
- It is served at once.
- It is compared with the verified index when that arrives, and on a
mismatch the tabs or cached answers are replaced.
- It is never used for the extension-publisher check, which waits for
verified data.
6. **Theseus only, last resort:** its own electrum indexer process
(`bns-indexer.js`), for a portable Theseus with no Ariadne and no CDN.
**Privacy:** sources 4–5 only ever see BNS names.
- The resolver only receives queries for BNS TLDs (NRPT and the PAC file
route only those).
- Theseus only asks for names under known BCNR TLDs.
- An ordinary web host is never sent to the public indexer.
### Ariadne's Thread: the indexer
- **One implementation** with Theseus's indexer logic (`bns-indexer.js`):
- warm start from the local snapshot;
- catch-up from the public snapshots;
- the electrum delta poll every 30 s;
- the index rebuilt from the beacon transactions, with `owner`;
- the TLD set from `tlds.bch`.
This replaces the daemon's current warm start from the pre-resolved list,
which has no owners and an operator-interpreted index.
- **Local snapshot:**
- It uses the raw format, written atomically after every change, and the
previous file is kept as the second copy.
- In All users, **users may read it; only SYSTEM and Administrators may
write it.** The transactions are not checked against block headers, so
a user-writable copy would let any local process insert names or owners.
- `policy.json` stays user-writable, as today; it holds settings, not
data.
- **The pipe:** `\\.\pipe\ariadne-bns`, or `…-<user SID>` for Just me.
- **Requests:** `hello` returns `{version, network, gen, builtAt,
snapshotPath}`; `snapshot` and `get <name>` return index data; `poll`
runs one delta poll on demand.
- **Push:** `subscribe` sends `{gen, changed}` on every change.
- **Server check:** a client checks that the server process is Ariadne's
indexer before trusting it (`GetNamedPipeServerProcessId`, then the
process image path under Program Files and, for All users, its owner
SYSTEM). Otherwise a process could take the pipe name while the indexer
is down and feed false owners to the extension-publisher check. A failed
check simply skips to source 3.
- **Not TCP:** a 127.0.0.1 port has no way to tell who is listening.
### Ariadne's Thread: the resolver
- **Runs only in All browsers mode;** Theseus is its own resolver. It is
small: DNS, proxy and gateway listeners plus the source chain, with no
electrum.
- **On its own:**
- With the indexer down it keeps answering from the local copies.
- It fetches the public snapshot once an hour when the local copies are
older than that, holding the result in memory only, since the indexer
owns the files.
- It uses the public indexer for single misses.
- **If the resolver itself is down,** the task restarts it within a minute.
With NRPT routing the BNS TLDs to 127.0.0.1 there is no system fallback
for those names meanwhile (ICANN names are unaffected). Open question: a
public BNS DNS server as NRPT's second name server, and a public proxy as
the PAC file's second entry.
### Ariadne's Thread: scope × mode
**Scope** decides who it runs for:
- **All users** (the admin path): system processes for every account on the
OS.
- **Just me:** runs under one account, with no admin.
**Mode** decides who it resolves for:
- **Theseus only.**
- **All browsers:** every browser on the machine, and for All users every app
as well.
| | **Theseus only** | **All browsers** (default) |
|---|---|---|
| **All users** (admin, SYSTEM at boot) | indexer only | indexer + resolver: system DNS on :53 with NRPT rules, the local gateway on :80/:443, a machine-wide CA. Every app on the PC resolves BNS names |
| **Just me** (no admin, at logon) | indexer only | indexer + resolver: a PAC file set in the account's own Windows proxy settings (HKCU, no admin) points at a local proxy on a high port, which routes BNS names to the gateway and passes everything else through. The CA goes into the user's certificate store, which Windows confirms once |
Notes on **Just me + All browsers**:
- Chrome, Edge and Brave follow the Windows proxy settings. Firefox follows
them when set to "use system proxy", its default, but needs the CA in its
own store, which Ariadne already handles for the machine CA.
- Programs that ignore the system proxy (some command-line tools, apps with
their own resolver) do not see BNS names. Covering them takes system DNS,
which needs admin.
- A PAC file is not DNS, so it also works when a browser uses its own
DNS-over-HTTPS, which bypasses the NRPT route on networks that tamper with
DNS.
**Where the mode lives:**
- All users: `policy.json` in ProgramData. Just me: `policy.json` in the
user's profile. The field is `"mode": "theseus" | "all-browsers"`.
- Switching starts or stops the resolver and adds or removes the NRPT rules,
or the per-user proxy setting.
- **It never touches the indexer.**
### Ariadne's Thread: lifetime and start-up
Both processes are background processes with no window, independent of
Theseus.
| | **All users** | **Just me** |
|---|---|---|
| Runs as | SYSTEM, scheduled tasks | the user, scheduled tasks (hidden) |
| Starts | at OS start, before anyone logs on | at that user's logon, the earliest without admin |
| Keeps running | until shutdown | until that user logs off |
To start before logon, a user switches the scope to All users (one UAC
prompt).
**Task settings, both tasks and both scopes:**
- Restart on failure (5 times, 1 min apart).
- No execution time limit.
- **`-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries`.** Today's task
uses Windows' defaults, which skip the start on battery and stop the task
when a laptop unplugs. Power is a setting of Ariadne's (below), not
something Windows decides by killing the process.
- Below-normal process and I/O priority for the indexer.
**"Launch at start" (on by default):** a switch in Ariadne's Thread settings,
mirrored in Theseus Settings › Plug-ins › Ariadne's Thread.
- **Scope of the switch:** it applies to the **indexer**. The resolver
follows the mode: in All browsers it always starts, because other browsers
depend on it and it is small.
- **Where it lives:** `policy.json`, `"startAtBoot": true | false`. The
running process enables or disables the trigger on its own task, so no
UAC prompt is needed in either scope.
- **On:** the indexer is up from OS start (All users) or logon (Just me),
whether or not Theseus is ever opened.
- **Off:** the indexer starts on demand when Theseus launches, by running the
task. For All users the task's security descriptor lets local users run it,
but not change it. Until then, names resolve from the source chain as
usual, in Theseus and in other browsers; only the freshness depends on the
indexer.
- **Turning it back on** starts the indexer at once as well as at the next
boot.
**Power: "Performance" or "Economy"**, in Ariadne's Thread settings,
mirrored in Theseus. It lives in `policy.json`: `"power": "performance" |
"economy"`, plus `"economyWhen": "on-battery" | "low-battery"`.
- **Performance (default):** the indexer syncs normally on battery.
- **Economy:** while the condition holds, the indexer **pauses its network
work**: the electrum poll, the published-snapshot refresh and server
discovery.
- Resolution is unaffected. Readers keep answering from memory and the
local copies, and the public indexer still covers single misses.
- When the condition clears, the indexer resumes with one immediate
catch-up poll.
- **The condition**, `economyWhen`:
- `on-battery`: running on battery, the old behaviour of Windows' default
task settings.
- `low-battery`: Windows' Battery Saver is on, which Windows turns on at
20% by default.
- Pausing rather than stopping keeps the warm process and a cheap resume.
Stopping would be just as safe for resolution, since the readers do not
depend on the indexer.
- **Detection:**
- The indexer reads the power state every 60 s and on resume-from-sleep: AC
or battery and the Battery Saver flag, from `GetSystemPowerStatus` via a
small helper, or one long-lived PowerShell process, never one process per
check.
- Theseus's fallback indexer uses Electron's `powerMonitor`.
- **Desktops** (no battery) never enter Economy, and the switch is hidden.
### Theseus
Theseus has its own complete BNS resolver: the **index** (`bns-indexer.js`,
a utilityProcess) and **serving** (the `bns://` protocol handler: on-chain
`h`, Sia via the gateway, `ip` with the TLS pin, `p`, signed DNS, all under
Tor or the session proxy). Serving stays in Theseus always; it costs nothing
when not in use and is what makes `bns://` work with Tor. **The index is a
standby:**
- **While Ariadne's indexer is healthy,** Theseus does not start its own at
all. That saves about 60–75 MB and its own electrum traffic.
- **Unhealthy means any of:**
- the pipe does not answer at launch;
- the pipe fails the server-process check;
- no update has arrived within the heartbeat window (a few minutes);
- Ariadne's snapshot is older than a set limit while Ariadne reports itself
running.
- **Taking over:** Theseus starts its own indexer, warm-started from
Ariadne's last snapshot file, so there is no download and no cold sync.
Lookups meanwhile keep using the source chain; nothing waits on the switch.
- **Handing back:** once Ariadne has been healthy again for a minute or two,
Theseus stops its own indexer. The delay keeps a flapping service from
starting and stopping Theseus's indexer over and over.
- **Until the Ariadne pipe exists** (migration step 2), Theseus's indexer is
its only working source and runs always, as in 0.3.70.
- **Launch:** the source chain above. In practice: Ariadne's local snapshot
file (milliseconds, parsed in Theseus's indexer process, never on the main
thread), then `subscribe` to the pipe for changes.
- **Its own copy:** Theseus keeps its own last-known-good copy in its
profile, so a missing Ariadne file still leaves one.
- **No index yet:** the quick single-name lookup (source 5) while the public
snapshot downloads in the background.
- **Never writes Ariadne's files,** and never starts, stops or reconfigures
the indexer. The one exception is running its task on demand when "Launch
at start" is off. Its Settings page shows Ariadne's status and edits its
`policy.json`.
## Installer
- **Ariadne's Thread is always installed** with Theseus. There is no "install
Ariadne?" question.
- **Setup asks two things on one page:** the scope and the mode.
### Install scope
| | **All users** (admin, default) | **Just me** (no admin) |
|---|---|---|
| Runs as | SYSTEM scheduled tasks, at boot | per-user scheduled tasks, at logon |
| Indexer + local snapshot | one per machine, `C:\ProgramData\Ariadne\` | one per account, `%LOCALAPPDATA%\Ariadne\` |
| Who may write the snapshot | SYSTEM and Administrators | the account (the same trust level as the Theseus profile) |
| Pipe | `\\.\pipe\ariadne-bns` | `\\.\pipe\ariadne-bns-<user SID>` |
| All browsers via | system DNS + NRPT + gateway | per-user PAC proxy + gateway |
Inno Setup provides the scope choice natively (`PrivilegesRequired=lowest`
with `PrivilegesRequiredOverridesAllowed=dialog`).
**Pre-selection (decided 2026-10-03):** All users when the installing
account is an administrator; **Just me when it is not**, so a standard user
is never sent to a UAC prompt they cannot approve. Both can still pick the
other.
### Mode
- **All browsers (recommended, default):** every browser on this PC resolves
BNS names. With All users, so does every other app.
- **Theseus only:** BNS names resolve in Theseus; nothing else on the system
changes.
### Changing either later
From Theseus Settings › Plug-ins › Ariadne's Thread, or Ariadne's own
settings:
- **Mode, Launch at start and Power:** no prompt, in either scope
(`policy.json`).
- **Scope,** in either direction: one UAC prompt, since it installs or
removes the SYSTEM tasks. The local snapshot moves to the new location.
- **Both present:** if a machine install and a per-user one exist at the same
time, readers use the machine one, and the per-user one stops itself.
### Other installer behaviour
- **Updates:** Ariadne keeps its own update lifecycle, chained by Theseus's
updater the way it chains the add-on updates. An indexer update never
interrupts resolution.
- **Uninstalling Theseus** asks whether to keep Ariadne's Thread. Keeping it
is the default in All-browsers mode.
## Migration from today
1. **Lift `bns-indexer.js` into a shared module,** and the source chain into
a shared reader module. Ariadne's two processes and Theseus use the same
code and the same tests.
2. **Split Ariadne's daemon** (today one process, `bnsd.js`) into:
- the indexer: raw snapshot, `owner`, `tlds.bch`, the electrum delta, the
two local copies with their ACL, the pipe, Power;
- the resolver: today's DNS, gateway and CA code on the shared reader,
plus the per-user PAC proxy for Just me.
Its read-only `/api/*` HTTP endpoints move to the resolver, for the
gateway and the Theseus status panel.
3. **Theseus:**
- the source chain;
- its own snapshot copy;
- the pipe client with the server-process check;
- its electrum indexer demoted to the last resort.
4. **Installer:** Ariadne bundled again, with the scope and mode questions.
5. **Pages:** the tools page and theseus.x describe Ariadne's Thread as part
of Theseus, with All browsers as the default mode.
## Open questions
- **System-level fallback while the resolver restarts:**
- a public BNS DNS server as NRPT's second name server (All users);
- a public proxy as the PAC file's second entry (Just me).
- **Portable Theseus:** it never installs Ariadne, so it lives on its own
copy, the public sources and its own indexer. Should it offer to install
Ariadne?
- **macOS/Linux:** launchd or systemd units for the same two processes, once
Theseus ships there.

331
bns-indexer.js Normal file
View file

@ -0,0 +1,331 @@
// BNS name indexer — runs in its own process (Electron utilityProcess),
// started by main.js, so the snapshot parsing, index builds and electrum
// traffic never compete with the browser's main thread.
//
// Boot is two-phase:
// 1. fast lane: load the local snapshot (user cache > bundled), build the
// index from it and publish it. No network. This is what the first tab
// needs, and it is all that runs while the browser is still starting.
// 2. full work, once main says "go" (its first page has loaded): electrum
// server discovery, the Sia snapshot refresh, and the 30 s delta poll.
// A lookup that misses the snapshot before then still gets a live
// delta poll on demand — only that, nothing else.
//
// Protocol (process.parentPort messages):
// main -> indexer { type: "init", resolverPath, userData, bundledSnapshot, torPort }
// { type: "go" } start phase 2
// { type: "tor", port } electrum via Tor (null = direct)
// { type: "stop-polling" }
// { id, type: "poll" | "rebuild" | "ready" } replies { type: "reply", id, ok }
// indexer -> main { type: "index", builtAt, names: [[key, entry]...], network }
// { type: "fresh", builtAt } poll succeeded, nothing changed
// { type: "reply", id, ok, error? }
//
// The index entries are the resolver's own (records, owner, txid, height, …)
// built from the raw beacon transactions — the trust model is unchanged.
"use strict";
const fs = require("fs");
const path = require("path");
const { pathToFileURL } = require("url");
const WebSocket = require("ws");
const port = process.parentPort;
const log = (...a) => console.log("[bns]", ...a);
// Same guard as main.js: ws turns "closed while still connecting" into an
// uncaught error event. Electrum servers that reset mid-handshake are routine.
{
const close = WebSocket.prototype.close;
WebSocket.prototype.close = function (...args) {
if (this.readyState === WebSocket.CONNECTING && this.listenerCount("error") === 0) this.once("error", () => {});
return close.apply(this, args);
};
}
// A flaky network must not take the indexer down; main restarts it if it
// exits, but the index would go cold for a moment.
process.on("uncaughtException", (err) => { try { console.error("[bns] uncaught:", (err && err.stack) || err); } catch {} });
process.on("unhandledRejection", (err) => { try { console.error("[bns] unhandled rejection:", (err && err.message) || err); } catch {} });
let cfg = null;
let R = null;
async function getResolver() {
if (!R) R = await import(pathToFileURL(cfg.resolverPath).href);
return R;
}
// ---- Tor ------------------------------------------------------------------
let torAgent = null;
async function setTor(torPort) {
if (!torPort) { torAgent = null; return; }
const { SocksProxyAgent } = await import("socks-proxy-agent");
torAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${torPort}`);
}
class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torAgent, ...opts }); } }
const currentWS = () => (torAgent ? TorWebSocket : WebSocket);
// ---- 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;
// Per-network cache files. Chipnet keeps the historical names so existing
// profiles are not invalidated; any other network gets its own files.
let networkId = "chipnet";
const netSuffix = () => (networkId === "chipnet" ? "" : `.${networkId}`);
const electrumFile = () => path.join(cfg.userData, `electrum-servers${netSuffix()}.json`);
const snapshotUserPath = () => path.join(cfg.userData, `bns-name-snapshot${netSuffix()}.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 r = await getResolver();
const seed = r.ELECTRUM || r.CHIPNET_ELECTRUM;
let saved = [];
try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {}
electrumPool = mergeServers(saved, seed); // 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 (!started) return; // phase 2 only
if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return;
lastElectrumRefresh = Date.now();
refreshElectrumPool();
}
// ---- index state + publishing ---------------------------------------------
let index = null; // Map name -> entry
let currentSnapshotState = null; // raw { beacon, history, txs, … } behind `index`
let lastSig = "";
// Cheap fingerprint of the snapshot: an index rebuilt from the same history
// at the same heights is the same index, so main isn't sent a copy.
const sigOf = (snap) => `${snap.history.length}:${snap.history.reduce((a, h) => a + (Number(h.height) || 0), 0)}`;
function publish(idx, snap, reason) {
index = idx;
const sig = snap ? sigOf(snap) : `full:${Date.now()}`;
const builtAt = reason === "snapshot" ? 0 : Date.now(); // a snapshot is a floor, not a ceiling
if (sig === lastSig) { port.postMessage({ type: "fresh", builtAt }); return; }
lastSig = sig;
port.postMessage({ type: "index", builtAt, names: [...idx], network: networkId, reason });
}
function readSnapshotFrom(p) {
try {
if (!fs.existsSync(p)) return null;
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
if (!parsed || !Array.isArray(parsed.history)) return null;
return parsed;
} catch { return null; }
}
// Phase 1: the local snapshot, nothing else.
async function warmFromSnapshot() {
const r = await getResolver();
networkId = r.NETWORK?.id || "chipnet";
if (!r.buildIndexFromSnapshot) return false; // an older resolver-web.js
const snap = readSnapshotFrom(snapshotUserPath()) || readSnapshotFrom(cfg.bundledSnapshot);
if (!snap) return false;
try {
const t0 = Date.now();
const idx = r.buildIndexFromSnapshot({ snapshot: snap });
currentSnapshotState = snap;
publish(idx, snap, "snapshot");
log(`warm-started from snapshot: ${idx.size} names in ${Date.now() - t0} ms @ height=${snap.asOfHeight ?? "?"} root=${snap.root ?? "?"}`);
return true;
} catch (e) {
log("snapshot warm-start failed:", e.message);
return false;
}
}
// ---- continuous background delta refresh ----------------------------------
// One electrum connection per poll: fetch the beacon's history (one call),
// fetch only the tx bodies not already held, rebuild locally, persist.
const POLL_INTERVAL_MS = 30_000;
// Neither the electrum connect nor its WebSocket handshake has a timeout of
// its own; a server that accepts TCP and then stalls would wedge the loop.
const POLL_DEADLINE_MS = 45_000;
let pollInFlight = null, pollTimer = null;
async function pollAndMerge() {
if (pollInFlight) return pollInFlight;
let conn = null, deadline;
const work = (async () => {
const r = await getResolver();
if (!r.connectElectrum || !r.BEACON_SCRIPTHASH || !r.buildIndexFromSnapshot) return rebuild();
if (!electrumPool) await initElectrumPool();
// Base state: memory > user cache > bundled > empty. The "empty" branch
// turns a first start without any snapshot into a full fetch.
const snap = currentSnapshotState
|| readSnapshotFrom(snapshotUserPath())
|| readSnapshotFrom(cfg.bundledSnapshot)
|| { beacon: r.BEACON_SCRIPTHASH, history: [], txs: {} };
const el = conn = 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 });
publish(idx, currentSnapshotState, "poll");
try {
fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true });
fs.writeFileSync(snapshotUserPath(), JSON.stringify(currentSnapshotState));
} catch { /* readonly userdata / disk full — skip */ }
if (added > 0) log(`delta-refresh: +${added} new tx${added === 1 ? "" : "s"} (total ${history.length}, index has ${idx.size} names)`);
return true;
} finally { try { el.close(); } catch {} }
})().catch(() => false);
const timeout = new Promise((resolve) => {
deadline = setTimeout(() => { try { conn?.close(); } catch {} resolve(false); }, POLL_DEADLINE_MS);
});
const p = Promise.race([work, timeout]).finally(() => {
clearTimeout(deadline);
if (pollInFlight === p) pollInFlight = null;
maybeRefreshElectrum();
});
pollInFlight = p;
return p;
}
// Full walk over electrum — only for a resolver without the delta primitives,
// or an explicit rebuild request.
let rebuilding = null;
function rebuild() {
if (rebuilding) return rebuilding;
rebuilding = (async () => {
const r = await getResolver();
if (!electrumPool) await initElectrumPool();
const idx = await r.buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
publish(idx, null, "rebuild");
return true;
})().catch(() => false).finally(() => { rebuilding = null; });
return rebuilding;
}
// The published raw snapshot (public, refreshed every 10 min on the VPS):
// one download that can carry a long-closed browser across days of beacon
// events, so the electrum poll after it only fetches what is newer still.
// Merged in now — not saved for the next start — and only ever adds: the
// index is rebuilt here from the transactions, as with any other source.
async function refreshFromPublishedSnapshot() {
try {
const r = await getResolver();
// Two public copies (dl vhost, Sia via the gateway): first one that answers.
const urls = [r.NETWORK?.nameListUrl, r.NETWORK?.nameListMirrorUrl].filter(Boolean);
let pub = null;
for (const url of urls) {
try {
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(15000) });
if (!res.ok) { log(`published snapshot ${new URL(url).host}: HTTP ${res.status}`); continue; }
pub = await res.json();
break;
} catch (e) { log(`published snapshot ${new URL(url).host}: ${e?.cause?.code || e?.message || e}`); }
}
if (!pub) return;
if (!pub || !Array.isArray(pub.history) || !pub.txs || (r.BEACON_SCRIPTHASH && pub.beacon !== r.BEACON_SCRIPTHASH)) return;
const base = currentSnapshotState || { beacon: r.BEACON_SCRIPTHASH, history: [], txs: {} };
const merged = new Map(base.history.map((h) => [h.tx_hash, h]));
const txs = { ...(base.txs || {}) };
let added = 0;
for (const h of pub.history) {
const mine = merged.get(h.tx_hash);
if (!pub.txs[h.tx_hash] && !txs[h.tx_hash]) continue; // no evidence, skip
if (!txs[h.tx_hash]) { txs[h.tx_hash] = pub.txs[h.tx_hash]; added++; }
// Keep a confirmed height over a mempool (0 / negative) one.
if (!mine || (!(Number(mine.height) > 0) && Number(h.height) > 0)) merged.set(h.tx_hash, { tx_hash: h.tx_hash, height: h.height });
}
const next = { ...base, beacon: base.beacon || pub.beacon, history: [...merged.values()], txs };
if (sigOf(next) === sigOf(base)) return;
currentSnapshotState = next;
publish(r.buildIndexFromSnapshot({ snapshot: next }), next, "published");
try {
fs.mkdirSync(path.dirname(snapshotUserPath()), { recursive: true });
fs.writeFileSync(snapshotUserPath(), JSON.stringify(next));
} catch {}
log(`published snapshot merged: +${added} tx${added === 1 ? "" : "s"} (total ${next.history.length}, ${index.size} names)`);
} catch (e) { log("published snapshot unavailable:", e?.message || e); }
}
// ---- phases -----------------------------------------------------------------
let warmed = null; // promise: phase 1 finished (index may still be null)
let started = false; // phase 2 running
function startFullWork() {
if (started) return;
started = true;
(async () => {
await warmed;
if (!electrumPool) await initElectrumPool();
// Fastest catch-up first: one download of the published snapshot, then
// the electrum delta for whatever is newer than that.
await refreshFromPublishedSnapshot();
lastElectrumRefresh = Date.now();
refreshElectrumPool();
// No snapshot at all (first start of a build without one): the poll's
// "empty" base state does the full fetch.
pollAndMerge();
pollTimer = setInterval(() => pollAndMerge(), POLL_INTERVAL_MS);
})().catch((e) => log("start failed:", e?.message));
}
// "ready": resolves once there is any index — the local snapshot, or (none
// on disk) the published one, one download; the live poll only if that
// failed too, since a first electrum walk fetches every beacon transaction.
let firstFetch = null;
async function whenReady() {
await warmed;
if (index) return true;
firstFetch = firstFetch || refreshFromPublishedSnapshot().finally(() => { firstFetch = null; });
await firstFetch;
if (index) return true;
return pollAndMerge();
}
port.on("message", async (e) => {
const m = e.data || {};
const reply = (ok, error) => { if (m.id != null) port.postMessage({ type: "reply", id: m.id, ok: !!ok, error }); };
try {
switch (m.type) {
case "init":
cfg = m;
await setTor(m.torPort);
warmed = warmFromSnapshot();
break;
case "go": startFullWork(); break;
case "tor": await setTor(m.port); break;
case "stop-polling": if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } break;
case "ready": reply(await whenReady()); break;
// An on-demand poll is allowed before phase 2: it is the "this tab's
// name isn't in the snapshot" case, and costs one electrum round trip.
case "poll": await warmed; reply(await pollAndMerge()); break;
case "rebuild": await warmed; reply(await rebuild()); break;
default: reply(false, "unknown request");
}
} catch (err) { reply(false, err?.message || String(err)); }
});

View file

@ -88,6 +88,7 @@
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; }
.tab .ask { font-size: 10px; margin-right: 2px; }
.tab .bg { font-size: 9px; opacity: .75; margin-right: 2px; }
/* Tab group visual: a colored dot before the title, plus a matching top
accent stripe on the tab itself so a whole group reads as one cluster
even when tabs are next to each other. */
@ -512,6 +513,14 @@
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg>
<span class="lcode" id="langCode">AUTO</span>
</button>
<!-- Translate chip: lights up when the page's declared language
differs from the user's preferred one. Click translates in
place; click again reverts. Right-click opens the chip menu
(change target, translator settings). -->
<button class="langchip" id="trBtn" title="Translate this page" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4 H8 M5 2 V4 M3 4 C3 7 5 10 8 10 M8 10 C5 10 3 11 2 13 M3 10 C6 10 8 7 8 4"/><path d="M8.5 14 L11 7 L13.5 14 M9.3 12 H12.7"/></svg>
<span class="lcode" id="trCode">EN</span>
</button>
<button class="star" id="star" title="Save this page">☆</button>
</div>
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
@ -759,6 +768,58 @@
.then(renderLang).catch(() => {});
T.onSettingsUpdate && T.onSettingsUpdate(renderLang);
// ---- page-translate chip ----
// Shows when settings.translateAutoOffer is on and the loaded page's
// declared language (<html lang>) differs from the user's target. Click
// toggles between translated and original. Right-click (or long-press)
// opens the chip menu.
const trBtn = $("trBtn"), trCode = $("trCode");
let trSettingsCached = null, trStateCached = null;
function trTargetBase() {
const s = trSettingsCached || {};
const tag = s.languageMode === "manual" && s.languageValue ? s.languageValue : systemLocaleCached;
return String(tag || "en").split("-")[0].toLowerCase();
}
function renderTr() {
if (!trBtn) return;
const s = trSettingsCached || {};
const st = trStateCached || {};
const target = trTargetBase();
const page = String(st.pageLang || "").toLowerCase();
const translated = !!st.translated;
// Hide when auto-offer is off AND we aren't already translated, OR when
// the page is already in the user's target language.
const shouldShow = translated || (s.translateAutoOffer !== false && page && page !== target);
trBtn.hidden = !shouldShow;
if (!shouldShow) return;
trCode.textContent = (target || "??").toUpperCase().slice(0, 2);
if (translated) {
trBtn.classList.add("on");
trBtn.title = `Translated to ${langName(target)}. Click to show the original.`;
} else if (st.error) {
trBtn.classList.remove("on");
trBtn.title = `Translation failed: ${st.error}. Right-click for settings.`;
} else {
trBtn.classList.remove("on");
trBtn.title = `Translate this page from ${langName(page) || page} to ${langName(target)}`;
}
}
if (trBtn) {
trBtn.onclick = () => {
if (trStateCached?.translated) T.pageTranslateRevert && T.pageTranslateRevert();
else T.pageTranslate && T.pageTranslate();
};
trBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
const r = trBtn.getBoundingClientRect();
T.pageTranslateMenu && T.pageTranslateMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
});
}
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; renderTr(); });
T.onSettingsUpdate && T.onSettingsUpdate((s) => { trSettingsCached = s; renderTr(); });
T.getSettings().then((s) => { trSettingsCached = s; renderTr(); }).catch(() => {});
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; renderTr(); }).catch(() => {});
// ---- extension dock ----
// One toolbar button per registered addon sidebar-panel, plus a static
// "coming soon" placeholder for Aegis (the built-in BCH wallet, in
@ -1579,7 +1640,7 @@
function makeTab(id) {
const el = document.createElement("div");
el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="ask" title="This page is waiting for you" hidden>💬</span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="ask" title="This page is waiting for you" hidden>💬</span><span class="bg" title="Keeps running in the background" hidden>▶</span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); };
// Right-click → native OS menu popped from main.js (a DOM menu forced the
// chrome view taller and opened a gap under the toolbar).
@ -1613,6 +1674,7 @@
if (label.textContent !== txt) label.textContent = txt;
el.querySelector(".mute").hidden = !t.muted;
el.querySelector(".ask").hidden = !t.asking;
el.querySelector(".bg").hidden = !t.keepRunning;
// Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");
@ -1815,6 +1877,8 @@
const bmDisabled = !t.url;
m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id)));
m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id)));
// Background tabs stop (freeze) unless marked — for music, calls, dashboards.
m.appendChild(item(t.keepRunning ? "Stop when in background" : "Keep running in background", "", () => T.tabKeepRunning(t.id)));
const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep);
m.appendChild(item("Close", "danger", () => T.closeTab(t.id)));
document.body.appendChild(m);

1063
main.js

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.69",
"version": "0.3.70",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
@ -54,6 +54,7 @@
"THIRD-PARTY-NOTICES.md",
"LICENSE",
"main.js",
"bns-indexer.js",
"preload.js",
"chrome.html",
"home.html",

View file

@ -40,6 +40,14 @@ contextBridge.exposeInMainWorld("theseus", {
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
systemLocale: () => ipcRenderer.invoke("system-locale"),
websiteLanguageMenu: (rect) => ipcRenderer.invoke("website-language-menu-popup", rect),
// Page translator chip: click translates to the user's language, click
// again reverts. The chip shows the target code; its "on" state is driven
// by the translated flag arriving via onPageTranslateState.
pageTranslate: (target) => ipcRenderer.invoke("page-translate", target),
pageTranslateRevert: () => ipcRenderer.invoke("page-translate-revert"),
pageTranslateState: () => ipcRenderer.invoke("page-translate-state"),
pageTranslateMenu: (rect) => ipcRenderer.invoke("page-translate-menu-popup", rect),
onPageTranslateState: (cb) => ipcRenderer.on("page-translate-state", (_e, d) => cb(d)),
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
// owns the bar UI and drives findInPage / stopFindInPage via these
// wrappers. Match count / active ordinal comes back through onFindResult.
@ -68,6 +76,7 @@ contextBridge.exposeInMainWorld("theseus", {
tabReload: (id) => ipcRenderer.invoke("tab-reload", id),
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabKeepRunning: (id, on) => ipcRenderer.invoke("tab-keep-running", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),

View file

@ -0,0 +1,78 @@
// Measure Theseus launches. Runs N traced starts of the dev tree against a
// throwaway profile and prints one row per run, so a change can be checked
// against the previous numbers.
//
// node scripts/boot-trace/run.mjs [--runs 3] [--seconds 25] [--fresh] [--profile <dir>]
//
// --fresh start from an empty profile (the first run then also measures
// first-launch work: seeding the bundled add-ons, filter builds)
// --profile profile dir to use (default: <tmp>/theseus-boot-trace)
//
// The profile never touches the real one (THESEUS_USER_DATA), and update
// checks are off (THESEUS_NO_UPDATE_CHECK) so a dev tree that is older than
// the live release can't offer — let alone install — an update.
import { spawn } from "node:child_process";
import { mkdirSync, rmSync, readFileSync, openSync, closeSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const APP = path.resolve(here, "..", "..");
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
const RUNS = Number(opt("--runs", 3));
const SECONDS = Number(opt("--seconds", 25));
const PROFILE = path.resolve(opt("--profile", path.join(tmpdir(), "theseus-boot-trace")));
const ELECTRON = path.join(APP, "node_modules", "electron", "dist", process.platform === "win32" ? "electron.exe" : "electron");
if (!existsSync(ELECTRON)) { console.error("electron binary not found:", ELECTRON); process.exit(1); }
if (args.includes("--fresh")) rmSync(PROFILE, { recursive: true, force: true });
mkdirSync(PROFILE, { recursive: true });
const traceDir = path.join(PROFILE, "..", path.basename(PROFILE) + "-traces");
mkdirSync(traceDir, { recursive: true });
function runOnce(i) {
const out = path.join(traceDir, `run${i}.json`);
const log = openSync(path.join(traceDir, `run${i}.log`), "w");
return new Promise((resolve) => {
// Output to a file, not inherited: an Electron child holding the parent's
// pipes keeps a shell pipeline open after it exits.
const p = spawn(ELECTRON, [path.join(here, "trace-main.js")], {
cwd: APP, stdio: ["ignore", log, log], windowsHide: false,
env: { ...process.env, THESEUS_USER_DATA: PROFILE, THESEUS_NO_UPDATE_CHECK: "1", BOOT_TRACE_OUT: out, BOOT_TRACE_MS: String(SECONDS * 1000) },
});
const kill = setTimeout(() => { try { p.kill(); } catch {} }, SECONDS * 1000 + 30000);
p.on("exit", () => { clearTimeout(kill); closeSync(log); resolve(out); });
});
}
function summarise(file) {
const ev = JSON.parse(readFileSync(file, "utf8"));
const at = (re) => (ev.find((e) => re.test(e.label)) || {}).t ?? null;
const metric = (label) => ev.find((e) => e.kind === "metrics" && e.label === label) || {};
return {
ready: at(/^ready$/),
toolbar: at(/loaded #\d+ file:chrome\.html/),
firstPage: at(/loaded #\d+ file:home\.html/),
blocks6s: ev.filter((e) => e.kind === "block" && e.endedAt < 6000).reduce((a, e) => a + e.ms, 0),
addons: ev.filter((e) => e.kind === "addon").reduce((a, e) => a + e.ms, 0),
overlaysLoaded: ev.filter((e) => e.kind === "wc" && /loaded #\d+ file:(popover|engine-picker|downloads|address-picker|pw-fill|link-status|approval|js-dialog)\.html/.test(e.label)).length,
fetches6s: ev.filter((e) => e.kind === "fetch" && e.start < 6000).length,
proc15: metric("15s").processes ?? null,
mem15: metric("15s").privateMB ?? null,
failed: ev.filter((e) => e.kind === "fail").map((e) => e.label),
};
}
const rows = [];
for (let i = 1; i <= RUNS; i++) {
process.stdout.write(`run ${i}/${RUNS}… `);
const file = await runOnce(i);
if (!existsSync(file)) { console.log("no trace written (see " + file.replace(/json$/, "log") + ")"); continue; }
const r = summarise(file); rows.push(r);
console.log(r.failed.length ? `INVALID — ${r.failed.length} page load(s) failed: ${r.failed[0]}` : "done");
}
const cols = [["ready", "ready"], ["toolbar", "toolbar"], ["firstPage", "1st page"], ["blocks6s", "blocked<6s"], ["addons", "addons"], ["overlaysLoaded", "overlays"], ["fetches6s", "fetch<6s"], ["proc15", "procs@15s"], ["mem15", "MB@15s"]];
console.log("\n" + ["run", ...cols.map((c) => c[1])].map((h) => h.padStart(11)).join(""));
rows.forEach((r, i) => console.log((r.failed.length ? "!" : " ") + [String(i + 1), ...cols.map(([k]) => String(r[k] ?? "—"))].map((v) => v.padStart(11)).join("")));
console.log(`\ntimes in ms since process start; MB = private memory of all Theseus processes. Traces: ${traceDir}`);

View file

@ -0,0 +1,89 @@
// Boot tracer — Electron entry that wraps main.js without changing it and
// records what happens during the first BOOT_TRACE_MS of a launch. Started by
// run.mjs (see there); writes BOOT_TRACE_OUT as JSON.
//
// Records: slow require()s, main-thread (event-loop) blocks, add-on
// activation times, every main-process fetch(), WebContents creation and
// first loads, selected log lines, and process count / private memory at
// 5 s, 15 s and the end.
"use strict";
const T0 = performance.timeOrigin;
const now = () => Math.round(performance.now());
const fs = require("fs"), path = require("path"), Module = require("module");
const APP = path.resolve(__dirname, "..", "..");
const OUT = process.env.BOOT_TRACE_OUT;
const DURATION = Number(process.env.BOOT_TRACE_MS || 40000);
if (!OUT || !process.env.THESEUS_USER_DATA) { console.error("run via scripts/boot-trace/run.mjs"); process.exit(2); }
const ev = [];
const mark = (kind, label, extra) => ev.push({ t: now(), kind, label, ...extra });
// Slow require()s, top-level only (self time including children).
const origLoad = Module._load; let depth = 0;
Module._load = function (request) {
const s = performance.now(); depth++;
try { return origLoad.apply(this, arguments); }
finally { depth--; const d = performance.now() - s; if (d > 15 && depth <= 1) mark("require", request, { ms: Math.round(d) }); }
};
// Event-loop blocks > 60 ms, sampled every 20 ms.
let last = performance.now();
setInterval(() => { const n = performance.now(); const block = n - last - 20; if (block > 60) mark("block", "event loop blocked", { ms: Math.round(block), endedAt: Math.round(n) }); last = n; }, 20).unref();
// Main-process fetch() traffic.
const origFetch = globalThis.fetch;
globalThis.fetch = async function (input, init) {
const url = String((input && input.url) || input); const s = now();
try { const r = await origFetch(input, init); mark("fetch", url.slice(0, 110), { start: s, ms: now() - s, status: r.status }); return r; }
catch (e) { mark("fetch", url.slice(0, 110), { start: s, ms: now() - s, error: e?.cause?.code || e?.name }); throw e; }
};
for (const k of ["log", "warn", "error"]) {
const o = console[k].bind(console);
console[k] = (...a) => { const line = a.map(String).join(" "); if (/\[(bns|addons|update|tor)\]|activated|snapshot/i.test(line)) mark("log", line.slice(0, 160)); o(...a); };
}
const { app } = require("electron");
// With a script as the entry, Electron takes the script's folder as the app
// path, and main.js loads its pages by relative name (loadFile("chrome.html"))
// — every UI page then fails with ERR_FILE_NOT_FOUND. Point it at the app.
app.setAppPath(APP);
app.setPath("userData", process.env.THESEUS_USER_DATA);
// With a script as the entry, package.json isn't read: keep the real name and
// version so profile paths and version checks behave as in a normal run.
const pkg = JSON.parse(fs.readFileSync(path.join(APP, "package.json"), "utf8"));
app.setName(pkg.productName || pkg.name);
app.getVersion = () => pkg.version;
app.on("ready", () => mark("app", "ready"));
app.on("web-contents-created", (_e, wc) => {
const id = wc.id; mark("wc", "created #" + id);
wc.once("did-finish-load", () => { let u = ""; try { u = wc.getURL(); } catch {} mark("wc", `loaded #${id} ${u.replace(/^file:\/\/\/.*\//, "file:").slice(0, 90)}`); });
// A failed main-frame load means the run did not measure a working launch.
wc.on("did-fail-load", (_e, code, desc, url, isMainFrame) => { if (isMainFrame && code !== -3) mark("fail", `#${id} ${code} ${desc} ${String(url).slice(0, 90)}`); });
});
const metrics = (label) => {
try {
const m = app.getAppMetrics();
const kb = m.reduce((a, p) => a + (p.memory?.privateBytes || 0), 0);
mark("metrics", label, { processes: m.length, renderers: m.filter((p) => p.type === "Tab").length, privateMB: Math.round(kb / 1024) });
} catch {}
};
setTimeout(() => metrics("5s"), 5000);
setTimeout(() => metrics("15s"), 15000);
const AddonHostProto = require(path.join(APP, "addons-host.js")).AddonHost.prototype, origAct = AddonHostProto._activateOne;
AddonHostProto._activateOne = function (manifest) {
const s = performance.now();
try { return origAct.apply(this, arguments); }
finally { mark("addon", manifest.id, { ms: Math.round(performance.now() - s) }); }
};
mark("trace", "main.js require start");
require(path.join(APP, "main.js"));
mark("trace", "main.js require done");
setTimeout(() => {
metrics("end");
fs.writeFileSync(OUT, JSON.stringify(ev.sort((a, b) => a.t - b.t), null, 1));
app.exit(0);
}, DURATION);

View file

@ -278,6 +278,25 @@
<input id="webLangOther" type="text" placeholder="BCP-47, e.g. cs-CZ" hidden style="width:150px">
</div>
</div>
<h2 class="sub">Translate pages</h2>
<p class="subd">Translate a page's visible text in place to your preferred language. The <b>Accept-Language</b> row above only asks the server for a translated body — many static sites (including names on BCDN) serve only one language, and translation turns them readable without a reload.</p>
<div class="row">
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.</div></div>
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">Translation peers</div>
<div class="d">Ordered list of LibreTranslate-compatible endpoints. The first one that answers wins; a dead or rate-limited peer is skipped and the next is tried. Keep Silent Mode's own instances at the top; add your own self-hosted LibreTranslate for privacy or unlimited use. On-device Bergamot (WASM) is coming in a later release.</div></div>
<div id="translateEndpointList" style="display:flex;flex-direction:column;gap:6px"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<input id="translatePeerUrl" type="text" placeholder="https://your-libretranslate/translate" style="flex:1;min-width:220px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
<button id="translatePeerAdd" class="btn">Add peer</button>
</div>
</div>
<div class="row">
<div class="txt"><div class="t">API key (optional)</div><div class="d">Shared across all peers in the list. Some LibreTranslate instances need a key for the paid tier or to raise the rate limit; leave blank for peers that don't.</div></div>
<input id="translateApiKey" type="text" placeholder="(none)" style="min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
</div>
<h2 class="sub">Startup</h2>
<div class="row">
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
@ -590,6 +609,10 @@
<div class="txt"><div class="t">Throttle inactive tabs</div><div class="d">Background and inactive tabs use far less CPU. Recommended.</div></div>
<label class="sw"><input type="checkbox" id="backgroundThrottle"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Stop tabs in the background</div><div class="d">A tab you switch away from stops running — scripts, timers, audio and video — until you come back to it. Right-click a tab and choose “Keep running in background” for music, calls or anything that should keep going.</div></div>
<label class="sw"><input type="checkbox" id="freezeBackgroundTabs"><span class="track"><span class="knob"></span></span></label>
</div>
</section>
<!-- PRIVACY -->
<section id="privacy" hidden>
@ -1018,15 +1041,24 @@
el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim()));
});
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow"];
"quickLinksShow", "translateAutoOffer"];
// Text inputs that round-trip through settings-set on change. Trimmed; a
// cleared field writes an empty string, which main re-defaults from
// SETTINGS_DEFAULTS on next launch.
const TEXT_FIELDS = ["translateApiKey"];
C.get().then((s) => {
for (const k of TOGGLES) {
const el = document.getElementById(k); if (!el) continue;
el.checked = !!s[k];
el.addEventListener("change", () => C.set(k, el.checked));
}
for (const k of TEXT_FIELDS) {
const el = document.getElementById(k); if (!el) continue;
el.value = String(s[k] ?? "");
el.addEventListener("change", () => C.set(k, el.value.trim()));
}
// ---- Quick-links list editor (General) --------------------------------
// Add/remove rows; each change writes the whole settings.quickLinks array.
// The strip view and the main window layout react through settings-set.
@ -1074,6 +1106,53 @@
// Live-sync from main (edits from other surfaces, or the strip's own add).
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
})();
// ---- Translation-peer list editor (General) ---------------------------
// Same shape as the Quick-links editor above: add a row, trash a row,
// each change writes the whole array. The chip + translator service read
// the list in order and skip dead peers; the top entry is the primary.
(function () {
const list = document.getElementById("translateEndpointList");
const urlIn = document.getElementById("translatePeerUrl");
const addBtn = document.getElementById("translatePeerAdd");
if (!list || !addBtn) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
function hostOf(u) { try { return new URL(u).host; } catch { return u; } }
function render(peers) {
list.innerHTML = "";
if (!peers.length) {
const empty = document.createElement("div");
empty.className = "pmuted"; empty.style.fontSize = "12.5px";
empty.textContent = "No peers configured — add one below, or restart to restore the defaults.";
list.appendChild(empty);
return;
}
for (let i = 0; i < peers.length; i++) {
const url = peers[i];
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
const primary = i === 0 ? '<span style="font-size:11px;color:var(--acid-text);background:rgba(214,255,61,.08);border:1px solid rgba(214,255,61,.3);border-radius:6px;padding:2px 6px;margin-right:6px">PRIMARY</span>' : '';
row.innerHTML = `<div style="flex:1;min-width:0"><div style="font-weight:600">${primary}${esc(hostOf(url))}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(url)}</div></div>
<button class="btn ghost" data-del="${i}">Remove</button>`;
list.appendChild(row);
}
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
const i = Number(b.dataset.del);
const cur = (await C.get()).translateEndpoints || [];
C.set("translateEndpoints", cur.filter((_, idx) => idx !== i));
});
}
render(s.translateEndpoints || []);
addBtn.onclick = async () => {
const u = String(urlIn.value || "").trim();
if (!u) return;
const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u;
const cur = (await C.get()).translateEndpoints || [];
if (cur.includes(url)) { urlIn.value = ""; return; }
C.set("translateEndpoints", [...cur, url]);
urlIn.value = "";
};
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || []));
})();
// ---- website language (General page) — friendly wrapper over the
// same languageMode/languageValue setting the Anti-fingerprinting Language
// row edits. "Auto" = languageMode="show" (follow OS); anything else