7 Commits

Author SHA1 Message Date
Fredrik Johansson
cb80040ddb chore: gitignore web/public/config.js; fix netHash to use WebCrypto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:21:12 +02:00
Fredrik Johansson
f7047b7bfe fix: align offer/answer tiebreak with yaw2/browser (lower ID offers)
The Go daemon was using higher-ID-offers while the browser adapter and
yaw2 use lower-ID-offers. This meant daemon↔browser pairs where the
daemon had the lower ID would never connect — neither side would offer.

All three comparison sites in anchor/client.go flipped: > 0 → < 0,
<= 0 → >= 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:47:13 +02:00
Fredrik Johansson
d529f58ddc Update README: hosting guide, browser vs daemon mode, NPM setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:57:44 +02:00
Fredrik Johansson
bbd78ac4de Remove hardcoded anchor URL fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:21 +02:00
Fredrik Johansson
de8d3ff70d Remove unsolicited deploy/ and config.example.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:00 +02:00
Fredrik Johansson
739c63f6b3 Add deploy/ with nginx config and web deploy script
nginx-waste.conf: serve web/dist/ at root, proxy /ws to anchor (port
8080), SPA fallback for clean invite URLs, immutable cache for assets.

deploy-web.sh: build + rsync to anchor host, skip config.js so the
host's runtime config is not clobbered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:44:46 +02:00
Fredrik Johansson
5bc16daae1 Add standalone browser mode: no daemon required
BrowserAdapter speaks yaw/2.1 signaling + WebRTC protocol entirely in
the browser (libsodium-wrappers for Ed25519/X25519). Identity stored in
localStorage; peers connect via any waste/yaw2 anchor.

When served from a non-localhost origin the app defaults to browser
mode. On localhost the daemon adapter is tried first with a one-click
switch to browser mode. config.js (gitignored, served by anchor) lets
the host pre-inject signalURL and other defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 00:06:41 +02:00
10 changed files with 1035 additions and 233 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ launch-tui.sh
*.swp
/tui
launch-web.sh
web/public/config.js

376
README.md
View File

@@ -9,7 +9,7 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
waste-go/
├── cmd/
│ ├── daemon/ The peer process — run one on each friend's machine
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS
│ ├── anchor/ WebSocket signaling server — run this on your VPS
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
└── internal/
├── proto/ All wire types (shared by daemon and anchor)
@@ -19,235 +19,225 @@ waste-go/
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
```
## Prerequisites
---
- Go 1.24+ → https://go.dev/dl/
- VS Code with the Go extension (`golang.go`)
## Hosting on a VPS
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them.
You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
## Getting started
### 1. Build and run the anchor
```bash
# Fetch dependencies
go mod tidy
# On your local machine — cross-compile for Linux
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
# Build everything (confirms it compiles)
go build ./...
# Copy to VPS
scp bin/waste-anchor user@your-vps:~/waste-anchor
```
# Terminal 1 — anchor (required for peers to find each other)
On the VPS, run the anchor and keep it alive (systemd, screen, whatever you use):
```bash
./waste-anchor -bind 127.0.0.1:8080
```
The anchor listens locally on port 8080 — Nginx Proxy Manager will expose it over TLS.
### 2. Build and upload the web UI
```bash
# On your local machine
cd web
npm install
npm run build
# Produces web/dist/
# Copy to VPS
rsync -az web/dist/ user@your-vps:/var/www/waste-web/
```
Create a `/var/www/waste-web/config.js` on the VPS (not in git — this is host-specific):
```js
window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws',
}
```
This tells the browser where to connect for signaling. Without it the join form shows a blank signal server field and the user must fill it in manually.
### 3. Nginx Proxy Manager setup
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations:
**Location 1 — WebSocket signaling (`/ws`)**
- Location: `/ws`
- Forward hostname/IP: `127.0.0.1`
- Forward port: `8080`
- Enable: WebSockets Support
**Location 2 — Web UI (catch-all)**
- Location: `/`
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
- Enable the SPA fallback so unknown paths return `index.html` — this is required for invite links to work
If NPM doesn't support static file serving directly, run a small static server on a spare port (e.g. `npx serve -s /var/www/waste-web -l 3000`) and proxy `/` to `127.0.0.1:3000`. The key requirements:
- `/ws` → anchor process (WebSocket, keep-alive)
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
---
## How it works: daemon vs browser mode
There are two ways to use the web UI.
### Browser mode (for anyone with just a URL)
When the web UI is served from a non-localhost origin it runs entirely in the browser — no daemon, no install. Crypto (Ed25519/X25519) runs via libsodium compiled to WebAssembly. The identity seed is stored in `localStorage` and persists across sessions.
A user visits your domain, enters their name and a network name, and joins. Invite links (`waste:…` or `?n=name&a=wss://…`) pre-fill the join form.
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
### Daemon mode (for users running the daemon locally)
`launch-web.sh` starts the Go daemon and the Vite dev server. The web UI connects to the local daemon over WebSocket IPC (`ws://127.0.0.1:17338`). The daemon handles all crypto and connects to the anchor.
When the web UI is loaded from `localhost`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
---
## Local development
### Prerequisites
- Go 1.24+ — https://go.dev/dl/
- Node.js 20+
### Quick start (three peers in one terminal session)
```bash
# Terminal 1 — local anchor
go run ./cmd/anchor -bind 127.0.0.1:17339
# Terminal 2 — peer A
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
# Terminal 3 — peer B (or use --join with an invite from peer A)
# Terminal 3 — peer B
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
```
Both peers join the same named network via IPC:
Join both to a network:
```bash
# Join peer A to a network called "friends"
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
# Join peer B to the same network
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
# Subscribe to peer A's events (in a separate terminal)
nc 127.0.0.1 17337 &
# Send a message from B
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
```
**On Windows** — use PowerShell's built-in TCP client instead of `nc`:
```powershell
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
# In a separate terminal — subscribe to peer A's events
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
$reader = [System.IO.StreamReader]::new($r.GetStream())
while ($true) { $reader.ReadLine() }
```
## Deploying the anchor on your Hetzner VPS
### Web UI (daemon mode)
```bash
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
scp bin/waste-anchor user@your-vps:~/
# Requires a running daemon on port 17337
./launch-web.sh
# On the VPS (also run coturn in STUN-only mode on port 3478)
./waste-anchor -bind 0.0.0.0:17339
# Or with a custom alias and network:
ALIAS=alice NETWORK=friends ./launch-web.sh
```
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC
with ICE (STUN-assisted hole punching) through the anchor for signaling.
## IPC protocol (plain JSON over TCP)
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
**Commands you send:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"leave_network"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
{"type":"get_state"}
{"type":"get_file_list"} // own share dir
{"type":"get_file_list","peer_id":"<64-hex>"} // remote peer's share dir
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} // offer a file from share dir
{"type":"generate_invite"}
```
**Events the daemon pushes:**
```jsonc
// Sent immediately on connect and in response to get_state
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
// Peer lifecycle
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
// File events
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"<32-hex>","name":"notes.txt","size":1024,"sha256":"<64-hex>"}}
{"type":"file_progress","transfer_id":"<32-hex>","bytes_received":65536,"total_bytes":1048576}
{"type":"file_complete","transfer_id":"<32-hex>","path":"/data-dir/downloads-<netid>/notes.txt"}
{"type":"file_list","peer_id":"<64-hex>","files":[{"name":"notes.txt","size_bytes":1024}]}
// Invite generation response
{"type":"invite_generated","invite":"waste:<base64>"}
// Error
{"type":"error","error_message":"..."}
```
## Crypto choices
| Purpose | Algorithm | Notes |
|---|---|---|
| Identity | Ed25519 | Fast, small keys, standard |
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
| Signaling encryption (2.0) | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
| **Signaling encryption (2.1)** | **XSalsa20-Poly1305, ephemeral X25519** | **Per-session keypair; `esk` wiped on close → forward secrecy** |
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
| Hashing | SHA-256 | File integrity, network name hashing |
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
### Forward-secret signaling (YAW/2.1)
By default waste-go speaks **YAW/2.1**: before sending an offer each peer generates a fresh
X25519 keypair (`esk`/`epk`), broadcasts its `epk` in a signed `ekey` message, then seals
`offer`/`answer`/`candidate` payloads with the *ephemeral* keys. `esk` is zeroed when the
session ends. Recorded signaling traffic cannot be decrypted even if the long-term Ed25519
keys later leak.
A 2.0 peer ignores the `ekey` message (unknown type → silently dropped) and the offerer
falls back to static-key sealing after a 2 s timeout, so **2.1 ↔ 2.0 sessions work** — the
session just isn't forward-secret. The log line `anchor: 2.0 fallback offer to …` flags this.
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
> on disk are unaffected — only the over-the-wire representation changed from base64url.
## Onboarding a new peer
Alice is already on the network and wants to add Bob.
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
```bash
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
```
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
```bash
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
**Bob opens the TUI**`--join` also accepts the invite to skip the `-network` flag:
```bash
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
## Terminal UI
Start the daemon first (see Getting started above), then:
```bash
go run ./cmd/tui -network friends
```
Options:
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string — sets the network name automatically |
| `-ipc` | `17337` | Daemon IPC port |
**Layout:**
```
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
│ ││ 15:05 charlie the mesh works ││ ● charlie │
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
╭─────────────────────────────────────────────────────────────╮
│ Type a message… │
╰─────────────────────────────────────────────────────────────╯
```
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
## Testing
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
### Automated test
```bash
./test-network.sh
```
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run:
Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
---
## Onboarding a new peer
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
```bash
# DB name includes the network ID (first 8 hex chars of sha256("yaw2-net:"+name))
sqlite3 /tmp/waste-test/alice/messages-<netid>.db
.headers on
SELECT room, from_peer, text, sent_at FROM messages;
SELECT peer_id, alias, last_seen FROM peers;
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJ..."}
```
There is also a TUI integration test that boots the same three-peer network and
launches the Bubble Tea UI as alice:
Bob joins using the invite:
```bash
./test-tui.sh
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJ...'
go run ./cmd/tui --join 'waste:eyJ...'
```
## Roadmap
The invite encodes the anchor URL and network name. Sharing it only lets the recipient join the same network — the anchor never sees plaintext messages.
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation
- [x] **Proto additions**`mid` dedup field, signaling types, anchor wire types, `hello` message
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server replacing TCP relay
- [x] **WebRTC peer connections** — pion/webrtc DataChannels; ICE hole-punching via STUN
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing
- [x] **IPC updates**`join_network`/`leave_network`; `session_ready` event; DMs via `to` field
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs
- [x] **File transfer** — chunked binary DataChannel (`f:<xid>`); SHA-256 verified; backpressure; auto-accept
- [x] **Forward-secret signaling (YAW/2.1)** — ephemeral X25519 per session; `esk` wiped on close; 2.0 fallback
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
---
## Terminal UI
```bash
go run ./cmd/tui -network friends
```
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port |
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
---
## IPC protocol
Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
**Commands:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","to":"<64-hex>","body":"hey"} // DM
{"type":"generate_invite"}
{"type":"get_state"}
{"type":"get_file_list"}
{"type":"get_file_list","peer_id":"<64-hex>"}
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
{"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."}
```
**Events:**
```jsonc
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"}
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
{"type":"network_joined","network_id":"...","network_name":"friends"}
{"type":"invite_generated","invite":"waste:<base64>"}
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."}
```
---
## Crypto
| Purpose | Algorithm |
|---|---|
| Identity | Ed25519 |
| Signaling (2.0) | XSalsa20-Poly1305, X25519 keys derived from Ed25519 |
| Signaling (2.1) | XSalsa20-Poly1305, ephemeral X25519 per session (forward secrecy) |
| Transport | WebRTC DataChannels (DTLS+SCTP via pion/webrtc) |
| File integrity | SHA-256 |
### Forward-secret signaling (YAW/2.1)
Each peer generates a fresh X25519 keypair per session and broadcasts the public half in a signed `ekey` message before sending an offer. The `esk` is zeroed when the session ends. A 2.0 peer ignores `ekey` and the offerer falls back to static-key sealing after 2 s — so 2.1↔2.0 sessions work, just without forward secrecy.

View File

@@ -122,9 +122,8 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
if already {
continue
}
// Use the same lexicographic tiebreak as anchor join to avoid
// both sides trying to offer simultaneously.
if strings.Compare(string(id.PeerID()), string(pid)) <= 0 {
// Lower ID offers (matches yaw2/browser convention).
if strings.Compare(string(id.PeerID()), string(pid)) >= 0 {
continue
}
go func(pid proto.PeerID) {
@@ -170,7 +169,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers))
for _, peerHex := range msg.Peers {
pid := proto.PeerID(peerHex)
if strings.Compare(string(id.PeerID()), peerHex) > 0 {
if strings.Compare(string(id.PeerID()), peerHex) < 0 {
go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s)
if err != nil {
@@ -187,7 +186,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
case proto.AnchorPeerJoin:
pid := proto.PeerID(msg.ID)
log.Printf("anchor: peer joined: %s", pid.Short())
if strings.Compare(string(id.PeerID()), msg.ID) > 0 {
if strings.Compare(string(id.PeerID()), msg.ID) < 0 {
go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s)
if err != nil {

View File

@@ -4,10 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<title>waste</title>
</head>
<body>
<div id="root"></div>
<!-- Optional runtime config (anchor host puts config.js here; silently absent in dev) -->
<script src="/config.js" onerror="void 0"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

16
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"zustand": "^5.0.14"
@@ -1883,6 +1884,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/libsodium": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz",
"integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz",
"integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"zustand": "^5.0.14"

View File

@@ -4,15 +4,21 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat'
import './App.css'
// Default to local daemon WS IPC. Override with VITE_DAEMON_WS env var.
// When served from the anchor (non-localhost), default to browser mode.
// When running locally, try the daemon first.
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
export default function App() {
const { connect, daemonStatus, localPeer } = useWaste()
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => {
connect(DAEMON_WS)
}, [connect])
if (isLocal) {
connect(DAEMON_WS)
} else {
connectBrowser()
}
}, [connect, connectBrowser])
if (daemonStatus !== 'connected' || !localPeer) {
return <Onboarding status={daemonStatus} />

707
web/src/adapter/browser.ts Normal file
View File

@@ -0,0 +1,707 @@
// BrowserAdapter — standalone in-browser waste client.
// Speaks the yaw/2.1 signaling + WebRTC protocol; emits IpcMessage events
// identical to DaemonAdapter so the store and UI need no changes.
//
// No daemon required. Identity is Ed25519, stored as hex seed in localStorage.
// Crypto via libsodium-wrappers.
import sodium from 'libsodium-wrappers'
import type { IpcMessage, PeerInfo } from '../types'
type Listener = (msg: IpcMessage) => void
type Status = 'disconnected' | 'connecting' | 'connected'
const BIND_PREFIX = 'yaw/2 bind'
const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302'
const enc = (s: string) => new TextEncoder().encode(s)
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
for (const a of arrs) { out.set(a, o); o += a.length }
return out
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
async function netHash(name: string): Promise<string> {
const input = enc('yaw2-net:' + name)
// Use native SHA-256 for protocol-compatible network IDs.
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
return toHex(new Uint8Array(digest))
}
// Fallback for environments where subtle crypto is unavailable.
const sha256 = (sodium as unknown as { crypto_hash_sha256?: (m: Uint8Array) => Uint8Array }).crypto_hash_sha256
if (sha256) return sodium.to_hex(sha256(input))
throw new Error('SHA-256 not available in this browser runtime')
}
// ── Identity ─────────────────────────────────────────────────────────────────
class Identity {
pub: Uint8Array
priv: Uint8Array
id: string
curvePriv: Uint8Array
nick: string
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
this.pub = kp.publicKey
this.priv = kp.privateKey
this.id = sodium.to_hex(this.pub)
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
this.nick = localStorage.getItem('waste_nick') || ''
}
static load(): Identity {
const seedHex = localStorage.getItem('waste_seed')
let kp
if (seedHex) {
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
} else {
kp = sodium.crypto_sign_keypair()
localStorage.setItem('waste_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
}
return new Identity(kp)
}
setNick(nick: string) {
this.nick = nick.trim().slice(0, 40)
localStorage.setItem('waste_nick', this.nick)
}
sign(data: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(data, this.priv)
}
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
catch { return false }
}
seal(recipIdHex: string, plaintext: Uint8Array): string {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
}
open(senderIdHex: string, boxB64: string): Uint8Array | null {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
catch { return null }
}
exportBackup(passphrase: string): object {
const BK_OPS = 2, BK_MEM = 67108864
const b64 = (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL)
const seed = this.priv.slice(0, 32)
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, salt,
BK_OPS, BK_MEM, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const ct = sodium.crypto_secretbox_easy(seed, nonce, key)
return {
yaw: 'yaw-key-backup-1', id: this.id, alg: 'argon2id-secretbox',
ops: BK_OPS, mem: BK_MEM, salt: b64(salt), nonce: b64(nonce), ct: b64(ct)
}
}
static importBackup(b: Record<string, unknown>, passphrase: string): Identity {
if (!b || b['yaw'] !== 'yaw-key-backup-1') throw new Error('not a yaw key backup')
const ub = (s: unknown) => sodium.from_base64(s as string, sodium.base64_variants.ORIGINAL)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, ub(b['salt']),
b['ops'] as number | 0, b['mem'] as number | 0, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const seed = sodium.crypto_secretbox_open_easy(ub(b['ct']), ub(b['nonce']), key)
localStorage.setItem('waste_seed', sodium.to_hex(seed))
return new Identity(sodium.crypto_sign_seed_keypair(seed))
}
get short(): string {
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
}
toPeerInfo(networkId = ''): PeerInfo {
return {
id: this.id, alias: this.nick || this.short, public_key: this.id,
created_at: new Date().toISOString(), network_id: networkId
} as PeerInfo & { network_id?: string }
}
}
// ── Signaling ─────────────────────────────────────────────────────────────────
class Signaling {
private ws: WebSocket | null = null
private _closed = false
private _backoff = 1000
private _cbs: {
onFrom?: (from: string, box: string) => void
onJoin?: (id: string) => void
onLeave?: (id: string) => void
onReconnect?: (peers: string[]) => void
} = {}
private url: string
private identity: Identity
private net: string
constructor(url: string, identity: Identity, net: string) {
this.url = url
this.identity = identity
this.net = net
}
connect(
onFrom: (from: string, box: string) => void,
onJoin: (id: string) => void,
onLeave: (id: string) => void,
onReconnect: (peers: string[]) => void,
): Promise<string[]> {
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
return this._open(true)
}
private _open(initial: boolean): Promise<string[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.url)
this.ws = ws
let joined = false
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
ws.onmessage = (ev) => {
let m: Record<string, unknown>
try { m = JSON.parse(ev.data) } catch { return }
if (m['type'] === 'challenge') {
const nonce = sodium.from_hex(m['nonce'] as string)
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
} else if (m['type'] === 'joined') {
joined = true; this._backoff = 1000
const peers = (m['peers'] as string[]) || []
if (initial) resolve(peers)
else this._cbs.onReconnect?.(peers)
} else if (m['type'] === 'from') {
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
} else if (m['type'] === 'peer-join') {
this._cbs.onJoin?.(m['id'] as string)
} else if (m['type'] === 'peer-leave') {
this._cbs.onLeave?.(m['id'] as string)
}
}
})
}
private _scheduleReconnect() {
if (this._closed) return
const delay = this._backoff
this._backoff = Math.min(this._backoff * 2, 30000)
setTimeout(() => { if (!this._closed) this._open(false).catch(() => { }) }, delay)
}
sendTo(toId: string, box: string) {
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { }
}
close() { this._closed = true; this.ws?.close() }
}
// ── Peer connection (yaw/2.1) ─────────────────────────────────────────────────
type PeerCallback = (event: string, data: Record<string, unknown>) => void
class PeerConn {
pc: RTCPeerConnection
dc: RTCDataChannel | null = null
verified = false
peerAuthed = false
created = Date.now()
private _esk: Uint8Array | null
private _epk: Uint8Array | null
peer_epk: Uint8Array | null = null
private _ekeySent = false
private _offerPending = false
private _offered = false
private identity: Identity
private sig: Signaling
public peerId: string
private on: PeerCallback
private nick: string
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.on = on
this.nick = nick
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
this._epk = kp.publicKey
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
this.pc.onconnectionstatechange = () =>
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
}
private _seal(obj: object, preferEph: boolean): [string, boolean] {
const data = enc(JSON.stringify(obj))
if (preferEph && this.peer_epk && this._esk) {
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
return [sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL), true]
}
return [this.identity.seal(this.peerId, data), false]
}
private _open(box: string): [Uint8Array | null, boolean] {
if (this.peer_epk && this._esk) {
try {
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
} catch { }
}
return [this.identity.open(this.peerId, box), false]
}
private _sendEkey() {
if (this._ekeySent || !this._epk) return
this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id),
sodium.from_hex(this.peerId), this._epk)
const msg = {
kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk),
sig: sodium.to_hex(this.identity.sign(signed))
}
const [box] = this._seal(msg, false)
this.sig.sendTo(this.peerId, box)
}
private async _onEkey(obj: Record<string, unknown>) {
if (this.peer_epk) return
try {
const epkRaw = sodium.from_hex(obj['epk'] as string)
const sig = sodium.from_hex(obj['sig'] as string)
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId),
sodium.from_hex(this.identity.id), epkRaw)
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sig)) return
this.peer_epk = epkRaw
} catch { return }
this._sendEkey()
if (this._offerPending) await this._doOffer()
}
async startOffer() {
this._sendEkey()
this._offerPending = true
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
if (this.peer_epk) await this._doOffer()
}
private async _doOffer() {
if (this._offered) return
this._offered = true; this._offerPending = false
this.dc = this.pc.createDataChannel('yaw')
this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc)
const [box] = this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)
this.sig.sendTo(this.peerId, box)
}
async onBox(box: string) {
const [plain, usedEph] = this._open(box)
if (!plain) return
this.peerAuthed = true
let obj: Record<string, unknown>
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
if (obj['kind'] === 'ekey') {
await this._onEkey(obj)
} else if (obj['kind'] === 'offer') {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc)
const [box2] = this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)
this.sig.sendTo(this.peerId, box2)
} else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
}
}
private _wire(channel: RTCDataChannel) {
if (channel.label !== 'yaw') return
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
}
private _sendHello() {
const bind = enc(BIND_PREFIX)
const caps: string[] = []
this._dc({
type: 'hello', id: this.identity.id, nick: this.nick, caps,
sig: sodium.to_hex(this.identity.sign(bind))
})
}
private _onControl(data: string) {
let m: Record<string, unknown>
try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') {
let reason = ''
if (m['id'] !== this.peerId) reason = 'id mismatch'
else if (!this.peerAuthed) reason = 'unauthenticated signaling'
this.verified = !reason
this.on('connected', {
peer: this.peerId, verified: this.verified,
nick: m['nick'] as string || '', caps: m['caps'] || []
})
} else if (m['type'] === 'chat') {
this.on('chat', {
peer: this.peerId, room: m['room'] as string || 'general',
text: m['text'] as string, ts: m['ts'] as number || Date.now()
})
} else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
}
}
sendChat(room: string, text: string) {
this._dc({ type: 'chat', room, text, ts: Date.now() })
}
sendPm(text: string) {
this._dc({ type: 'pm', text, ts: Date.now() })
}
private _dc(obj: object) {
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
}
close() { try { this.pc.close() } catch { } }
}
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve()
return new Promise((res) => {
const check = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', check)
res()
}
}
pc.addEventListener('icegatheringstatechange', check)
setTimeout(res, 6000)
})
}
// ── BrowserAdapter ────────────────────────────────────────────────────────────
export class BrowserAdapter {
private listeners: Listener[] = []
private identity: Identity | null = null
private sig: Signaling | null = null
private peers: Map<string, PeerConn> = new Map()
private present: Set<string> = new Set()
private networkId = ''
private networkName = ''
status: Status = 'disconnected'
onStatusChange?: (s: Status) => void
private setStatus(s: Status) {
this.status = s
this.onStatusChange?.(s)
}
private emit(msg: IpcMessage) {
this.listeners.forEach(l => l(msg))
}
on(listener: Listener) {
this.listeners.push(listener)
return () => { this.listeners = this.listeners.filter(l => l !== listener) }
}
// Call after connect() to set/update the nick shown to peers
setNick(nick: string) {
this.identity?.setNick(nick)
if (this.identity) {
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: this.networkId ? [{
network_id: this.networkId,
network_name: this.networkName,
local_peer: this.identity.toPeerInfo(this.networkId),
}] : [],
})
}
}
async connect() {
await sodium.ready
this.identity = Identity.load()
this.setStatus('connecting')
// Emit identity immediately — UI can show alias before network join
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: [],
})
// Mark connected — onboarding shows join form
this.setStatus('connected')
}
disconnect() {
this.sig?.close()
this.sig = null
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
this.networkId = ''
this.networkName = ''
this.setStatus('disconnected')
}
async joinNetwork(anchorUrl: string, nameOrHash: string, isHash = false) {
if (!this.identity) return
const hash = isHash ? nameOrHash : await netHash(nameOrHash)
this.networkId = hash.slice(0, 16)
this.networkName = isHash ? hash.slice(0, 16) : nameOrHash
this.sig?.close()
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
const sig = new Signaling(anchorUrl, this.identity, hash)
this.sig = sig
try {
const present = await sig.connect(
(from, box) => this._onFrom(from, box),
(pid) => { this.present.add(pid); this._tryConnect(pid) },
(pid) => {
this.present.delete(pid)
this.peers.get(pid)?.close()
this.peers.delete(pid)
this.emit({ type: 'peer_disconnected', peer_id: pid as unknown as import('../types').PeerID })
},
(peers) => {
this.present = new Set(peers)
peers.forEach(pid => this._tryConnect(pid))
},
)
for (const pid of present) {
this.present.add(pid)
await this._tryConnect(pid)
}
const localPeer = this.identity.toPeerInfo(this.networkId)
this.emit({
type: 'network_joined',
network_id: this.networkId,
network_name: this.networkName,
local_peer: localPeer,
networks: [{ network_id: this.networkId, network_name: this.networkName, local_peer: localPeer }],
})
setInterval(() => { this.present.forEach(pid => this._tryConnect(pid)) }, 4000)
} catch (err) {
this.emit({ type: 'error', error_message: `signaling: ${err}` })
}
}
private async _tryConnect(pid: string) {
if (!this.identity || pid === this.identity.id) return
if (this.identity.id >= pid) return // lower ID answers; higher ID offers
const existing = this.peers.get(pid)
if (existing) {
const open = existing.dc?.readyState === 'open'
const state = existing.pc.connectionState
const alive = state !== 'failed' && state !== 'closed'
const fresh = Date.now() - existing.created < 12000
if (existing.verified || open || (alive && fresh)) return
}
const peer = new PeerConn(this.identity, this.sig!, pid,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
this.peers.set(pid, peer)
await peer.startOffer()
}
private async _onFrom(from: string, box: string) {
if (!this.identity || from === this.identity.id) return
let peer = this.peers.get(from)
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
peer = new PeerConn(this.identity, this.sig!, from,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
this.peers.set(from, peer)
}
await peer.onBox(box)
}
private _onPeerEvent(event: string, data: Record<string, unknown>) {
if (event === 'connected') {
const nick = (data['nick'] as string) || (data['peer'] as string).slice(0, 16)
this.emit({
type: 'peer_connected',
peer: {
id: data['peer'] as string, alias: nick || (data['peer'] as string).slice(0, 8),
public_key: data['peer'] as string, created_at: new Date().toISOString()
},
})
} else if (event === 'chat') {
const ts = (data['ts'] as number) || Date.now()
const mid = `${data['peer']}-${ts}`
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid,
from: data['peer'] as unknown as import('../types').PeerID,
room: (data['room'] as string) || 'general',
text: data['text'] as string,
ts,
},
})
} else if (event === 'pm') {
const ts = (data['ts'] as number) || Date.now()
const from = data['peer'] as string
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid: `${from}-${ts}`,
from: from as unknown as import('../types').PeerID,
room: `dm:${from.slice(0, 8)}`,
text: data['text'] as string,
ts,
},
})
}
}
send(msg: IpcMessage) {
if (!this.identity) return
if (msg.type === 'join_network') {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
const anchorUrl = cfg?.signalURL
|| localStorage.getItem('waste_anchor_url')
|| ''
const isHash = !msg.network_name && !!msg.network_hash
const nameOrHash = msg.network_name || msg.network_hash || ''
if (nameOrHash) this.joinNetwork(anchorUrl, nameOrHash, isHash)
return
}
if (msg.type === 'send_message') {
const room = msg.room || 'general'
const text = msg.body || ''
if (!text) return
const ts = Date.now()
const mid = `local-${ts}`
if (msg.to) {
// DM
const pid = msg.to as string
this.peers.get(pid)?.sendPm(text)
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts
},
})
} else {
// Broadcast
this.peers.forEach(p => p.sendChat(room, text))
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
room, text, ts
},
})
}
return
}
if (msg.type === 'export_identity') {
if (!msg.passphrase) return
try {
const backup = this.identity.exportBackup(msg.passphrase)
this.emit({ type: 'identity_exported', backup: JSON.stringify(backup, null, 2) })
} catch (err) {
this.emit({ type: 'error', error_message: `export_identity: ${err}` })
}
return
}
if (msg.type === 'import_identity') {
if (!msg.passphrase || !msg.backup) return
try {
const parsed = JSON.parse(msg.backup)
const newId = Identity.importBackup(parsed, msg.passphrase)
this.identity = newId
this.emit({ type: 'identity_imported' })
this.emit({
type: 'state_snapshot',
master_alias: newId.nick || newId.short,
master_id: newId.id,
rooms: ['general'],
networks: [],
})
} catch (err) {
this.emit({ type: 'error', error_message: `import_identity: ${err}` })
}
return
}
if (msg.type === 'generate_invite') {
const anchor = localStorage.getItem('waste_anchor_url') || 'wss://waste.dev.xplwd.com/ws'
const name = this.networkName
if (!name || name === this.networkId) {
this.emit({ type: 'error', error_message: 'generate_invite: joined by hash — no network name available' })
return
}
const h = netHash(name)
const payload = btoa(JSON.stringify({ anchor, network: name, net: h }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
this.emit({ type: 'invite_generated', invite: `waste:${payload}`, network_id: this.networkId })
return
}
}
}

