Initial commit — Silent Mode baseline (2026-07-29)

Snapshot of the decentralized-web stack at the point of the resolver+Theseus
rebuild deploy. Includes:

- Argus (BNS engine + resolver daemon + Sia gateway)
- AriadneResolver (Windows Inno installer bundle + Android APK sources +
  Firefox extension)
- TheseusNavigator (Electron browser)
- site/ (silentmode.st content, deployed to Sia at bns/silentmode/)
- design docs, roadmap, protocol spec

Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json,
Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes
also excluded.

Shipped hashes on dl.silentmode.st at this commit:
  AriadneResolver-Setup-0.1.0.exe    5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a
  TheseusNavigator-Setup-0.0.1.exe   7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b
  TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521
  ariadne-v0.2.apk                   635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
This commit is contained in:
Local Dev 2026-07-29 13:54:34 +02:00
commit 7898e78ff4
19 changed files with 7023 additions and 0 deletions

76
BROWSER-PROMPT.md Normal file
View file

@ -0,0 +1,76 @@
# Prompt for a new session: build Theseus, the browser
Copy everything below the line into a fresh Claude Code session (suggested
working directory: `D:\Dev\BnsBrowser`).
---
Build **Theseus Navigator** ("Theseus" for short) — a standalone desktop web
browser with native support for decentralized `.bch` domain names, resolved
from the Bitcoin Cash blockchain. Windows 10 first; Electron is the expected
shell (Chromium engine, no forking).
Branding: *Theseus Navigator, by Silent Mode — a Deviant project*. The name refers to the
thread through the labyrinth: the blockchain is the thread. Read
`D:\Dev\SilentMode\README.md` first for the whole picture. `theseus.bch` is
already registered on chipnet and can serve the browser's own homepage.
## Context — a working stack already exists
`D:\Dev\NameCoin` contains a complete, tested prototype (read its README.md,
PROTOCOL.md, and ROADMAP.md first):
- `src/lib/bns.js` — the resolver core: reads the "BNS1" protocol from BCH
chipnet via electrum (mainnet-js). Names are CashTokens NFTs; records are
JSON in OP_RETURN (`ip`, `u`, `p`, `h`, `s3`, `tls` types); discovery via a
beacon address. Subdomains map through the parent name (see bnsd.js).
- `src/daemon/bnsd.js` — reference gateway: serves `h` (on-chain HTML), `p`
(reverse proxy), `s3` (Sia storage via an S3 endpoint configured in
`sia-s3.json`), redirects `u`, and resolves `ip` records at DNS level.
- `ca/` — a local root CA ("BNS Local Root CA") that signs per-name certs;
fingerprints can be pinned via on-chain `tls` records.
- Live test names on chipnet right now: `hello.bch` (on-chain HTML),
`coinspectrum.bch` (ip → real nginx server), `siatest.bch` and
`coinspectrum.deviant.bch` (Sia-backed, subdomain namespace),
`theseus.bch` and `silentmode.bch` (on-chain HTML, brand pages).
Reuse `bns.js` as a library (npm file: dependency or copied module) — do not
reimplement the protocol. The browser must NOT require the system daemon,
NRPT rules, or the OS trust store: everything in-process.
## Requirements
1. **Chrome-like shell**: tabs, address bar, back/forward/reload, history.
Electron BrowserViews/WebContentsViews per tab.
2. **Native .bch resolution**: typing `hello.bch` or `x.deviant.bch` resolves
via the BNS core inside the app (no OS DNS involvement). Everything else
browses the normal web unchanged.
3. **Record handling**: `h` render directly; `ip` connect to the host (http);
`p`/`s3` fetch via an in-app gateway on a loopback port; `u` redirect.
4. **TLS for .bch without touching the OS store**: intercept at the session
level (Electron `setCertificateVerifyProc`) — accept certs for .bch hosts
when they chain to the BNS root or match the name's on-chain `tls`
fingerprint; normal CA validation for everything else.
5. **UI truthfulness**: a visible indicator when a page came from the chain /
Sia / a direct ip record, showing the NFT category and record type — the
"padlock" for decentralized provenance.
6. **Fallback honesty**: unregistered .bch → a clean NXDOMAIN page naming the
chain; electrum outages → readable error, not a hang.
7. Package with electron-builder into a Windows installer (unsigned is fine).
## Definition of done
All four live names above load correctly in tabs alongside normal websites
(e.g. wikipedia.org), with provenance indicators, from a packaged .exe on
Windows 10 — no admin rights, no system daemon, no OS DNS or cert changes.
## Constraints & notes
- BCH chipnet; electrum servers as in mainnet-js defaults (see bns.js usage).
- `sia-s3.json` in D:\Dev\NameCoin holds the S3 endpoint + keys for `s3`
records (server-side s3d at https://coinspectrum.duckdns.org:8600) — copy
it into the browser's config directory; do not commit it.
- Wallet/registration UI is OUT of scope (exists in the NameCoin repo's
wallet app); this session is the *viewing* side only.
- Test names may be unconfirmed on chipnet — the resolver handles mempool
entries; height -1 is normal.

91
PACKAGING-PROMPT.md Normal file
View file

@ -0,0 +1,91 @@
# Theseus Navigator — packaging session brief
Goal of this session: turn Theseus from "runs via `npm start`" into a
**downloadable, secret-free Windows installer** hosted at `dl.silentmode.st`,
with its SHA-256 published on-chain. Do it in the three phases below, in order.
Working dir: `D:\Dev\SilentMode\TheseusNavigator`. Prefix shell commands with
`rtk`. Windows + PowerShell primary; Bash tool available.
**Read first:** `..\README.md` (hub), `..\BUILD-ROADMAP.md` (this is Stage 5),
`..\SECURITY.md` (Rules 0/2/3 govern this session), `..\_coordination\INTERFACES.md`
(the resolver contract), and this file. Theseus already works for `.bch`; the
engine (`..\Argus\src\lib\resolver-web.js`) is already multi-TLD and zero-dep.
---
## Current state (verified 2026-07-25)
- Theseus **runs** (`npm start`): Electron, tabs, back/fwd/reload/home, address
bar, DuckDuckGo search box, home page, **Tor toggle** (bundled `tor/tor.exe`,
routes web session + resolver WS + content fetch via SOCKS).
- Resolution reuses `..\Argus\src\lib\resolver-web.js` (import in `main.js`).
- `bns://` custom protocol serves content; provenance strip shows chain/Sia/server.
- **NOT** packaged: no `electron-builder`, no installer.
- **Two blockers for a public build (must fix):**
1. `main.js` `resolveHost()` is still `.bch`-only
(`host.replace(/\.bch$/,"").split(".").pop()`), and `navigate()` /
`serveBns()` / `subFolder()` assume `.bch`. The engine is multi-TLD; the
browser must catch up.
2. `serveBns()` fetches `s3` content with **your Sia credentials**
(`getAws()` reads `..\Argus\sia-s3.json`). **A public build MUST NOT ship
credentials** (SECURITY.md Rule 0). Switch `s3` to the public gateway.
## Phase 1 — Multi-TLD + registry badge (browser catches up to engine)
- `resolveHost(host)`: stop stripping `.bch`/`.pop()`. Pass the **full host** to
`resolveName(host, { WebSocket: currentWS() })` — it already normalizes any
`<sub>.<label>.<tld>` to the canonical key. Cache/serve by full host.
- `navigate()`: replace `host.endsWith(".bch")` checks with "is this a BNS name?"
= has a dot and the rightmost label is a known BNS TLD (`bch`, `p2p`, `deviant`,
`bit`, …) — everything else is normal web. Keep a small TLD allowlist constant.
- `serveBns()` + `subFolder()`: generalize off `.bch` (drop tld + label to get
the subfolder — mirror `resolver-web.js`/the gateway).
- **Registry badge (trusted chrome — the security invariant):** in the toolbar
provenance strip (chrome, NOT page content), show the **registry / TLD** the
name resolved under (e.g. "BitcoinCash · .bch"). This is where the future
multi-registry `.bit` (BCH vs Namecoin) switch will surface. Unspoofable
because it's painted by the browser, not the page.
- Verify: `hello.bch`, `demo.p2p`, `coinspectrum.deviant.bch` all load in tabs;
badge shows the right TLD; normal web (wikipedia.org) still works.
## Phase 2 — Secret-free content (SECURITY.md Rule 0)
- Replace the `getAws()` signed-S3 path in `serveBns()` with a plain fetch from
the **public gateway**: `https://navigate.st/bns/<name>/<path>` (or the
subdomain form). The gateway already serves `s3`/`ip`/`h`/`u` and injects no
secrets. Remove the `aws4fetch` dependency and any read of `sia-s3.json`.
- Net effect: a shipped Theseus contains **no credentials**. `s3` sites render by
asking the public relay for the bytes; resolution stays local/trustless.
- Grep the repo + built output for `sia-s3`, `accessKey`, `secretKey`,
`C:\\Users\\`, the OS username — must be clean before packaging.
## Phase 3 — Package, scrub, publish
- Add `electron-builder`; target Windows **NSIS installer** + a **portable .exe**.
Bundle `tor/`, `chrome.html`, `home.html`, `preload.js`, and the resolver it
imports (either vendor `resolver-web.js` into the app or include `../Argus`
path in `files`). Confirm `tor.exe` and geoip files are packaged.
- **Unsigned by choice** (SECURITY.md Rule 2). Document the SmartScreen warning.
- **Scrub fingerprints** (Rule 3): build in a neutral path or strip absolute
paths/source maps; no real name/email/username in metadata; set the app
`author` to "Silent Mode". Normalize timestamps where feasible.
- Compute **SHA-256** of the installer. Publish the hash **on-chain** — a BNS
record (e.g. a `releases` name) or note the mechanism in `..\SECURITY.md`.
- Deploy the installer to **`dl.silentmode.st`** (nginx already serves
`/opt/silent-mode/dl/` on the VPS via the `silentmode-st` / `dl` vhost). Update
the download page (`..\site\index.html`) — swap the "packaging in progress"
chip for a real link + the SHA-256 + "unsigned, verify the hash" note.
- Verify end to end: download the installer on a clean path, install, launch,
load `hello.bch` + a Sia site + normal web, confirm no secrets in the install
dir, confirm the published hash matches.
## Guardrails
- Do NOT touch `D:\Dev\NameCoin` (live/canonical) or register/spend on-chain
beyond (optionally) one release-hash record. Reads are fine.
- Do NOT ship `sia-s3.json`, `ca/root-ca.key`, `wallets.json`, any keystore/
password. Follow the SECURITY.md pre-release checklist.
- Elevated installs, code-signing, and AMO steps are the user's to run — don't
attempt them; hand off exact commands.
## Definition of done
A downloadable **`dl.silentmode.st`** Windows installer that: contains no
secrets, resolves `.bch`/`.p2p`/etc. with a trusted-chrome registry badge,
renders Sia sites via the public gateway, browses the normal web, and whose
SHA-256 is published on-chain and shown on the download page.

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# Theseus Navigator
The browser — the consumer face of the stack. Native `.bch` support with the
resolver built in, so a user installs **one app** instead of modifying their
operating system. Named for the thread through the labyrinth: the chain is the
thread.
## Scope
- Electron shell (Chromium engine, no forking): tabs, address bar, history.
- **In-process `.bch` resolution** — no system daemon, no NRPT, no OS
trust-store changes; reuses `bns.js` from the BNS repo as a library.
- Record handling: `h` render, `ip` connect, `p`/`s3` via in-app gateway,
`u` redirect.
- **TLS via cert-verify hook** (Electron `setCertificateVerifyProc`) against the
BNS root / on-chain `tls` fingerprints — no OS store touched.
- **Provenance indicator**: shows whether a page came from the chain / Sia / a
direct server, with NFT category and record type — the decentralized padlock.
## Status: not started
Build it in its own session/repo. The ready-to-paste brief is
**`BROWSER-PROMPT.md`** (a reference copy in this folder; canonical source is
`D:\Dev\NameCoin\BROWSER-PROMPT.md` — if they diverge, NameCoin wins). It points
here and reuses the resolver core. `theseus.bch` is registered and should
eventually serve the browser's own homepage over the protocol it implements.
## Roadmap
[BUILD-ROADMAP.md](../BUILD-ROADMAP.md) Stage 5.

93
RELEASE-HANDOFF.md Normal file
View file

@ -0,0 +1,93 @@
# Theseus Navigator v0.0.1 — release handoff
Built 2026-07-25 from `D:\Dev\SilentMode\TheseusNavigator`. The code changes
(multi-TLD, registry badge, secret-free Sia-via-gateway) and packaging are done.
The steps below are the ones this session must NOT do for you — they spend
on-chain and touch the VPS. Run them yourself; exact commands are given.
## Artifacts (in `dist-public/`)
| File | Size | SHA-256 |
|---|---|---|
| `TheseusNavigator-Setup-0.0.1.exe` (NSIS installer) | ~92 MB | `55d6d23d950952e5dae22474e1abd4420e8d4081a17ee7be21fbcffc2141189c` |
| `TheseusNavigator-0.0.1-portable.exe` (portable) | ~92 MB | `9832d8cef5c1c1b8d7025cbdf4a70ef82e1e10255f83bc700da2014f9849330f` |
> **The binaries have been rebuilt more than once and the hashes changed each
> time** (`41df5809…``77e836ac…``f39132bc…`). The values above were
> re-computed from `dist-public/` on 2026-07-26 and match what `site/` and
> `site/releases-manifest.json` publish. **Always re-hash immediately before the
> Step 2 on-chain publish** — a rebuild between writing a doc and spending the
> transaction silently invalidates it, and that step cannot be undone.
Verified clean: no `sia-s3`/`accessKey`/`secretKey`/`aws4fetch` and no
`C:\Users\valer` / username strings in `app.asar` or bundled resources.
Re-verify the hashes any time:
```bash
cd D:\Dev\SilentMode\TheseusNavigator\dist-public && sha256sum *.exe
```
## Step 1 — Deploy to `dl.silentmode.st` (your VPS)
nginx already serves `/opt/silent-mode/dl/` on the `dl` / `silentmode-st` vhost.
Upload both installers + the manifest (run over the anonymous VPN/Tor per
SECURITY.md §4):
```bash
scp dist-public/TheseusNavigator-Setup-0.0.1.exe dist-public/TheseusNavigator-0.0.1-portable.exe site/releases-manifest.json <vps>:/opt/silent-mode/dl/
```
**Manifest moved (2026-07-26).** `releases-manifest.json` is now multi-product
(Theseus + Ariadne Android) and lives at `site/releases-manifest.json` — note
the `scp` line above copies it from `site/`, not from `dist-public/`. The old
Theseus-only copy in `dist-public/` was deleted the same day: uploading it would
have wiped the Android APK's checksum off the live manifest. Do not re-create
one there — that is electron-builder's output directory, and hand-placed files
are lost on the next `npm run dist`.
The download page (`site/index.html`) already links these exact filenames at
`https://dl.silentmode.st/…`, so no page edit is needed once they land.
## Step 2 — Publish the hash on-chain (`releases.silentmode.bch`)
This spends chipnet funds and uses the wallet seed — your action, not the
session's. The engine tool is `Argus/src/update.js`. Publish the manifest as an
inline `h` record so `releases.silentmode.bch` itself serves the hash list
(same mechanism as `hello.bch`):
```bash
cd D:\Dev\SilentMode\Argus
node src/update.js releases.silentmode "{\"h\":\"<html><body><pre>Theseus 0.0.1\nSetup 55d6d23d950952e5dae22474e1abd4420e8d4081a17ee7be21fbcffc2141189c\nPortable 9832d8cef5c1c1b8d7025cbdf4a70ef82e1e10255f83bc700da2014f9849330f\nAriadne Android 0.2\nAPK 635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c</pre></body></html>\"}"
```
- If `releases.silentmode` is not yet registered, register it first with
`node src/register.js releases.silentmode "{...}"`.
- If the full HTML exceeds the OP_RETURN size your relay policy accepts, store a
compact record instead — e.g. `{"u":"https://dl.silentmode.st/releases-manifest.json"}`
— but the trustless ideal is the hashes themselves on-chain.
- **Do this from `D:\Dev\SilentMode\Argus`, never `D:\Dev\NameCoin` (live).**
## Step 3 — Optional per SECURITY.md §5 (multi-channel)
- Create a torrent/magnet of each installer; add the magnet to the manifest +
download page.
- Mirror to IPFS (`ipfs add`) and Sia; list all mirrors, all verified against
the on-chain hash.
- Sign `releases-manifest.json` with your pseudonymous minisign key.
## Build reproducibility note (for whoever rebuilds)
`npm run dist` fetches electron-builder's `winCodeSign` package, whose macOS
symlinks fail to extract on Windows without admin/Developer Mode. Since builds
are **unsigned by choice** (SECURITY.md §2), pre-extract it once without the
`darwin` folder, then the build runs clean:
```bash
CACHE="$LOCALAPPDATA/electron-builder/Cache/winCodeSign"
node_modules/7zip-bin/win/x64/7za.exe x "$CACHE"/<downloaded>.7z -o"$CACHE/winCodeSign-2.6.0" -xr!darwin -y
```
Build with a fixed `SOURCE_DATE_EPOCH` (set to 2026-07-25 for this release) and
`CSC_IDENTITY_AUTO_DISCOVERY=false` so timestamps don't fingerprint the build
and no signing identity is sought.

222
chrome.html Normal file
View file

@ -0,0 +1,222 @@
<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
body { margin: 0; height: 100vh; background: #0f1420; color: #e7eaf1; user-select: none; overflow: hidden; }
/* tab strip */
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; }
.tab { display: flex; align-items: center; gap: 8px; max-width: 200px; min-width: 90px; padding: 6px 10px;
background: #1b2330; border: 1px solid #ffffff14; border-bottom: none; border-radius: 8px 8px 0 0;
font-size: 12.5px; cursor: default; color: #b9c2d0; }
.tab.active { background: #26304180; color: #fff; }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .x:hover { opacity: 1; background: #ffffff1a; }
.newtab { padding: 4px 10px; cursor: pointer; color: #8b98a9; border-radius: 6px; font-size: 16px; }
.newtab:hover { background: #ffffff12; color: #fff; }
/* toolbar */
.bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; }
.nav { display: flex; gap: 1px; }
/* Firefox-style round-rect icon buttons: 16px stroke glyph in a 34x32 hit target */
.ic { width: 34px; height: 32px; display: grid; place-items: center; border-radius: 8px; cursor: pointer;
color: #c5cdda; background: transparent; border: none; padding: 0; }
.ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round; }
.ic:hover { background: #ffffff14; color: #fff; }
.ic:active { background: #ffffff22; }
.ic:disabled { opacity: .28; cursor: default; background: transparent; }
/* clickable Theseus logo = site-info button */
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d6ff3d; white-space: nowrap;
padding: 5px 10px; margin: 0 2px; border: 1px solid transparent; border-radius: 8px; cursor: pointer;
background: transparent; font-size: 13px; }
.logo:hover { background: rgba(214,255,61,.10); border-color: #d6ff3d33; }
.logo.open { background: rgba(214,255,61,.14); border-color: #d6ff3d55; }
.logo .chev { color: #9fb04e; font-size: 9px; }
input { padding: 8px 13px; border-radius: 999px; border: 1px solid #ffffff22; background: #1b2330; color: inherit; font-size: 13.5px; outline: none; }
input:focus { border-color: #4b7bec; }
.urlwrap { flex: 1; display: flex; align-items: center; gap: 6px; background: #1b2330; border: 1px solid #ffffff22;
border-radius: 999px; padding-right: 8px; }
.urlwrap:focus-within { border-color: #4b7bec; }
#url { flex: 1; border: none; background: transparent; border-radius: 999px; }
#url:focus { border: none; }
#search { width: 180px; }
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid #ffffff22; background: #2b2f3a; color: #fff; cursor: pointer; white-space: nowrap; font-size: 12.5px; }
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
.sep { width: 1px; height: 22px; background: #ffffff1a; margin: 0 4px; }
.searchbox { display: flex; align-items: center; gap: 4px; }
.searchbox .mag { color: #8b98a9; font-size: 13px; }
/* registry/TLD badge — trusted chrome, painted by the browser not the page */
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 9px; border-radius: 999px;
background: rgba(214,255,61,.13); color: #d6ff3d; border: 1px solid #d6ff3d33; white-space: nowrap; }
/* site-info dropdown panel (opened by the logo) */
.siteinfo { font-size: 12px; color: #dbe3ef; background: #141b28; border-top: 1px solid #ffffff12;
padding: 10px 16px; display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; }
.siteinfo .k { color: #7f8aa0; }
.siteinfo .v { color: #e7eaf1; }
.siteinfo .v code { font-family: ui-monospace, monospace; color: #bfeae4; }
.siteinfo .dotv { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #5e6678; margin-right: 6px; vertical-align: middle; }
.siteinfo .dotv.chain { background: #d6ff3d; } .siteinfo .dotv.sia { background: #b39ddb; }
.siteinfo .dotv.server { background: #4fd1a5; } .siteinfo .dotv.web { background: #8b93a7; } .siteinfo .dotv.err { background: #f6768a; }
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
.tordisc a { color: #d6ff3d; }
/* passive "also on BCNR" bar — dual-use TLD switch (never hijacks the web) */
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: #dbead0;
background: #1a2417; border-top: 1px solid #d6ff3d33; padding: 6px 14px; }
.bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px;
background: rgba(214,255,61,.14); color: #d6ff3d; border: 1px solid #d6ff3d33; }
.bcnrbar b { color: #fff; }
.bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid #d6ff3d55;
background: rgba(214,255,61,.12); color: #eaffb0; cursor: pointer; font-size: 11.5px; }
.bcnrbar .bopen:hover { background: rgba(214,255,61,.22); }
.bcnrbar .bdismiss { color: #8b98a9; text-decoration: none; }
.bcnrbar .bdismiss:hover { color: #fff; }
</style></head>
<body>
<div class="tabs" id="tabs"></div>
<div class="bar">
<div class="nav">
<button class="ic" id="back" title="Back">
<svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
<button class="ic" id="fwd" title="Forward">
<svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
<button class="ic" id="reload" title="Reload">
<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
<button class="ic" id="home" title="Home">
<svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
</div>
<button class="logo" id="logo" title="Site information">
<span>⛓ Theseus</span><span class="chev"></span></button>
<div class="urlwrap">
<input id="url" placeholder="Search or enter a .bch / .p2p name or web address" spellcheck="false">
<span class="reg" id="reg" hidden></span>
</div>
<span class="sep"></span>
<div class="searchbox">
<span class="mag">🔍</span>
<input id="search" placeholder="Search the web" spellcheck="false">
</div>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
<button class="ic" id="settings" title="Settings">
<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.5 L8 3 M8 13 L8 14.5 M1.5 8 L3 8 M13 8 L14.5 8 M3.3 3.3 L4.3 4.3 M11.7 11.7 L12.7 12.7 M12.7 3.3 L11.7 4.3 M4.3 11.7 L3.3 12.7"/></svg></button>
</div>
<div id="siteinfo" class="siteinfo" hidden></div>
<div id="tordisc" class="tordisc" hidden>
Onion mode hides your IP from sites and your ISP (including .bch lookups).
It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser.
<a href="#" id="tordismiss">got it</a>
</div>
<div id="bcnrbar" class="bcnrbar" hidden>
<span class="breg" id="bcnrreg">BCNR</span>
<span><b id="bcnrhost"></b> also exists on the <b>BCNR</b> decentralized registry.</span>
<button id="bcnropen" class="bopen">Open on BCNR</button>
<a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a>
</div>
<script>
const $ = (id) => document.getElementById(id);
const T = window.theseus;
// Keep the chrome view tall enough for whatever bars are visible (site info / Tor / BCNR).
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
function goURL() { const v = $("url").value.trim(); if (v) T.navigate(v); }
$("url").addEventListener("keydown", (e) => { if (e.key === "Enter") goURL(); });
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) { T.search(q); $("search").value = ""; } } });
$("back").onclick = () => T.back();
$("fwd").onclick = () => T.forward();
$("reload").onclick = () => T.reload();
$("home").onclick = () => T.goHome();
$("settings").onclick = () => T.openSettings();
// ---- site-info panel (toggled by the Theseus logo) ----
let lastNav = null, infoOpen = false;
function renderInfo() {
const d = lastNav, box = $("siteinfo");
const row = (k, v) => `<span class="k">${k}</span><span class="v">${v}</span>`;
let html = "";
if (!d || d.kind === "home") {
html = row("Page", "Theseus home") +
row("About", "Theseus is a decentralized-web navigator. It resolves BCNR names (.bch, .p2p, .nav…) straight from the Bitcoin&nbsp;Cash chain — no ICANN, no DNS middleman.");
} else if (d.kind === "web") {
html = row("Site", `<span class="dotv web"></span><code>${(d.host||"").replace(/</g,"&lt;")}</code>`) +
row("Resolved via", "ICANN DNS (ordinary web)") +
row("Registry", "ICANN — not a BCNR name");
} else if (d.kind === "resolving") {
html = row("Site", `<code>${(d.host||"").replace(/</g,"&lt;")}</code>`) + row("Status", "resolving on the BCH chain…");
} else if (d.kind === "nxdomain") {
html = row("Name", `<span class="dotv err"></span><code>${(d.host||"").replace(/</g,"&lt;")}</code>`) +
row("Status", "not registered on the BCNR chain (NXDOMAIN)");
} else if (d.kind === "error") {
html = row("Status", `<span class="dotv err"></span>${(d.error||"resolution failed").replace(/</g,"&lt;")}`);
} else { // ok
const cls = (d.source||"").includes("chain") ? "chain" : (d.source||"").includes("Sia") ? "sia" : (d.source||"").includes("server") ? "server" : "web";
html = row("Site", `<span class="dotv ${cls}"></span><code>${(d.host||"").replace(/</g,"&lt;")}</code>`) +
row("Served from", d.source || "chain") +
row("Registry", (d.registry || "BCNR") + (d.tld ? " · ." + d.tld : "")) +
(d.records && d.records.length ? row("Records", `<code>${d.records.join(", ")}</code>`) : "") +
(d.category ? row("Certificate", `<code>${String(d.category).slice(0,24)}…</code>`) : "");
}
box.innerHTML = html;
}
function toggleInfo(force) {
infoOpen = force !== undefined ? force : !infoOpen;
$("siteinfo").hidden = !infoOpen;
$("logo").classList.toggle("open", infoOpen);
if (infoOpen) renderInfo();
syncHeight();
}
$("logo").onclick = () => toggleInfo();
let torShown = false;
$("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } };
$("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); };
// Passive "also on BCNR" bar for dual-use ICANN TLDs (.de/.dev/.ltd).
T.onBcnrOffer((d) => {
const bar = $("bcnrbar");
if (d && d.host) {
$("bcnrhost").textContent = d.host;
$("bcnrreg").textContent = d.registry || "BCNR";
bar.hidden = false;
} else { bar.hidden = true; }
syncHeight();
});
$("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); };
$("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); };
T.onTor((d) => {
const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : "");
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
});
T.onTabs((d) => {
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
if (document.activeElement !== $("url")) $("url").value = d.url || "";
const box = $("tabs");
box.innerHTML = d.tabs.map((t) =>
`<div class="tab ${t.active ? "active" : ""}" data-id="${t.id}">
<span class="t">${(t.title || "New Tab").replace(/</g, "&lt;")}</span>
<span class="x" data-close="${t.id}"></span></div>`).join("") +
`<span class="newtab" id="newtab">+</span>`;
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
});
$("newtab").onclick = () => T.newTab();
});
T.onNav((d) => {
lastNav = d;
// Registry/TLD badge: shown for any BNS name, painted here (trusted chrome).
const reg = $("reg");
if (d.tld) { reg.hidden = false; reg.textContent = (d.registry || "BCNR") + " · ." + d.tld; }
else reg.hidden = true;
// Keep the address bar in sync (unless the user is typing).
if (document.activeElement !== $("url")) {
if (d.kind === "home") $("url").value = "";
else if (d.host && !$("url").value) $("url").value = d.host;
}
if (infoOpen) renderInfo();
});
syncHeight();
</script>
</body>
</html>

44
dev/README.md Normal file
View file

@ -0,0 +1,44 @@
# Theseus dev/test harness
Two ways to drive Theseus's resolution + content path headlessly, for testing
and debugging without clicking through the GUI. Both use the **real** resolver
(`Argus/src/lib/resolver-web.js`) and gateway path the shipped app uses.
## `selftest.js` — full render (Electron)
Runs the actual `serveBns` protocol handler (imported from `main.js`) in a
hidden offscreen Electron window, loads a `bns://` name, lets its JavaScript
execute, then reports what really rendered and writes a screenshot.
```bash
npx electron dev/selftest.js hello.bch
npx electron dev/selftest.js coinspectrum.deviant.bch # JS-driven Sia site
THESEUS_SETTLE=8000 npx electron dev/selftest.js <name> # wait longer for data
```
Output: a JSON report (title, badge, stylesheet/script counts, local vs external
broken-image counts, visible-text length, failed loads, `verdict`) plus
`dev-out/render.png` and `dev-out/render.html`. Exit 0 = PASS. The verdict counts
only the site's own `bns://` assets — external CDN images are informational.
This is the faithful version of manual tests #2 (Sia-via-gateway rendering) and
#3 (multi-TLD badge). `main.js` exports its handler and skips auto-launch when
`THESEUS_NO_AUTOSTART=1`, which the harness sets.
## `probe-site.mjs` — content path only (Node, no Electron)
Fast check with no display or Electron: resolves a name, fetches the index
through the gateway exactly as `serveBns` does (with the `<base>` strip), and
fetches each **static** same-origin asset, reporting status/content-type.
```bash
node dev/probe-site.mjs coinspectrum.deviant.bch
```
Limitation: static-HTML only. It cannot see assets a page builds at runtime in
JavaScript — use `selftest.js` for JS-driven sites.
## `dev-out/`
Generated render artifacts (screenshot + HTML dump). Safe to delete; regenerated
on each `selftest.js` run.

65
dev/probe-site.mjs Normal file
View file

@ -0,0 +1,65 @@
// Dev harness — exercise Theseus's `s3` content path (serveBns) WITHOUT Electron.
// Faithfully replicates what the browser triggers for a Sia site: resolve the
// name, fetch the index through the public gateway exactly as serveBns does,
// strip the gateway's <base>, then fetch every referenced same-origin asset the
// same way and report which load. This is the headless version of manual test
// #2 (Sia-via-gateway rendering).
//
// Usage: node dev/probe-site.mjs <name> e.g. coinspectrum.deviant.bch
import WebSocket from "ws";
import { resolveName } from "../../Argus/src/lib/resolver-web.js";
const GATEWAY = "https://navigate.st"; // must match main.js
const host = (process.argv[2] || "coinspectrum.deviant.bch").toLowerCase();
// mirror of serveBns's s3 fetch for one request path
async function gw(reqPath) {
const r = await fetch(`${GATEWAY}/bns/${host}${reqPath}`);
const ct = r.headers.get("content-type") || "";
const buf = Buffer.from(await r.arrayBuffer());
return { status: r.status, ct, buf };
}
const stripBase = (html) => html.replace(/<base\s+href="\/bns\/[^"]*">/i, "");
// same-origin STATIC asset refs a browser would request from bns://host/.
// Strip <script>/<style> bodies first so we don't match template literals or
// URLs built at runtime (those need a real render to verify — see selftest.js).
function assetPaths(html) {
const stripped = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
const out = new Set();
const re = /(?:href|src)\s*=\s*["']([^"']+)["']/gi;
let m;
while ((m = re.exec(stripped))) {
const u = m[1].trim();
if (!u || u.startsWith("#") || u.startsWith("data:") || u.startsWith("mailto:")) continue;
if (/^[a-z]+:\/\//i.test(u) || u.startsWith("//")) continue; // external
if (u.includes("${") || u.includes("{{")) continue; // runtime-built
out.add(u.startsWith("/") ? u : "/" + u.replace(/^\.?\//, ""));
}
return [...out];
}
console.log(`\n=== probe ${host} ===`);
const entry = await resolveName(host, { WebSocket });
if (!entry) { console.log("NXDOMAIN — not registered"); process.exit(1); }
console.log("records:", JSON.stringify(entry.records));
if (!entry.records.s3) { console.log("(not an s3 site — this harness targets s3)"); process.exit(0); }
const idx = await gw("/");
const wasBase = /<base\s+href="\/bns\//i.test(idx.buf.toString("utf8"));
const html = stripBase(idx.buf.toString("utf8"));
console.log(`index: HTTP ${idx.status} ${idx.ct} base-injected-by-gateway=${wasBase} (stripped)`);
const assets = assetPaths(html);
console.log(`\nassets referenced: ${assets.length}`);
let fail = 0;
for (const p of assets) {
const a = await gw(p);
const ok = a.status >= 200 && a.status < 300;
if (!ok) fail++;
console.log(` ${ok ? "OK " : "FAIL"} ${String(a.status).padEnd(3)} ${a.ct.split(";")[0].padEnd(24)} ${p}`);
}
console.log(`\nresult: ${assets.length - fail}/${assets.length} assets loaded, ${fail} failed`);
process.exit(fail ? 2 : 0);

21
dev/registry-decide.mjs Normal file
View file

@ -0,0 +1,21 @@
// Daemon decision logic (favor-BCNR, detect ICANN conflict), tested against the
// real chain + real ICANN DNS. This is what bnsd.js would run per query.
import WebSocket from "ws";
import { resolveName } from "../../Argus/src/lib/resolver-web.js";
import { Resolver } from "node:dns/promises";
const icann = new Resolver(); icann.setServers(["1.1.1.1"]); // bypass local NRPT
async function decide(name) {
const [bcnr, ip] = await Promise.all([
resolveName(name, { WebSocket }).catch(() => null),
icann.resolve4(name).then(a => a[0]).catch(() => null),
]);
let d;
if (bcnr && ip) d = "CONFLICT → ask user (BCNR vs ICANN)";
else if (bcnr) d = "serve BCNR (favored; no ICANN entry)";
else if (ip) d = `forward ICANN (${ip})`;
else d = "NXDOMAIN (neither root has it)";
console.log(`${name.padEnd(16)} BCNR=${bcnr ? "yes[" + Object.keys(bcnr.records) + "]" : "no ".padEnd(3)} ICANN=${(ip||"no").padEnd(15)} -> ${d}`);
}
const names = process.argv.slice(2).length ? process.argv.slice(2) : ["syskypo.de","google.de","go.dev","nothing-xyz.de"];
for (const n of names) await decide(n);
process.exit(0);

9
dev/rescheck.mjs Normal file
View file

@ -0,0 +1,9 @@
import WebSocket from "ws";
import { resolveName, CHIPNET_ELECTRUM } from "../../Argus/src/lib/resolver-web.js";
console.log("electrum pool:", CHIPNET_ELECTRUM.map((s) => (typeof s === "string" ? s : s.url)).join(", "));
for (const n of ["hello.bch","coinspectrum.bch","siatest.bch"]) {
const t=Date.now();
try { const e=await resolveName(n,{WebSocket}); console.log(` ${n}: ${e?("OK ["+Object.keys(e.records)+"] "+(Date.now()-t)+"ms"):"NXDOMAIN"}`); }
catch(err){ console.log(` ${n}: ERROR ${err.message} (${Date.now()-t}ms)`); }
}
process.exit(0);

82
dev/selftest.js Normal file
View file

@ -0,0 +1,82 @@
// Electron render harness — loads a bns:// name through the REAL serveBns handler
// (imported from main.js), lets its JS execute, then reports what actually
// rendered: title, image load status, stylesheet/script counts, visible text
// length, and any failed resource loads. Also writes render.html + render.png.
// This is the full-fidelity version of manual test #2/#3 — it runs the same
// resolver + gateway path the shipped app uses.
//
// Usage: npx electron dev/selftest.js <name> e.g. hello.bch / coinspectrum.deviant.bch
process.env.THESEUS_NO_AUTOSTART = "1";
const { app, BrowserWindow, protocol } = require("electron");
const { serveBns, nativeTld, registryOf } = require("../main.js");
const fs = require("fs");
const path = require("path");
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("no-sandbox");
const name = (process.argv[2] || "coinspectrum.deviant.bch").toLowerCase();
const settleMs = Number(process.env.THESEUS_SETTLE || 5000);
const outDir = path.join(__dirname, "..", "dev-out");
// hard watchdog so the harness can never hang a CI/agent run
const watchdog = setTimeout(() => { console.log("WATCHDOG: timed out"); try { app.exit(3); } catch {} }, 45000);
app.whenReady().then(async () => {
protocol.handle("bns", serveBns);
const win = new BrowserWindow({ width: 1200, height: 900, show: false, webPreferences: { offscreen: true } });
const wc = win.webContents;
const failed = [];
wc.on("did-fail-load", (_e, code, desc, url, isMainFrame) => { if (code !== -3) failed.push({ code, desc, url, isMainFrame }); });
const tld = nativeTld(name);
const report = { name, url: `bns://${name}/`, badge: tld ? `${registryOf(tld)} · .${tld}` : null };
try {
await wc.loadURL(`bns://${name}/`);
} catch (e) { report.loadError = e.message; }
await new Promise((r) => setTimeout(r, settleMs)); // let JS build the DOM + fetch data
try {
Object.assign(report, await wc.executeJavaScript(`(() => {
const imgs = [...document.images].map(i => {
const src = i.currentSrc || i.src || '';
return { src: src.slice(0, 90), ok: i.complete && i.naturalWidth > 0, local: src.startsWith('bns:') };
});
const brokenLocal = imgs.filter(i => !i.ok && i.local);
return {
title: document.title,
stylesheets: document.styleSheets.length,
scripts: document.scripts.length,
imgTotal: imgs.length,
imgBrokenLocal: brokenLocal.length, // served by us (bns://) — a real failure
imgBrokenExternal: imgs.filter(i => !i.ok && !i.local).length, // 3rd-party CDN — informational
brokenLocalImgs: brokenLocal.map(i => i.src),
textLen: (document.body ? document.body.innerText : '').trim().length,
h1: (document.querySelector('h1,h2') || {}).innerText || null,
};
})()`));
} catch (e) { report.evalError = e.message; }
report.failedLoads = failed;
fs.mkdirSync(outDir, { recursive: true });
try {
const html = await wc.executeJavaScript("document.documentElement.outerHTML");
fs.writeFileSync(path.join(outDir, "render.html"), html);
} catch {}
try {
const img = await wc.capturePage();
const png = img.toPNG();
if (png && png.length > 0) { fs.writeFileSync(path.join(outDir, "render.png"), png); report.screenshot = `dev-out/render.png (${png.length} bytes)`; }
else report.screenshot = "empty (offscreen not compositing)";
} catch (e) { report.screenshot = "error: " + e.message; }
console.log("\n===== SELFTEST REPORT =====");
console.log(JSON.stringify(report, null, 2));
// Verdict ignores external CDN images — only our own (bns://) assets + page loads count.
report.verdict = (!report.loadError && !report.imgBrokenLocal && report.textLen > 0) ? "PASS" : "FAIL";
clearTimeout(watchdog);
app.exit(report.verdict === "PASS" ? 0 : 2);
});

32
dev/test-directip.mjs Normal file
View file

@ -0,0 +1,32 @@
// Prove the DNS-independent path: a WS wrapper that FAILS for any hostname URL,
// so a successful .bch resolution can only have come from the pinned IP + SNI.
import WebSocket from "ws";
import { resolveName } from "../../Argus/src/lib/resolver-web.js";
const isIpUrl = (u) => /\/\/\d{1,3}(\.\d{1,3}){3}:/.test(u);
class DnsDeadWS extends WebSocket {
constructor(url, opts) {
if (isIpUrl(url)) {
console.log(" → connecting by IP:", url, "SNI:", opts?.servername);
super(url, opts);
} else {
console.log(" ✗ hostname blocked (simulated dead DNS):", url);
super("wss://127.0.0.1:1"); // unreachable → error → triggers IP fallback
}
}
}
console.log("resolving hello.bch with system DNS 'dead' (hostnames blocked)…");
try {
const entry = await resolveName("hello.bch", { WebSocket: DnsDeadWS, directIP: true });
if (entry) {
console.log("\n✅ RESOLVED via pinned IP — DNS-independent path works.");
console.log(" records:", Object.keys(entry.records).join(", "));
} else {
console.log("\n⚠ connected but hello.bch not found (name may be unregistered) — IP path still worked.");
}
} catch (e) {
console.log("\n❌ FAILED:", e.message);
}
process.exit(0);

59
home.html Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Theseus</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
background: radial-gradient(1000px 500px at 50% -10%, #16202e, #0b0e14);
color: #e7eaf1; display: flex; flex-direction: column; align-items: center; }
.hero { text-align: center; margin-top: 14vh; padding: 0 1rem; }
.mark { font-size: 64px; line-height: 1; }
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
h1 .g { color: #d6ff3d; }
.tag { color: #8b98a9; margin: 0 0 1.6rem; }
form { display: flex; gap: 8px; justify-content: center; max-width: 560px; margin: 0 auto; }
input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid #ffffff22;
background: #141a24; color: #e7eaf1; font-size: 15px; outline: none; }
input:focus { border-color: #4b7bec; }
button { padding: 13px 20px; border-radius: 999px; border: none; background: #4b7bec; color: #fff; font-size: 15px; cursor: pointer; }
.hint { color: #5e6678; font-size: 12.5px; margin-top: .8rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;
max-width: 720px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; }
.card { display: block; text-decoration: none; color: inherit; background: #141a24; border: 1px solid #ffffff12;
border-radius: 12px; padding: 14px 16px; transition: .15s; }
.card:hover { border-color: #4b7bec66; background: #18202c; transform: translateY(-1px); }
.card .n { font-weight: 600; color: #e7eaf1; }
.card .d { color: #8b98a9; font-size: 12.5px; margin-top: 3px; }
.badge { display: inline-block; font-size: 10.5px; padding: 1px 7px; border-radius: 999px; margin-top: 8px; }
.b-chain { background: #d6ff3d22; color: #d6ff3d; } .b-sia { background: #b39ddb22; color: #b39ddb; }
.b-srv { background: #4fd1a522; color: #4fd1a5; }
footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; }
footer b { color: #6b7688; }
</style></head>
<body>
<div class="hero">
<div class="mark">⛓️</div>
<h1>Theseus <span class="g">Navigator</span></h1>
<p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p>
<form action="https://duckduckgo.com/" method="GET">
<input name="q" placeholder="Search the web (DuckDuckGo)" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p>
</div>
<div class="grid">
<a class="card" href="https://hello.bch/"><div class="n">hello.bch</div><div class="d">The page hosted on the blockchain itself.</div><span class="badge b-chain">on-chain</span></a>
<a class="card" href="https://theseus.bch/"><div class="n">theseus.bch</div><div class="d">This browser's own name, on-chain.</div><span class="badge b-chain">on-chain</span></a>
<a class="card" href="https://silentmode.bch/"><div class="n">silentmode.bch</div><div class="d">The division building this stack.</div><span class="badge b-chain">on-chain</span></a>
<a class="card" href="https://argo.bch/"><div class="n">argo.bch</div><div class="d">The host network, crewed by Argonauts.</div><span class="badge b-chain">on-chain</span></a>
<a class="card" href="https://coinspectrum.deviant.bch/"><div class="n">coinspectrum.deviant.bch</div><div class="d">A full site served from the Sia network.</div><span class="badge b-sia">Sia</span></a>
<a class="card" href="https://coinspectrum.bch/"><div class="n">coinspectrum.bch</div><div class="d">The same site, from a direct server.</div><span class="badge b-srv">server</span></a>
<a class="card" href="https://faucet.deviant.bch/"><div class="n">faucet.deviant.bch</div><div class="d">A testnet faucet hub, on Sia.</div><span class="badge b-sia">Sia</span></a>
<a class="card" href="https://siatest.bch/"><div class="n">siatest.bch</div><div class="d">A page with no server at all.</div><span class="badge b-sia">Sia</span></a>
</div>
<footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer>
</body>
</html>

586
main.js Normal file
View file

@ -0,0 +1,586 @@
// Theseus Navigator — Electron main process.
// Native .bch via a custom `bns://` protocol: resolves names with the shared
// portable resolver (Argus/resolver-web.js) and serves content itself (on-chain
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard } = require("electron");
const path = require("path");
const http = require("http");
const https = require("https");
const { spawn } = require("child_process");
const fs = require("fs");
const WebSocket = require("ws");
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
// run from inside app.asar); dev runs read them from the repo.
const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
// Bundled as .mjs so it loads as ES module in the packaged app (no package.json
// sits next to it in resources/, so a bare .js would be treated as CommonJS and
// fail on `export`). Dev reads the engine copy directly (Argus is type:module).
const RESOLVER = app.isPackaged
? path.join(RES_DIR, "resolver-web.mjs")
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
const SEARCH = (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q);
// Public content relay (secret-free): serves s3/ip/h/u without shipping keys.
const GATEWAY = "https://navigate.st";
// ---- BNS name detection (multi-TLD) --------------------------------------
// The engine (resolver-web.js) resolves any <label>.<tld> from the BCNR beacon.
// Two client-side sets decide how Theseus treats a name:
// NATIVE — not in the ICANN root, so we own them outright (go straight to BCNR).
// DUAL — real ICANN TLDs we ALSO offer on BCNR. These never hijack the real
// web: the ICANN site loads normally, and if a BCNR name also exists
// we surface a passive "also on BCNR" switch (see navigateTab).
const BNS_NATIVE_TLDS = new Set(["bch", "p2p", "bit", "nav"]);
const BNS_DUAL_TLDS = new Set(["de", "dev", "ltd"]);
const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = (host) => {
const h = String(host).toLowerCase().replace(/\.$/, "");
const dot = h.lastIndexOf(".");
return dot < 0 ? null : h.slice(dot + 1);
};
const nativeTld = (host) => { const t = tldOf(host); return t && BNS_NATIVE_TLDS.has(t) ? t : null; };
const dualTld = (host) => { const t = tldOf(host); return t && BNS_DUAL_TLDS.has(t) ? t : null; };
// Kept for will-navigate: only native BNS hosts are intercepted as BCNR up front.
const isBnsHost = (host) => nativeTld(host) !== null;
const registryOf = (_tld) => REGISTRY;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
// what mainstream browsers do — anything with whitespace, or a bare word with no
// dot, is a search; a scheme, an IP, localhost, or a dotted host is a URL.
function looksLikeUrl(q) {
if (!q) return false;
if (/\s/.test(q)) return false; // has whitespace -> search
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(q)) return true; // scheme://…
if (/^localhost(:\d+)?([/?#]|$)/i.test(q)) return true; // localhost[:port]
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/.test(q)) return true; // IPv4[:port]
const host = q.split(/[/?#]/)[0]; // strip path/query/frag
return host.includes(".") && !host.startsWith(".") && !host.endsWith("."); // dotted host
}
// ---- persistent user settings (userData/settings.json) ----
const SETTINGS_DEFAULTS = {
webrtcProtect: true, // reduce WebRTC IP leaks even without Tor (Tor strengthens it)
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
blockMicrophone: true, // deny microphone by default (also hides mic labels)
blockLocation: true, // deny geolocation by default
restoreSession: true, // reopen last session's tabs on launch
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
timezoneMode: "show", // show (real) | hide (UTC) | change (timezoneValue)
timezoneValue: "UTC", // IANA zone used when timezoneMode === "change"
languageMode: "show", // show (real) | hide (en-US) | change (languageValue)
languageValue: "en-US", // locale used when languageMode === "change"
};
let settings = { ...SETTINGS_DEFAULTS };
const settingsFile = () => path.join(app.getPath("userData"), "settings.json");
function loadSettings() {
try { if (fs.existsSync(settingsFile())) settings = { ...SETTINGS_DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), "utf8")) }; }
catch (e) { console.error("settings load failed:", e.message); }
}
function saveSettings() {
try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); }
}
// WebRTC IP-handling policy: protect by default (independent of Tor), strongest under Tor.
function webrtcPolicy() {
if (!settings.webrtcProtect) return "default";
return torState === "on" ? "disable_non_proxied_udp" : "default_public_interface_only";
}
// ---- anti-fingerprinting: timezone + language (Show / Hide / Change) ----
// Effective override, or null = "show" (report the real value).
function effTimezone() {
if (settings.timezoneMode === "hide") return "UTC";
if (settings.timezoneMode === "change") return settings.timezoneValue || "UTC";
return null;
}
function effLocale() {
if (settings.languageMode === "hide") return "en-US";
if (settings.languageMode === "change") return settings.languageValue || "en-US";
return null;
}
// Applied per tab via CDP — the engine-level override the Tor/Mullvad browsers do:
// timezone -> Intl/Date; locale -> Intl + navigator.language(s).
async function applyFingerprint(wc) {
try {
if (!wc.debugger.isAttached()) wc.debugger.attach("1.3");
const tz = effTimezone();
await wc.debugger.sendCommand("Emulation.setTimezoneOverride", { timezoneId: tz || "" });
const loc = effLocale();
await wc.debugger.sendCommand("Emulation.setLocaleOverride", loc ? { locale: loc } : {});
// setLocaleOverride covers Intl but NOT navigator.language(s) — inject a getter.
await wc.debugger.sendCommand("Page.enable");
if (wc._langScript) {
try { await wc.debugger.sendCommand("Page.removeScriptToEvaluateOnNewDocument", { identifier: wc._langScript }); } catch {}
wc._langScript = null;
}
if (loc) {
const langs = JSON.stringify([loc, loc.split("-")[0]]);
const src = `Object.defineProperty(navigator,'language',{get:()=>${JSON.stringify(loc)},configurable:true});` +
`Object.defineProperty(navigator,'languages',{get:()=>${langs},configurable:true});`;
const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src });
wc._langScript = res.identifier;
try { await wc.executeJavaScript(src); } catch {} // apply to the current page too
}
} catch { /* debugger busy (e.g. devtools) — best effort */ }
}
function applyFingerprintAll() { for (const t of tabs) applyFingerprint(t.view.webContents); }
// Accept-Language header follows the locale setting (session-wide, best effort).
function applyAcceptLanguage() {
const loc = effLocale() || app.getLocale() || "en-US";
try {
const ua = session.defaultSession.getUserAgent();
session.defaultSession.setUserAgent(ua, `${loc},${loc.split("-")[0]};q=0.8`);
} catch {}
}
// ---- session restore + background throttling ----
const sessionFile = () => path.join(app.getPath("userData"), "session.json");
function saveSession() {
try { fs.writeFileSync(sessionFile(), JSON.stringify(tabs.filter((t) => !t.settings && t.url).map((t) => t.url))); }
catch (e) { console.error("session save failed:", e.message); }
}
function loadSession() {
try { if (fs.existsSync(sessionFile())) return JSON.parse(fs.readFileSync(sessionFile(), "utf8")); } catch {}
return [];
}
function applyThrottle() {
for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} }
}
// Privacy-first permissions: Electron auto-grants everything by default. Deny the
// sensitive ones (camera/mic/geolocation/device access) — this also hides real
// media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied.
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
// A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed(kinds) {
if (kinds.includes("video") && settings.blockCamera) return false;
if (kinds.includes("audio") && settings.blockMicrophone) return false;
return true;
}
function applyPermissions() {
const ses = session.defaultSession;
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
if (permission === "geolocation") return callback(!settings.blockLocation);
if (SENSITIVE_DEVICE.has(permission)) return callback(false);
callback(true); // benign UX permissions (fullscreen, pointerLock, …)
});
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
if (permission === "media") {
if (details?.mediaType === "video") return !settings.blockCamera;
if (details?.mediaType === "audio") return !settings.blockMicrophone;
return !(settings.blockCamera && settings.blockMicrophone);
}
if (permission === "geolocation") return !settings.blockLocation;
if (SENSITIVE_DEVICE.has(permission)) return false;
return true;
});
}
protocol.registerSchemesAsPrivileged([
{ scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
]);
let resolver;
async function getResolver() {
if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`);
return resolver;
}
// ---- Tor (optional onion routing, toggled from the UI) ----
// IP privacy, not full anonymity: this browser can still be fingerprinted.
const TOR_PORT = 9152;
const TOR_BIN = path.join(RES_DIR, "tor", "tor", "tor.exe");
const TOR_GEOIP = path.join(RES_DIR, "tor", "data", "geoip");
const TOR_GEOIP6 = path.join(RES_DIR, "tor", "data", "geoip6");
let torProc = null, torState = "off";
let torWsAgent = null;
let SocksProxyAgent;
async function loadSocks() { if (!SocksProxyAgent) ({ SocksProxyAgent } = await import("socks-proxy-agent")); }
function sendTor() { try { chrome?.webContents.send("tor", { state: torState }); } catch {} }
async function startTor() {
if (torProc) return;
torState = "connecting"; sendTor();
await loadSocks();
const dataDir = path.join(app.getPath("userData"), "tor-data");
torProc = spawn(TOR_BIN, ["--SocksPort", String(TOR_PORT), "--ControlPort", "0",
"--DataDirectory", dataDir, "--GeoIPFile", TOR_GEOIP, "--GeoIPv6File", TOR_GEOIP6], { windowsHide: true });
torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); });
torProc.stderr.on("data", () => {});
torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); });
}
function torReady() {
torState = "on";
torWsAgent = new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`);
session.defaultSession.setProxy({ proxyRules: `socks5://127.0.0.1:${TOR_PORT}` });
applyWebRTCPolicy();
sendTor();
}
function torOff() {
torState = "off"; torWsAgent = null;
session.defaultSession.setProxy({ proxyRules: "" });
applyWebRTCPolicy();
sendTor();
}
function stopTor() { torOff(); if (torProc) { try { torProc.kill(); } catch {} torProc = null; } }
// While Tor is on, stop WebRTC from leaking the real IP around the SOCKS proxy
// (STUN/UDP bypasses an HTTP/SOCKS proxy — plain Electron doesn't block it the
// way the Tor Browser does). This is the usual reason a site still sees your IP.
function applyWebRTCPolicy() {
const policy = webrtcPolicy();
for (const t of tabs) { try { t.view.webContents.setWebRTCIPHandlingPolicy(policy); } catch {} }
}
class TorWebSocket extends WebSocket { constructor(url, opts) { super(url, { agent: torWsAgent, ...opts }); } }
const currentWS = () => (torState === "on" ? TorWebSocket : WebSocket);
function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const lib = u.protocol === "https:" ? https : http;
const req = lib.request(u, { method, headers, agent }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) }));
});
req.on("error", reject); req.end();
});
}
async function contentFetch(url, init = {}) {
if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); }
const r = await fetch(url, init);
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) };
}
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
// on-chain ELECTRUM_LIST_NAME record so the pool can be rotated without a new
// build. The last discovered list is cached to disk and tried first next launch.
let electrumPool = null;
let lastElectrumRefresh = 0;
const electrumFile = () => path.join(app.getPath("userData"), "electrum-servers.json");
const serverKey = (s) => (typeof s === "string" ? s : s && s.url);
function mergeServers(preferred, rest) {
const seen = new Set(), out = [];
for (const s of [...(preferred || []), ...(rest || [])]) {
const k = serverKey(s);
if (k && !seen.has(k)) { seen.add(k); out.push(s); }
}
return out;
}
async function initElectrumPool() {
const { CHIPNET_ELECTRUM } = await getResolver();
let saved = [];
try { if (fs.existsSync(electrumFile())) saved = JSON.parse(fs.readFileSync(electrumFile(), "utf8")); } catch {}
electrumPool = mergeServers(saved, CHIPNET_ELECTRUM); // discovered first, seed always kept
}
async function refreshElectrumPool() {
try {
const { fetchElectrumServers } = await getResolver();
const found = await fetchElectrumServers({ WebSocket: currentWS(), directIP: true, electrum: electrumPool });
if (found && found.length) {
electrumPool = mergeServers(found, electrumPool);
try { fs.writeFileSync(electrumFile(), JSON.stringify(found, null, 2)); } catch {}
}
} catch { /* list unpublished or unreachable — keep the current pool */ }
}
function maybeRefreshElectrum() {
if (Date.now() - lastElectrumRefresh < 30 * 60 * 1000) return;
lastElectrumRefresh = Date.now();
refreshElectrumPool(); // fire-and-forget
}
const entries = new Map();
async function resolveHost(host) {
const { resolveName } = await getResolver();
if (!electrumPool) await initElectrumPool();
// Pass the full host; the engine normalizes any <sub>.<label>.<tld> itself.
// directIP: dial pinned electrum IPs when system DNS is dead (desktop lifeboat).
const entry = await resolveName(host, { WebSocket: currentWS(), directIP: true, electrum: electrumPool });
if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() });
maybeRefreshElectrum();
return entry;
}
const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml",
ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" };
const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
async function serveBns(request) {
const url = new URL(request.url);
const host = url.hostname.toLowerCase();
const reqPath = decodeURIComponent(url.pathname) || "/";
let rec = entries.get(host);
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
const r = rec.entry.records;
try {
if (r.h) { if (reqPath === "/") return new Response(r.h, { headers: { "content-type": "text/html; charset=utf-8" } }); return new Response("not found", { status: 404 }); }
if (r.s3) {
// Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build.
const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {});
const ct = up.contentType && up.contentType !== "application/octet-stream"
? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath);
let body = up.buffer;
if (ct.includes("text/html")) {
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
// resolve against the bns:// origin, not back through the relay.
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
}
return new Response(body, { status: up.status, headers: { "content-type": ct } });
}
if (r.ip) {
const up = await contentFetch(`http://${r.ip}${reqPath}${url.search}`, { headers: { host } });
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } });
}
if (r.u) return Response.redirect(r.u, 302);
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { return new Response("Theseus error: " + e.message, { status: 502 }); }
}
// ---- window + tabs ----
let win, chrome, statusbar;
let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer / site info) is shown
const STATUS_H = 24; // fixed bottom resolver/provenance line
const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id);
const activeTab = () => tabById(activeId);
// Provenance goes to BOTH the top chrome (registry badge + site-info panel) and
// the bottom status line, so the resolver detail lives on the bottom bar.
function pushNav(prov) {
chrome?.webContents.send("nav", prov);
statusbar?.webContents.send("nav", prov);
}
function layout() {
if (!win) return;
const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H - STATUS_H);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH });
statusbar?.setBounds({ x: 0, y: height - STATUS_H, width, height: STATUS_H });
}
function setActive(id) {
activeId = id;
for (const t of tabs) t.view.setVisible(t.id === id);
const t = activeTab();
if (t?.prov) pushNav(t.prov);
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
emitTabs();
}
function emitTabs() {
const t = activeTab();
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId })),
url: t?.url || "",
canBack: wc ? wc.navigationHistory.canGoBack() : false,
canForward: wc ? wc.navigationHistory.canGoForward() : false,
});
}
function loadHome(id) {
const t = tabById(id); if (!t) return;
t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" };
t.view.webContents.loadFile("home.html");
if (id === activeId) pushNav(t.prov);
emitTabs();
}
function createTab(initial, opts = {}) {
const id = ++tabSeq;
const view = new WebContentsView(opts.settings ? { webPreferences: { preload: path.join(__dirname, "settings-preload.js") } } : {});
const wc = view.webContents;
try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {}
try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {}
applyFingerprint(wc);
const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", prov: null, settings: !!opts.settings };
tabs.push(tab);
win.contentView.addChildView(view);
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
wc.on("did-navigate", () => emitTabs());
wc.on("did-navigate-in-page", () => emitTabs());
wc.on("will-navigate", (e, u) => {
try {
const parsed = new URL(u);
if (parsed.protocol === "bns:") return;
if (isBnsHost(parsed.hostname)) { e.preventDefault(); navigateTab(id, parsed.hostname + parsed.pathname); }
} catch {}
});
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => {
if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab" });
return { action: "deny" };
});
// Right-click context menu.
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in new tab", click: () => createTab(p.linkURL) },
{ label: "Open link in new background tab", click: () => createTab(p.linkURL, { background: true }) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
items.push(
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
{ label: "Reload", click: () => wc.reload() },
);
Menu.buildFromTemplate(items).popup();
});
layout();
if (opts.background) { view.setVisible(false); emitTabs(); }
else setActive(id);
if (opts.settings) {
tab.prov = { host: "", kind: "home" };
wc.loadFile("settings.html");
if (id === activeId) pushNav(tab.prov);
emitTabs();
} else if (initial) navigateTab(id, initial);
else loadHome(id);
return id;
}
function closeTab(id) {
const i = tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const [t] = tabs.splice(i, 1);
win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.();
if (tabs.length === 0) { createTab(); return; }
if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id);
else emitTabs();
}
function createWindow() {
win = new BrowserWindow({ width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: "#0f1420" });
chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
win.contentView.addChildView(chrome);
chrome.webContents.loadFile("chrome.html");
// Bottom resolver/provenance line (trusted chrome, painted by the browser).
statusbar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } });
win.contentView.addChildView(statusbar);
statusbar.webContents.loadFile("statusbar.html");
// The bottom bar loads async; once ready, paint it with the active tab's state.
statusbar.webContents.once("did-finish-load", () => { const t = activeTab(); if (t?.prov) statusbar.webContents.send("nav", t.prov); });
chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
});
win.on("resize", layout);
layout();
}
async function navigateTab(id, input) {
const t = tabById(id); if (!t) return;
let q = String(input).trim();
if (!q) return;
// Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search.
if (!looksLikeUrl(q)) q = SEARCH(q);
const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase();
const rest = raw.slice(host.length) || "/";
const native = nativeTld(host);
const dual = dualTld(host);
// Every fresh navigation clears any stale "also on BCNR" offer for this tab.
t.nav = (t.nav || 0) + 1;
const navId = t.nav;
t.bcnrOffer = null;
if (id === activeId) chrome.webContents.send("bcnr-offer", null);
// Native BCNR TLD (.bch/.p2p/.nav): resolve from the registry outright.
if (native) return loadBns(t, id, host, rest, native);
// Ordinary web — and dual-use ICANN TLDs (.de/.dev/.ltd) — load the REAL site
// immediately. We never hijack or delay the clearnet web.
t.url = q.includes("://") ? q : "https://" + q;
await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web" };
if (id === activeId) pushNav(t.prov);
emitTabs();
// Dual-use: in parallel, check whether the name also exists on BCNR. If it
// does, offer a passive switch — the user opts in; nothing is forced.
if (dual) {
resolveHost(host).then((entry) => {
if (entry && tabById(id) === t && t.nav === navId) {
t.bcnrOffer = { host, rest, tld: dual };
if (id === activeId) chrome.webContents.send("bcnr-offer", { host, tld: dual, registry: REGISTRY });
}
}).catch(() => {});
}
}
// Load a name from BCNR into a tab — used by native TLDs and by an accepted
// dual-use switch from the "also on BCNR" bar.
async function loadBns(t, id, host, rest, tld) {
const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
let entry;
try { entry = await resolveHost(host); }
catch (e) { t.prov = { host, kind: "error", error: e.message, tld, registry }; if (id === activeId) pushNav(t.prov); return; }
t.url = host + (rest === "/" ? "" : rest);
if (!entry) {
t.prov = { host, kind: "nxdomain", tld, registry };
await t.view.webContents.loadURL(`bns://${host}/`);
if (id === activeId) pushNav(t.prov); emitTabs(); return;
}
await t.view.webContents.loadURL(`bns://${host}${rest}`);
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
if (id === activeId) pushNav(t.prov);
emitTabs();
}
ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input));
ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("close-tab", (_e, id) => closeTab(id));
ipcMain.handle("switch-tab", (_e, id) => setActive(id));
ipcMain.handle("go-home", () => loadHome(activeId));
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
ipcMain.handle("reload", () => activeTab()?.view.webContents.reload());
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
ipcMain.handle("settings-get", () => settings);
ipcMain.handle("settings-set", (_e, key, val) => {
if (key in SETTINGS_DEFAULTS) { settings[key] = val; saveSettings(); }
if (key === "webrtcProtect") applyWebRTCPolicy();
if (key === "backgroundThrottle") applyThrottle();
if (["timezoneMode", "timezoneValue", "languageMode", "languageValue"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); }
return settings;
});
ipcMain.handle("set-chrome-height", (_e, h) => {
const next = Math.max(74, Math.min(260, Math.round(h) || 84));
if (next !== CHROME_H) { CHROME_H = next; layout(); }
});
ipcMain.handle("switch-to-bcnr", () => {
const t = activeTab(); if (!t || !t.bcnrOffer) return;
const { host, rest, tld } = t.bcnrOffer;
t.bcnrOffer = null;
chrome.webContents.send("bcnr-offer", null);
return loadBns(t, activeId, host, rest || "/", tld);
});
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
if (!process.env.THESEUS_NO_AUTOSTART) {
app.whenReady().then(() => {
loadSettings();
applyPermissions();
applyAcceptLanguage();
protocol.handle("bns", serveBns);
createWindow();
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on("before-quit", () => { saveSession(); stopTor(); });
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
}
module.exports = { serveBns, resolveHost, isBnsHost, nativeTld, dualTld, registryOf };

5347
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

57
package.json Normal file
View file

@ -0,0 +1,57 @@
{
"name": "theseus-navigator",
"version": "0.0.1",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",
"type": "commonjs",
"scripts": {
"start": "electron .",
"resolve-test": "node resolve-test.mjs",
"dist": "electron-builder --win nsis portable"
},
"dependencies": {
"fetch-socks": "^1.3.3",
"socks-proxy-agent": "^10.1.0",
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "st.silentmode.theseus",
"productName": "Theseus Navigator",
"artifactName": "TheseusNavigator-${version}-${arch}.${ext}",
"directories": { "output": "dist-public" },
"asar": true,
"files": [
"main.js",
"preload.js",
"chrome.html",
"home.html",
"settings.html",
"settings-preload.js",
"package.json",
"node_modules/**/*",
"!**/*.md",
"!**/*.map",
"!tor${/*}",
"!dist-public${/*}",
"!resolve-test.mjs",
"!*PROMPT.md"
],
"extraResources": [
{ "from": "tor", "to": "tor" },
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" }
],
"win": { "target": ["nsis", "portable"] },
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "TheseusNavigator-Setup-${version}.${ext}"
},
"portable": { "artifactName": "TheseusNavigator-${version}-portable.${ext}" }
}
}

20
preload.js Normal file
View file

@ -0,0 +1,20 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("theseus", {
navigate: (input) => ipcRenderer.invoke("navigate", input),
search: (q) => ipcRenderer.invoke("search", q),
newTab: () => ipcRenderer.invoke("new-tab"),
closeTab: (id) => ipcRenderer.invoke("close-tab", id),
switchTab: (id) => ipcRenderer.invoke("switch-tab", id),
goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"),
forward: () => ipcRenderer.invoke("go-forward"),
reload: () => ipcRenderer.invoke("reload"),
toggleTor: () => ipcRenderer.invoke("toggle-tor"),
openSettings: () => ipcRenderer.invoke("open-settings"),
switchToBcnr: () => ipcRenderer.invoke("switch-to-bcnr"),
setChromeHeight: (h) => ipcRenderer.invoke("set-chrome-height", h),
onNav: (cb) => ipcRenderer.on("nav", (_e, d) => cb(d)),
onTor: (cb) => ipcRenderer.on("tor", (_e, d) => cb(d)),
onTabs: (cb) => ipcRenderer.on("tabs", (_e, d) => cb(d)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),
});

5
settings-preload.js Normal file
View file

@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("cfg", {
get: () => ipcRenderer.invoke("settings-get"),
set: (key, value) => ipcRenderer.invoke("settings-set", key, value),
});

130
settings.html Normal file
View file

@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Theseus — Settings</title>
<style>
:root{ color-scheme: dark; --bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; }
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:radial-gradient(900px 480px at 50% -10%,#16202e,var(--bg));
color:var(--ink);font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
.wrap{max-width:720px;margin:0 auto;padding:2.6rem 1.4rem 4rem}
h1{font-size:1.6rem;margin:0 0 .2rem;letter-spacing:.3px}
h1 .g{color:var(--acid)}
.lede{color:var(--mut);margin:0 0 2rem;font-size:14px}
.group{margin:0 0 1.8rem}
.group h2{font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin:0 0 .7rem;padding-left:2px}
.row{display:flex;align-items:center;gap:16px;background:var(--panel);border:1px solid var(--line);
border-radius:12px;padding:15px 18px;margin-bottom:10px}
.row .txt{flex:1}
.row .t{font-weight:600;color:var(--ink)}
.row .d{color:var(--mut);font-size:13px;margin-top:2px}
.row.soon{opacity:.55}
/* toggle */
.sw{position:relative;width:46px;height:26px;flex:none;cursor:pointer}
.sw input{opacity:0;width:0;height:0}
.track{position:absolute;inset:0;background:#2b3444;border:1px solid var(--line);border-radius:999px;transition:.15s}
.knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#8b98a9;transition:.15s}
.sw input:checked + .track{background:rgba(214,255,61,.25);border-color:#d6ff3d55}
.sw input:checked + .track .knob{transform:translateX(20px);background:var(--acid)}
.ctl{display:flex;flex-direction:column;gap:6px;align-items:flex-end;flex:none}
select,.ctl input{background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;
padding:7px 10px;font-size:13px;outline:none}
select:focus,.ctl input:focus{border-color:#4b7bec}
.ctl input{width:160px}
.note{color:var(--dim);font-size:12.5px;margin-top:1.4rem;border-top:1px solid var(--line);padding-top:1rem}
.badge{display:inline-block;font-size:10px;letter-spacing:.05em;text-transform:uppercase;color:var(--dim);
border:1px solid var(--line);border-radius:999px;padding:1px 8px;margin-left:8px}
</style></head>
<body>
<div class="wrap">
<h1>⚙ Theseus <span class="g">Settings</span></h1>
<p class="lede">Changes apply immediately and are saved for next time.</p>
<div class="group">
<h2>Privacy</h2>
<div class="row">
<div class="txt"><div class="t">WebRTC leak protection</div>
<div class="d">Stops sites from discovering your real IP through WebRTC. Strongest with Tor on. On by default.</div></div>
<label class="sw"><input type="checkbox" id="webrtcProtect"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Block camera</div>
<div class="d">Deny camera access by default — also hides your camera's name from fingerprinting.</div></div>
<label class="sw"><input type="checkbox" id="blockCamera"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Block microphone</div>
<div class="d">Deny microphone access by default — also hides your mic's name from fingerprinting.</div></div>
<label class="sw"><input type="checkbox" id="blockMicrophone"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Block location</div>
<div class="d">Deny geolocation requests by default.</div></div>
<label class="sw"><input type="checkbox" id="blockLocation"><span class="track"><span class="knob"></span></span></label>
</div>
</div>
<div class="group">
<h2>General</h2>
<div class="row">
<div class="txt"><div class="t">Reopen tabs on launch</div>
<div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</div>
</div>
<div class="group">
<h2>Performance</h2>
<div class="row">
<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>
<div class="group">
<h2>Anti-fingerprinting</h2>
<div class="row">
<div class="txt"><div class="t">Timezone</div>
<div class="d">What sites read via JavaScript (Intl / Date). <b>Hide</b> reports UTC; <b>Change</b> lets you set your own.</div></div>
<div class="ctl">
<select id="timezoneMode"><option value="show">Show real</option><option value="hide">Hide (UTC)</option><option value="change">Change…</option></select>
<input id="timezoneValue" placeholder="e.g. Europe/Berlin" hidden>
</div>
</div>
<div class="row">
<div class="txt"><div class="t">Language</div>
<div class="d">navigator.language and the Accept-Language header. <b>Hide</b> reports en-US; <b>Change</b> lets you set your own.</div></div>
<div class="ctl">
<select id="languageMode"><option value="show">Show real</option><option value="hide">Hide (en-US)</option><option value="change">Change…</option></select>
<input id="languageValue" placeholder="e.g. de-DE" hidden>
</div>
</div>
</div>
<div class="note">Theseus is honest about its limits: these reduce tracking and hide your IP, but a custom
browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.</div>
</div>
<script>
const C = window.cfg;
const KEYS = ["webrtcProtect", "blockCamera", "blockMicrophone", "blockLocation", "restoreSession", "backgroundThrottle"];
C.get().then((s) => {
for (const k of KEYS) {
const el = document.getElementById(k);
if (!el) continue;
el.checked = !!s[k];
el.addEventListener("change", () => C.set(k, el.checked));
}
// Show/Hide/Change selectors with a value field revealed on "change"
for (const [mode, val] of [["timezoneMode", "timezoneValue"], ["languageMode", "languageValue"]]) {
const m = document.getElementById(mode), v = document.getElementById(val);
m.value = s[mode] || "show";
v.value = s[val] || "";
v.hidden = m.value !== "change";
m.addEventListener("change", () => { C.set(mode, m.value); v.hidden = m.value !== "change"; });
v.addEventListener("change", () => C.set(val, v.value.trim()));
}
});
</script>
</body>
</html>

57
statusbar.html Normal file
View file

@ -0,0 +1,57 @@
<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
html, body { margin: 0; height: 100%; }
body { display: flex; align-items: center; gap: 8px; padding: 0 12px;
background: #0b0f18; color: #8b93a7; font-size: 11.5px; white-space: nowrap;
overflow: hidden; border-top: 1px solid #ffffff10; user-select: none; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #5e6678; flex: none; }
.dot.chain { background: #d6ff3d; } .dot.sia { background: #b39ddb; }
.dot.server { background: #4fd1a5; } .dot.web { background: #8b93a7; } .dot.err { background: #f6768a; }
#host { color: #e7eaf1; }
#src { color: #bfeae4; }
.reg { color: #d6ff3d; }
.sep { color: #3a4152; }
#records { color: #6f7789; font-family: ui-monospace, monospace; }
#cert { color: #6f7789; margin-left: auto; font-family: ui-monospace, monospace;
overflow: hidden; text-overflow: ellipsis; }
</style></head>
<body>
<span class="dot" id="dot"></span>
<span id="host"></span>
<span id="sep1" class="sep" hidden>·</span>
<span id="src"></span>
<span id="sep2" class="sep" hidden>·</span>
<span class="reg" id="reg"></span>
<span id="records"></span>
<span id="cert"></span>
<script>
const $ = (id) => document.getElementById(id);
function clear() { for (const id of ["host","src","reg","records","cert"]) $(id).textContent = ""; $("sep1").hidden = $("sep2").hidden = true; }
window.theseus.onNav((d) => {
const dot = $("dot"); dot.className = "dot";
clear();
if (!d || d.kind === "home") { $("host").textContent = "Theseus — decentralized web navigator"; return; }
if (d.kind === "resolving") { $("host").textContent = "resolving " + d.host + " on the BCH chain…"; return; }
if (d.kind === "nxdomain") { dot.classList.add("err"); $("host").textContent = d.host + " → not registered on chain (NXDOMAIN)"; return; }
if (d.kind === "error") { dot.classList.add("err"); $("host").textContent = "error: " + (d.error || "resolution failed"); return; }
if (d.kind === "web") {
dot.classList.add("web");
$("host").textContent = d.host || "";
$("sep1").hidden = false; $("src").textContent = "web (ICANN DNS)";
return;
}
// kind === "ok": a resolved BNS name
const cls = (d.source || "").includes("chain") ? "chain" : (d.source || "").includes("Sia") ? "sia" : (d.source || "").includes("server") ? "server" : "web";
dot.classList.add(cls);
$("host").textContent = d.host || "";
$("sep1").hidden = false; $("src").textContent = "served from " + (d.source || "chain");
if (d.registry || d.tld) { $("sep2").hidden = false; $("reg").textContent = (d.registry || "BCNR") + (d.tld ? " ·." + d.tld : ""); }
if (d.records && d.records.length) $("records").textContent = " [" + d.records.join(",") + "]";
if (d.category) $("cert").textContent = "cert " + String(d.category).slice(0, 16) + "…";
});
</script>
</body>
</html>