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