View File

@@ -10,8 +10,8 @@ interface Props {
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
// ?n=<name> network name shorthand
// ?network=<name> network name
// ?net=<64hex> yaw2-style full network hash (joined without plaintext name)
// ?a=<url> anchor URL hint (informational — daemon controls its anchor)
// ?net=<64hex> yaw2-style full network hash
// ?a=<url> anchor URL hint
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
const p = new URLSearchParams(window.location.search)
const inviteString = p.get('invite') ?? ''
@@ -21,7 +21,6 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
if (inviteString.startsWith('waste:')) {
try {
// URL-safe base64 → standard base64
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
network = network || json.network || ''
netHash = netHash || json.net || ''
@@ -29,33 +28,36 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
} catch { /* ignore bad invite */ }
}
// URL path: /<16hex>/ — short network ID from anchor-served URL
const pathMatch = window.location.pathname.match(/\/([0-9a-f]{16})\/?$/i)
if (pathMatch && !netHash && !network) {
// Only the short ID — can't join by short ID alone (need full hash for HKDF).
// Store it as a hint; the user must supply the network name OR a full 64-char hash.
}
return { network, netHash, anchor, inviteString }
}
const DEFAULT_ANCHOR = (() => {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
return cfg?.signalURL ?? localStorage.getItem('waste_anchor_url') ?? ''
})()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
export function Onboarding({ status }: Props) {
const { send, masterAlias, masterId, exportedBackup } = useWaste()
const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
const [network, setNetwork] = useState('')
const [netHash, setNetHash] = useState('')
const [inviteString, setInviteString] = useState('')
const [anchorHint, setAnchorHint] = useState('')
const [anchorUrl, setAnchorUrl] = useState(DEFAULT_ANCHOR)
const [nick, setNick] = useState(localStorage.getItem('waste_nick') || '')
const [exportPass, setExportPass] = useState('')
const [importJson, setImportJson] = useState('')
const [importPass, setImportPass] = useState('')
const [importStatus, setImportStatus] = useState('')
const [showBackup, setShowBackup] = useState(false)
const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338')
useEffect(() => {
const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams()
if (n) setNetwork(n)
if (nh) setNetHash(nh)
if (a) setAnchorHint(a)
if (a) setAnchorUrl(a)
if (inv) setInviteString(inv)
}, [])
@@ -64,8 +66,14 @@ export function Onboarding({ status }: Props) {
const name = network.trim()
const hash = netHash.trim()
if (!name && !hash) return
// In browser mode: persist anchor URL choice and pass it via localStorage
if (adapterMode === 'browser') {
localStorage.setItem('waste_anchor_url', anchorUrl)
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
}
if (hash.length === 64 && !name) {
// yaw2-style: join by full network hash without plaintext name
send({ type: 'join_network', network_hash: hash })
} else {
send({ type: 'join_network', network_name: name })
@@ -75,7 +83,6 @@ export function Onboarding({ status }: Props) {
function exportIdentity(e: React.FormEvent) {
e.preventDefault()
if (!exportPass.trim()) return
setExportBlob('')
send({ type: 'export_identity', passphrase: exportPass })
}
@@ -86,6 +93,15 @@ export function Onboarding({ status }: Props) {
send({ type: 'import_identity', backup: importJson, passphrase: importPass })
}
function switchToDaemon() {
localStorage.setItem('waste_daemon_ws', daemonUrl)
connect(daemonUrl)
}
function switchToBrowser() {
connectBrowser()
}
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
// ── disconnected / connecting ────────────────────────────────────────────────
@@ -94,18 +110,16 @@ export function Onboarding({ status }: Props) {
<div className="onboarding">
<h1>waste</h1>
{status === 'connecting' ? (
<p className="status connecting">Connecting to daemon</p>
<p className="status connecting">
{adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'}
</p>
) : (
<>
<p className="status disconnected">Daemon not running</p>
<p className="onboarding-hint">
Start the daemon to use the web UI:
</p>
<p className="onboarding-hint">Start the daemon to use the web UI:</p>
<pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted">
Or pass a custom alias and network:
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
<p className="onboarding-hint muted">Or use browser mode (no install required):</p>
<button className="primary" onClick={switchToBrowser}>Use browser mode</button>
</>
)}
</div>
@@ -121,14 +135,25 @@ export function Onboarding({ status }: Props) {
<div className="onboarding-identity">
<span className="alias">{masterAlias}</span>
{shortId && <span className="peer-id mono">{shortId}</span>}
<span className="peer-id" style={{ opacity: 0.4, fontSize: '9px' }}>
{adapterMode === 'browser' ? 'browser mode' : 'daemon mode'}
</span>
</div>
)}
<form onSubmit={joinNetwork} className="join-form">
<label className="join-label">Join a network</label>
{anchorHint && (
<p className="onboarding-hint muted">via {anchorHint}</p>
{adapterMode === 'browser' && (
<input
value={nick}
onChange={e => setNick(e.target.value)}
placeholder="your name (optional)"
autoComplete="nickname"
style={{ marginBottom: '0.4rem' }}
/>
)}
{netHash && !network ? (
<input
value={netHash}
@@ -148,6 +173,18 @@ export function Onboarding({ status }: Props) {
autoFocus
/>
)}
{adapterMode === 'browser' && (
<input
value={anchorUrl}
onChange={e => setAnchorUrl(e.target.value)}
placeholder="signal server (wss://…)"
autoComplete="url"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
)}
{inviteString && (
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
{inviteString.slice(0, 48)}
@@ -158,6 +195,31 @@ export function Onboarding({ status }: Props) {
</button>
</form>
{/* Mode switcher — only shown when on localhost */}
{isLocal && adapterMode !== null && (
<div className="onboarding-section">
{adapterMode === 'daemon' ? (
<button className="toggle-link" onClick={switchToBrowser}>
Switch to browser mode
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<button className="toggle-link" onClick={() => {}}>
Switch to daemon mode
</button>
<input
value={daemonUrl}
onChange={e => setDaemonUrl(e.target.value)}
placeholder="ws://127.0.0.1:17338"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
<button className="primary" onClick={switchToDaemon}>Connect</button>
</div>
)}
</div>
)}
<div className="onboarding-section">
<button
className="toggle-link"
@@ -170,7 +232,7 @@ export function Onboarding({ status }: Props) {
<div className="backup-panel">
<p className="onboarding-hint">
Export your identity as an encrypted file. You can import it on
any machine or in the TUI with <code>--import-identity</code>.
any device browser or TUI.
</p>
<form onSubmit={exportIdentity} className="backup-form">

View File

@@ -1,10 +1,14 @@
import { create } from 'zustand'
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types'
import { DaemonAdapter } from '../adapter/daemon'
import { BrowserAdapter } from '../adapter/browser'
type AnyAdapter = DaemonAdapter | BrowserAdapter
interface WasteState {
// connection
adapter: DaemonAdapter | null
adapter: AnyAdapter | null
adapterMode: 'daemon' | 'browser' | null
daemonStatus: 'disconnected' | 'connecting' | 'connected'
// identity
@@ -31,6 +35,7 @@ interface WasteState {
// actions
connect: (url: string) => void
connectBrowser: () => void
disconnect: () => void
send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void
@@ -40,6 +45,7 @@ interface WasteState {
export const useWaste = create<WasteState>((set, get) => ({
adapter: null,
adapterMode: null,
daemonStatus: 'disconnected',
masterAlias: null,
masterId: null,
@@ -57,12 +63,22 @@ export const useWaste = create<WasteState>((set, get) => ({
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter })
set({ adapter, adapterMode: 'daemon' })
},
connectBrowser() {
const adapter = new BrowserAdapter()
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter, adapterMode: 'browser' })
},
disconnect() {
get().adapter?.disconnect()
set({ adapter: null, daemonStatus: 'disconnected' })
const a = get().adapter
if (a instanceof DaemonAdapter) a.disconnect()
else if (a instanceof BrowserAdapter) a.disconnect()
set({ adapter: null, adapterMode: null, daemonStatus: 'disconnected' })
},
send(msg) {
@@ -93,11 +109,13 @@ export const useWaste = create<WasteState>((set, get) => ({
}
case 'network_joined': {
set(s => ({
localPeer: s.localPeer ?? msg.local_peer ?? null,
networks: s.networks.some(n => n.network_id === msg.network_id)
? s.networks
: [...s.networks, {
network_id: msg.network_id!,
network_name: msg.network_name!,
local_peer: msg.local_peer,
share_dir: msg.share_dir,
}],
activeNetworkId: s.activeNetworkId ?? msg.network_id!,