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:
commit
7898e78ff4
19 changed files with 7023 additions and 0 deletions
76
BROWSER-PROMPT.md
Normal file
76
BROWSER-PROMPT.md
Normal 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
91
PACKAGING-PROMPT.md
Normal 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
27
README.md
Normal 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
93
RELEASE-HANDOFF.md
Normal 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
222
chrome.html
Normal 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 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,"<")}</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,"<")}</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,"<")}</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,"<")}`);
|
||||||
|
} 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,"<")}</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, "<")}</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
44
dev/README.md
Normal 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
65
dev/probe-site.mjs
Normal 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
21
dev/registry-decide.mjs
Normal 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
9
dev/rescheck.mjs
Normal 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
82
dev/selftest.js
Normal 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
32
dev/test-directip.mjs
Normal 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
59
home.html
Normal 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
586
main.js
Normal 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
5347
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
package.json
Normal file
57
package.json
Normal 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
20
preload.js
Normal 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
5
settings-preload.js
Normal 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
130
settings.html
Normal 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
57
statusbar.html
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue