Compare commits
10 commits
f2ff48b977
...
e8317fef16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8317fef16 | ||
|
|
0b36532922 | ||
|
|
f3c8998ecc | ||
|
|
8b0cc00290 | ||
|
|
adcea19f8a | ||
|
|
b531c89f82 | ||
|
|
a57d20751b | ||
|
|
c5b2383df8 | ||
|
|
1f1bde6e60 | ||
|
|
c2ec0f0d02 |
9 changed files with 1709 additions and 382 deletions
369
DESIGN-bns-indexer-service.md
Normal file
369
DESIGN-bns-indexer-service.md
Normal 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
331
bns-indexer.js
Normal 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)); }
|
||||
});
|
||||
66
chrome.html
66
chrome.html
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
78
scripts/boot-trace/run.mjs
Normal file
78
scripts/boot-trace/run.mjs
Normal 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}`);
|
||||
89
scripts/boot-trace/trace-main.js
Normal file
89
scripts/boot-trace/trace-main.js
Normal 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);
|
||||
|
|
@ -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) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue