Compare commits
71 Commits
be072c7309
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15306dc0c2 | ||
|
|
7c3cedc549 | ||
|
|
1c73f1b1ef | ||
|
|
b2b5c8c7cb | ||
|
|
b6ff30de78 | ||
|
|
1bd719fa58 | ||
|
|
be297d3a49 | ||
|
|
c426fa8c08 | ||
|
|
0789cf8840 | ||
|
|
d233f4d79e | ||
|
|
95fd29ae8d | ||
|
|
340735f992 | ||
|
|
1308082c7b | ||
|
|
31e13fd509 | ||
|
|
0e8ddbf4f4 | ||
|
|
f326ff2605 | ||
|
|
e0704f210c | ||
|
|
0f54f3bbad | ||
|
|
80e05b81ac | ||
|
|
2c71d9c5c6 | ||
|
|
dfbdd34aaa | ||
|
|
295851f966 | ||
|
|
02eb83b63a | ||
|
|
ea1eb767f1 | ||
|
|
c4032417ae | ||
|
|
fb14ca82af | ||
|
|
5421352e62 | ||
|
|
068e7e6566 | ||
|
|
91b7406d01 | ||
|
|
ea66e2eb58 | ||
|
|
076f9641c2 | ||
|
|
1d38766006 | ||
|
|
f5a11cb22b | ||
|
|
478a0a32af | ||
|
|
4e5e41b733 | ||
|
|
27874cd721 | ||
|
|
6e8c8180b5 | ||
|
|
bb53357bc8 | ||
|
|
cb80040ddb | ||
|
|
f7047b7bfe | ||
|
|
d529f58ddc | ||
|
|
bbd78ac4de | ||
|
|
de8d3ff70d | ||
|
|
739c63f6b3 | ||
|
|
5bc16daae1 | ||
|
|
add7c5fea8 | ||
|
|
7fe02e9463 | ||
|
|
2bc1dbbedf | ||
|
|
06f9359da8 | ||
|
|
ff14e955ea | ||
|
|
77830a3b3f | ||
|
|
c1dea1d19d | ||
|
|
b47f659b7d | ||
|
|
f1498697b6 | ||
|
|
d02e18e212 | ||
|
|
f437fe94f4 | ||
|
|
274ff423f6 | ||
|
|
13b30ca0cb | ||
|
|
0051c8fdbf | ||
|
|
b87f14a361 | ||
|
|
13fb7ba1fe | ||
|
|
8d3ca9d331 | ||
|
|
1a5f416ee4 | ||
|
|
abc6bf46ce | ||
|
|
5a0bcee8c6 | ||
|
|
714ac15816 | ||
|
|
c4a059431b | ||
|
|
d75d907245 | ||
|
|
5a05e6e1ef | ||
|
|
cde611b261 | ||
|
|
b648f7029f |
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go build *)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
81
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Install platform dependencies
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev
|
||||
|
||||
# ── Server binaries (CGO_ENABLED=0, cross-compile freely) ──────────────
|
||||
|
||||
- name: Build server binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
build() {
|
||||
local GOOS=$1 GOARCH=$2
|
||||
local SUFFIX="${GOOS}-${GOARCH}"
|
||||
local EXT=""
|
||||
[ "$GOOS" = "windows" ] && EXT=".exe"
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||
go build -trimpath -ldflags="-s -w" \
|
||||
-o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||
go build -trimpath -ldflags="-s -w" \
|
||||
-o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
|
||||
}
|
||||
build linux amd64
|
||||
build linux arm64
|
||||
build darwin amd64
|
||||
build darwin arm64
|
||||
build windows amd64
|
||||
|
||||
# ── Desktop app (Linux amd64, CGo + Wails) ─────────────────────────────
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cp -r dist ../cmd/app/frontend/dist
|
||||
|
||||
- name: Build desktop app
|
||||
run: |
|
||||
cd cmd/app
|
||||
wails build -trimpath -ldflags="-s -w" -tags webkit2_41 -o ../../dist/waste-linux-amd64
|
||||
|
||||
# ── Publish release (tags only) ─────────────────────────────────────────
|
||||
|
||||
- name: Create release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: https://gitea.com/actions/gitea-release-action@main
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: dist/*
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
21
.gitignore
vendored
@@ -1,5 +1,26 @@
|
||||
/bin/
|
||||
setup-anchor.sh
|
||||
launch-tui.sh
|
||||
*.exe
|
||||
# compiled binaries that land in the repo root
|
||||
/anchor
|
||||
/waste-anchor
|
||||
/waste-daemon
|
||||
/waste
|
||||
/app
|
||||
/relay
|
||||
*.identity.json
|
||||
/tmp/
|
||||
.env
|
||||
.DS_Store
|
||||
*.swp
|
||||
/tui
|
||||
launch-web.sh
|
||||
build-web.sh
|
||||
build-daemon.sh
|
||||
deploy-web.sh
|
||||
deploy-daemon.sh
|
||||
serve-web.sh
|
||||
web/public/config.js
|
||||
cmd/app/frontend/dist/*
|
||||
!cmd/app/frontend/dist/.gitkeep
|
||||
|
||||
306
EXTENSIONS.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# waste-go Protocol Extensions
|
||||
|
||||
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
|
||||
They do **not** break compatibility — YAW/2-only peers silently ignore all new
|
||||
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
|
||||
simply has no effect on that peer.
|
||||
|
||||
---
|
||||
|
||||
## EXT-001 — Signed Invites
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** `waste:` invite format, `hello` DataChannel message
|
||||
|
||||
### Motivation
|
||||
|
||||
The base YAW/2 network model is open to anyone who knows the anchor URL and
|
||||
network name (or hash). This extension adds opt-in cryptographic membership
|
||||
gating: invites are signed by an existing peer, and peers that enforce
|
||||
`RequireInvite` reject hellos that carry no valid signed invite.
|
||||
|
||||
### Invite format changes
|
||||
|
||||
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": "wss://...",
|
||||
"network": "friends",
|
||||
"net": "<64-hex SHA-256(yaw2-net:name)>",
|
||||
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
|
||||
"sig": "<hex Ed25519 signature>"
|
||||
}
|
||||
```
|
||||
|
||||
The signature covers the following bytes (null-separated):
|
||||
|
||||
```
|
||||
anchor \x00 network \x00 net \x00 inviter
|
||||
```
|
||||
|
||||
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
|
||||
|
||||
### Hello message extension
|
||||
|
||||
The YAW/2 §6 hello message gains one optional field:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"id": "<hex pubkey>",
|
||||
"nick": "alice",
|
||||
"caps": ["chat", "file"],
|
||||
"sig": "<DTLS binding sig>",
|
||||
"invite": "waste:eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
`invite` carries the full `waste:` string the connecting peer used to join.
|
||||
YAW/2-only peers ignore this field.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Per-network flag `RequireInvite` (set via `join_network` IPC command).
|
||||
When enabled:
|
||||
|
||||
1. A peer that presents no `invite` in hello is disconnected immediately.
|
||||
2. A peer that presents an invite with no signature is disconnected.
|
||||
3. A peer whose invite signature is invalid is disconnected.
|
||||
4. A peer whose invite was signed by an unknown peer ID (not in the store or
|
||||
currently connected) is disconnected.
|
||||
|
||||
The inviter's key must be a **known peer** — i.e. previously connected and
|
||||
stored in the per-network SQLite store, or currently connected. This forms a
|
||||
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
|
||||
invite Carol, whose invite Alice will also accept.
|
||||
|
||||
**Default:** off. Networks opt in. Existing networks with no RequireInvite
|
||||
behave exactly as before.
|
||||
|
||||
---
|
||||
|
||||
## EXT-002 — Hash-based Hang Link
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** web UI URL handling only, no wire changes
|
||||
|
||||
### Motivation
|
||||
|
||||
A shareable URL that pre-fills the join form without conveying cryptographic
|
||||
membership. Suitable for public announcements ("come hang out here"). The
|
||||
fragment is never sent to the server, keeping the network name opaque to
|
||||
server logs and HTTP intermediaries.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
https://host/#waste:eyJ...
|
||||
```
|
||||
|
||||
The fragment payload is the standard `waste:` base64 JSON with only `network`
|
||||
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
|
||||
membership on networks with `RequireInvite` enabled; it only pre-fills the
|
||||
join form.
|
||||
|
||||
The web UI generates hang links via the 🔗 button in the Networks sidebar
|
||||
section. Arriving users see the join form pre-populated and still need a
|
||||
proper signed invite (if the network enforces it) to be accepted by peers.
|
||||
|
||||
---
|
||||
|
||||
## EXT-003 — Multi-Share Configuration
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** IPC protocol only, no peer-to-peer wire changes
|
||||
|
||||
### New IPC commands
|
||||
|
||||
```jsonc
|
||||
{"type":"add_share","path":"/home/alice/Music"} // global
|
||||
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped
|
||||
{"type":"remove_share","path":"/home/alice/Music"}
|
||||
{"type":"list_shares"}
|
||||
```
|
||||
|
||||
### New IPC event
|
||||
|
||||
```jsonc
|
||||
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
|
||||
```
|
||||
|
||||
### Persistence
|
||||
|
||||
`shares.json` in the data directory (next to `identity.json`). Each entry:
|
||||
|
||||
```json
|
||||
{ "path": "/absolute/path", "networks": ["*"] }
|
||||
```
|
||||
|
||||
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
|
||||
Coexists with the legacy `set_share_dir` single-dir mechanism.
|
||||
|
||||
File listings returned by `get_file_list` and `MsgFileListReq` include
|
||||
entries from all applicable share roots, with relative `path` fields
|
||||
(e.g. `"path": "docs/report.pdf"`).
|
||||
|
||||
---
|
||||
|
||||
## EXT-004 — TURN Relay (browser mode)
|
||||
|
||||
**Status:** implemented (browser mode + daemon mode)
|
||||
**Affects:** ICE server configuration only, no wire changes
|
||||
|
||||
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
|
||||
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
|
||||
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
|
||||
|
||||
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
|
||||
opt-in via server configuration and does not affect peers that omit it.
|
||||
|
||||
---
|
||||
|
||||
## EXT-005 — Per-Network Path in FileEntry
|
||||
|
||||
**Status:** implemented
|
||||
**Affects:** `MsgFileListResp` wire message (additive field)
|
||||
|
||||
`FileEntry` gains an optional `path` field carrying the file's relative path
|
||||
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
|
||||
this field continue to use `name` for display and download requests.
|
||||
|
||||
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
|
||||
falling back to `name` for backward compat with peers that don't send `path`.
|
||||
|
||||
---
|
||||
|
||||
## EXT-006 — File Transfer Resume
|
||||
|
||||
**Status:** implemented (daemon mode)
|
||||
**Affects:** `file-accept` wire message (additive field)
|
||||
|
||||
### Motivation
|
||||
|
||||
A transfer interrupted mid-stream (peer disconnect, network drop) can be
|
||||
continued from where it left off on the next offer of the same file, avoiding
|
||||
a full re-download.
|
||||
|
||||
### Protocol change
|
||||
|
||||
`file-accept` gains one optional field:
|
||||
|
||||
```json
|
||||
{ "type": "file-accept", "xid": "...", "resume_offset": 65536 }
|
||||
```
|
||||
|
||||
`resume_offset` is the number of bytes the receiver already has on disk.
|
||||
When non-zero, the sender seeks to that byte position before streaming.
|
||||
Peers that don't understand this field ignore it and send from the start —
|
||||
the receiver detects this by comparing incoming data to expected offset and
|
||||
will still verify the final SHA-256, but the partial bytes from the interrupted
|
||||
session will be overwritten (YAW/2-only interop degrades gracefully to a full
|
||||
re-download, not corruption).
|
||||
|
||||
### Receiver behaviour
|
||||
|
||||
1. On `file-offer`, the receiver scans its download directory for a `.tmp.meta`
|
||||
sidecar whose `sha256` matches the offer.
|
||||
2. If found, the corresponding `.tmp` file's size is the resume offset. This is
|
||||
sent back in `file-accept`.
|
||||
3. On DC open, the receiver opens the existing `.tmp` in append mode and
|
||||
re-hashes its existing bytes to restore the SHA-256 state.
|
||||
4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar
|
||||
removed, file renamed to final path. Hash mismatch → both files removed.
|
||||
5. On DC close with fewer bytes than expected (interrupted again) → both files
|
||||
kept for the next resume attempt.
|
||||
|
||||
### Sidecar format
|
||||
|
||||
Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
|
||||
|
||||
```json
|
||||
{ "name": "archive.zip", "sha256": "abc...", "from": "<peer-id>", "size": 1048576 }
|
||||
```
|
||||
|
||||
The sidecar is written when the transfer starts and removed on completion or
|
||||
corruption. Interrupted transfers keep the sidecar indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## EXT-007 — P2P Message History Gossip
|
||||
|
||||
**Status:** implemented (daemon mode)
|
||||
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
|
||||
|
||||
### Motivation
|
||||
|
||||
When a peer joins a network for the first time (or reconnects after an
|
||||
absence), they have no history. This extension lets them request recent
|
||||
messages from an existing peer over the already-established encrypted
|
||||
DataChannel, without involving the anchor.
|
||||
|
||||
### Wire messages
|
||||
|
||||
#### `history_request`
|
||||
|
||||
Sent by the newly-connected peer to the first peer whose hello is verified.
|
||||
One request per room.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_request",
|
||||
"room": "general",
|
||||
"since": 1700000000000,
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------------|-------------|
|
||||
| `room` | string | Room to request history for. |
|
||||
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
|
||||
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
|
||||
|
||||
#### `history_chunk`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_chunk",
|
||||
"room": "general",
|
||||
"history": [
|
||||
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
|
||||
],
|
||||
"history_done": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|--------|-------------|
|
||||
| `history` | array | Messages, oldest-first. |
|
||||
| `history_done` | bool | Always `true` (single-chunk response). |
|
||||
|
||||
### Deduplication
|
||||
|
||||
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
|
||||
so receiving a message twice (live or via gossip) is a no-op. Messages
|
||||
without a `mid` are assigned one at receive time and are not gossipped.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The **receiver** sends one `history_request` per known room immediately
|
||||
after hello verification with the **first** peer it connects to. Requesting
|
||||
only the first peer avoids fan-out amplification.
|
||||
- The **responder** queries its SQLite store and replies with a single
|
||||
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
|
||||
request per (peer, room) per 60 seconds.
|
||||
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
|
||||
and emitted as `history_loaded` IPC events so the UI can display them.
|
||||
|
||||
### IPC event
|
||||
|
||||
```json
|
||||
{ "type": "history_loaded", "room": "general", "messages": [...] }
|
||||
```
|
||||
|
||||
Emitted once per room after a `history_chunk` is fully processed. The UI
|
||||
should render these messages with a visual separator from live messages.
|
||||
141
FUTURE.md
@@ -11,10 +11,10 @@ Two clean layers, connected by the IPC port.
|
||||
### Daemon
|
||||
The real application. A long-running background process that handles everything:
|
||||
|
||||
- Peer mesh and connection management
|
||||
- Cryptography and handshake
|
||||
- NAT traversal and relay fallback
|
||||
- File transfer
|
||||
- Peer mesh and connection management (WebRTC DataChannels, DTLS, ICE)
|
||||
- Cryptography and handshake (Ed25519 identity, nacl/box signaling, YAW/2.1 FS)
|
||||
- NAT traversal (ICE/STUN via pion/webrtc — no custom relay needed)
|
||||
- File transfer (dedicated binary DataChannels per transfer)
|
||||
|
||||
Exposes a local JSON API over TCP (`127.0.0.1:17337`). Can run headlessly — SSH into a box and the mesh stays alive even with no UI attached.
|
||||
|
||||
@@ -23,59 +23,116 @@ Talks to the daemon over the IPC port. The separation means the UI is replaceabl
|
||||
|
||||
Target: a web frontend (React or similar) wrapped in a native binary using a Tauri-style approach — native packaging, OS webview, no Electron weight. Avoids the wxWidgets ugliness of the old wxWASTE fork and the Qt licensing headaches of the VIA fork.
|
||||
|
||||
#### TUI (near-term)
|
||||
A terminal UI is worth building first, as a `cmd/tui` using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Since the IPC contract is already the full boundary, a TUI is just another client — connect to `127.0.0.1:17337`, receive the `state_snapshot`, then funnel incoming events into Bubble Tea's update loop. Incoming mesh events map naturally onto its Elm-style message model.
|
||||
#### TUI ✅ (shipped)
|
||||
A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Three-pane layout: rooms, messages, peers. Supports group chat, DMs, room switching, invite generation. Works over SSH.
|
||||
|
||||
Benefits over jumping straight to a native GUI:
|
||||
- Works over SSH; zero packaging complexity
|
||||
- Validates the full IPC protocol and message flow end-to-end
|
||||
- Useful day-to-day while the native UI is still future work
|
||||
|
||||
The TUI doesn't replace the long-term GUI — it won't serve non-technical friends — but it's the right first UI milestone.
|
||||
#### Web UI ✅ (shipped)
|
||||
React + Vite frontend. Two modes:
|
||||
- **Browser mode** — runs entirely in-browser, connects directly to the anchor via WebSocket. No daemon required. Identity persists in `localStorage`. File sharing, file push, per-peer ICE/NAT status.
|
||||
- **Daemon mode** — web UI connects to a local daemon over WebSocket IPC. Same UI, different adapter.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Modernization
|
||||
## Protocol
|
||||
|
||||
### NAT Traversal
|
||||
The main unsolved problem from the original WASTE — one party always needed an open port.
|
||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
||||
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
||||
|
||||
- Try UDP hole punching (STUN) first
|
||||
- Fall back to an encrypted relay (DERP-style) when hole punching fails
|
||||
- Relay sees only opaque encrypted blobs — end-to-end encryption holds
|
||||
- Run the relay on a Hetzner VPS; `waste-relay` already implements the blind-forward model
|
||||
### TURN relay ✅ (shipped)
|
||||
Both browser and daemon modes support TURN relay.
|
||||
|
||||
### Bootstrapping & Rendezvous
|
||||
No DHT needed at small group scale (10–50 nodes). Keep it simple:
|
||||
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
|
||||
|
||||
- Each peer generates an Ed25519 keypair on first run — the public key **is** their identity
|
||||
- Share a small signed invite file (`.waste-invite`) out of band: email, Signal, whatever
|
||||
- The invite contains: current IP:port hint + public key + short-lived signature
|
||||
- Once two peers connect, they gossip each other's addresses to mutual friends
|
||||
- A local known-peers list in the data directory is sufficient at this scale
|
||||
**Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.
|
||||
|
||||
### Identity
|
||||
- Persistent Ed25519 keypair, generated once
|
||||
- Public key = stable identity, not a mutable nickname
|
||||
- No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases
|
||||
### Signaling ✅ YAW/2.1 (shipped)
|
||||
Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
||||
|
||||
### Transport (Long-term)
|
||||
Current transport is TCP with custom framing. QUIC is worth revisiting once the core is solid — it gives multiplexing and better NAT traversal behavior essentially for free.
|
||||
### Multi-Network Support ✅ (shipped)
|
||||
One daemon, multiple simultaneously-joined networks. Per-network HKDF-derived identities prevent cross-network correlation. Network-scoped SQLite stores.
|
||||
|
||||
### Invite System ✅ (shipped)
|
||||
`waste:<base64>` URIs encoding anchor URL + network name. `--join` flag on daemon and TUI. `Ctrl+I` in TUI or `generate_invite` via IPC.
|
||||
|
||||
### File Transfer ✅ (shipped)
|
||||
Dedicated binary DataChannel per transfer (`f:<xid>`). SHA-256 integrity verification. 64 KiB chunks with backpressure. Auto-accept. In browser mode: both pull (browse peer's shared folder) and push (📎 send directly to a peer).
|
||||
|
||||
### Peer Gossip ✅ (shipped)
|
||||
When a new peer connects, the mesh immediately gossips the full peer list to them (`peer_gossip` wire message). New arrivals discover existing peers without needing the anchor to re-introduce them. The anchor becomes optional once the first handshake has happened — the mesh self-heals around anchor downtime.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Session Persistence ✅ (shipped)
|
||||
Browser mode now auto-rejoins on reload. The last-used network name, alias, and anchor URL are saved to `localStorage` on join and restored on load. A ⏻ logout button in the sidebar clears session state (optionally including the identity keypair) and reloads the page.
|
||||
|
||||
### Per-Network Share Directories ✅ (shipped)
|
||||
Share state is tracked per `network_id` in the store (`sharedFilesByNetwork`). Switching networks switches the active share.
|
||||
|
||||
### Persistent Multi-Share Configuration ✅ (shipped)
|
||||
Multiple share roots per network, with global (all networks) or scoped visibility.
|
||||
|
||||
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
|
||||
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
|
||||
|
||||
### Additional Channels / Rooms ✅ (shipped)
|
||||
Custom rooms are supported in both the web UI and the TUI.
|
||||
|
||||
**Web UI:** The `+` button in the Rooms sidebar creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically.
|
||||
|
||||
**TUI:** Type `/room <name>` in the input to create a room. The daemon persists it in the `rooms` SQLite table and echoes a `room_created` IPC event back. On reconnect, rooms are restored via `state_snapshot`. Rooms that receive messages while not active show a `*` prefix in the sidebar; the marker clears when you switch to that room.
|
||||
|
||||
DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages arrive.
|
||||
|
||||
### File Transfer UX ✅ (shipped)
|
||||
- Manual accept/reject via the Transfers panel in the sidebar
|
||||
- Transfer cancellation (`file-cancel` message, closes DataChannel)
|
||||
- Live progress bar per active transfer
|
||||
- Push (📎) sends directly to a peer without them needing to share a folder
|
||||
|
||||
**Not yet done:** daemon-side resume UX (IPC event to surface resumable transfers to the UI on reconnect).
|
||||
|
||||
### Native UI
|
||||
Web frontend (React, already built) + [Wails v2](https://wails.io) shell for native packaging. Wails is Go-native — no Rust toolchain required. The daemon runs embedded in the same process; the webview connects to the existing WebSocket IPC at `ws://127.0.0.1:17338`. Scaffolded in `cmd/app/`; build with `./build-app.sh`. Remaining work: system tray, OS notifications.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Priority | Item |
|
||||
| Status | Item |
|
||||
|---|---|
|
||||
| 1 | Deploy `waste-relay` to Hetzner; verify cross-internet NAT traversal |
|
||||
| 2 | Invite file format (`.waste-invite`) — solve bootstrapping without manual IP sharing |
|
||||
| 3 | Peer gossip — auto-connect to friends-of-friends after initial invite |
|
||||
| 4 | File transfer — chunked, encrypted, resumable |
|
||||
| 5 | Message persistence — SQLite via `modernc.org/sqlite` |
|
||||
| 6 | UI — web frontend consuming the IPC port; native packaging |
|
||||
| 7 | UDP hole punching — full STUN implementation in `internal/nat` |
|
||||
| 8 | QUIC transport — replace TCP framing for better NAT behavior |
|
||||
| ✅ shipped | Daemon + anchor server |
|
||||
| ✅ shipped | WebRTC DataChannels (ICE/STUN hole punching) |
|
||||
| ✅ shipped | Ed25519 identity, nacl/box signaling (YAW/2.0 + 2.1) |
|
||||
| ✅ shipped | IPC protocol — join/leave/chat/DM/state |
|
||||
| ✅ shipped | Message persistence (SQLite, per-network) |
|
||||
| ✅ shipped | TUI (`cmd/tui`, Bubble Tea) |
|
||||
| ✅ shipped | Invite system (`waste:` URI, `--join` flag) |
|
||||
| ✅ shipped | Multi-network support (HKDF derived identities) |
|
||||
| ✅ shipped | File transfer (binary DataChannels, pull + push) |
|
||||
| ✅ shipped | Forward-secret signaling (YAW/2.1 ephemeral X25519) |
|
||||
| ✅ shipped | Peer gossip (anchor-free mesh reconnection) |
|
||||
| ✅ shipped | Web UI — browser mode + daemon mode |
|
||||
| ✅ shipped | Per-peer NAT/ICE status, identity backup/restore |
|
||||
| ✅ shipped | Per-network share directories |
|
||||
| ✅ shipped | Additional channels/rooms per network |
|
||||
| ✅ shipped | TURN relay (browser mode, coturn `use-auth-secret`) |
|
||||
| ✅ shipped | File transfer UX (progress, cancel, manual accept) |
|
||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
|
||||
| ✅ shipped | TUI room creation + daemon-side room persistence |
|
||||
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
|
||||
| ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) |
|
||||
| ✅ shipped | File transfer resume after disconnection |
|
||||
| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android |
|
||||
| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary |
|
||||
| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) |
|
||||
|
||||
---
|
||||
|
||||
@@ -84,5 +141,5 @@ Current transport is TCP with custom framing. QUIC is worth revisiting once the
|
||||
- **Small group** — not a public network, not federated, not discoverable
|
||||
- **No registration** — no phone number, no email, no central service
|
||||
- **Encrypted everything** — at rest and in transit, end to end
|
||||
- **Equal nodes** — no peer is "the server"; the relay is dumb infrastructure only
|
||||
- **Equal nodes** — no peer is "the server"; the anchor is dumb infrastructure
|
||||
- **The soul** — a private overlay for people you actually trust
|
||||
|
||||
154
PROPOSAL-history-gossip.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Proposal: P2P Message History Gossip
|
||||
|
||||
## Goals
|
||||
|
||||
- New peers joining a network can retrieve recent message history from existing peers
|
||||
- No central storage — history lives only in peer daemons (SQLite)
|
||||
- No new trust requirements — history is shared only over already-established encrypted DataChannels
|
||||
- No duplicates in the local store or UI
|
||||
- No conflicts — history gossip is append-only and idempotent
|
||||
|
||||
---
|
||||
|
||||
## Privacy Model
|
||||
|
||||
History is shared **peer-to-peer over the encrypted mesh**, never via the anchor. The anchor remains dumb — it sees only signaling blobs. A peer only receives history from peers they have successfully completed a YAW/2 handshake with, so the same trust boundary as live messages applies.
|
||||
|
||||
A peer can choose not to share history by ignoring `history_request` messages — the protocol is advisory, not mandatory.
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
Two new YAW/2 extension messages (added to EXTENSIONS.md):
|
||||
|
||||
### `history_request`
|
||||
|
||||
Sent by a newly-connected peer to one or more existing peers shortly after the handshake completes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_request",
|
||||
"room": "general",
|
||||
"since": "2026-01-01T00:00:00Z",
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|-----------------|----------------------------------------------------------|
|
||||
| `room` | string | Room name to request history for. One request per room. |
|
||||
| `since` | ISO 8601 or "" | Only return messages newer than this timestamp. Empty = return up to `limit` most recent. |
|
||||
| `limit` | int (max 500) | Maximum number of messages to return. Responder may return fewer. |
|
||||
|
||||
### `history_chunk`
|
||||
|
||||
Response from an existing peer. May be sent in multiple chunks if `limit` is large.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_chunk",
|
||||
"room": "general",
|
||||
"messages": [
|
||||
{
|
||||
"id": "sha256:<hex>",
|
||||
"from": "<peer-alias>",
|
||||
"from_id": "<hex-pubkey>",
|
||||
"body": "hello",
|
||||
"ts": "2026-06-01T12:00:00Z",
|
||||
"room": "general"
|
||||
}
|
||||
],
|
||||
"done": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------|----------------------------------------------------------|
|
||||
| `messages` | array | Ordered oldest-first. |
|
||||
| `done` | bool | `true` on the final chunk. Receiver may display after this. |
|
||||
|
||||
---
|
||||
|
||||
## Message Identity and Deduplication
|
||||
|
||||
Each message has a **content-addressed ID**:
|
||||
|
||||
```
|
||||
id = "sha256:" + hex(SHA-256(from_id || room || ts || body))
|
||||
```
|
||||
|
||||
- Computed by the original sender and included in every live message going forward
|
||||
- The SQLite `messages` table gains an `id TEXT UNIQUE` column
|
||||
- On insert, use `INSERT OR IGNORE` — receiving the same message twice (live or via gossip) is a no-op
|
||||
- The UI sorts by `ts`, so late-arriving history slots in correctly without reordering visible messages
|
||||
|
||||
Legacy messages (before this feature) have no `id`. They are assigned a local-only ID on migration and are never gossipped (they have no canonical ID the receiver could deduplicate against).
|
||||
|
||||
---
|
||||
|
||||
## Daemon-Side Implementation
|
||||
|
||||
### SQLite schema change
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages ADD COLUMN msg_id TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id) WHERE msg_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Handling `history_request`
|
||||
|
||||
```
|
||||
peer sends history_request{room, since, limit}
|
||||
→ query SQLite: SELECT * FROM messages WHERE room=? AND ts>? ORDER BY ts ASC LIMIT ?
|
||||
→ send history_chunk{room, messages, done:true}
|
||||
```
|
||||
|
||||
Responder enforces:
|
||||
- `limit` capped at 500
|
||||
- Only messages the responder itself received or sent (no re-gossipping of gossipped history to avoid amplification)
|
||||
- Rate limit: one `history_request` per peer per room per 60 seconds
|
||||
|
||||
### Handling `history_chunk`
|
||||
|
||||
```
|
||||
for each message in chunk:
|
||||
INSERT OR IGNORE INTO messages (msg_id, room, from_alias, from_id, body, ts) VALUES (...)
|
||||
emit IPC event: history_loaded{room, count}
|
||||
```
|
||||
|
||||
### When to request
|
||||
|
||||
- After handshake completes with the **first** peer in a network (only ask one peer — avoids fan-out)
|
||||
- Request rooms the local peer knows about (from its own SQLite `rooms` table)
|
||||
- If no rooms known yet: request `"general"` only; discover others from incoming live messages
|
||||
|
||||
---
|
||||
|
||||
## IPC / UI Integration
|
||||
|
||||
New IPC event emitted after history is loaded:
|
||||
|
||||
```json
|
||||
{ "type": "history_loaded", "network_id": "...", "room": "general", "count": 47 }
|
||||
```
|
||||
|
||||
The web UI and TUI insert a visual separator above the first gossipped message:
|
||||
|
||||
```
|
||||
── 47 earlier messages ─────────────────────────────
|
||||
[12:03] alice: hey
|
||||
[12:04] bob: yo
|
||||
── live ─────────────────────────────────────────────
|
||||
[14:22] you joined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What This Does Not Do
|
||||
|
||||
- **No conflict resolution** — messages are immutable append-only records; there is nothing to conflict
|
||||
- **No ordering guarantee beyond timestamp** — if two peers sent messages at the same millisecond, both are stored; the UI sorts by `ts` then `msg_id` for a stable tiebreak
|
||||
- **No full sync** — gossip is bounded by `limit` and `since`; it is not a replication protocol
|
||||
- **No anchor involvement** — the anchor never sees history
|
||||
- **No history from peers who were offline** — if no peer with history is online when you join, you get nothing (acceptable given the trust model)
|
||||
519
PROTOCOL.md
Normal file
@@ -0,0 +1,519 @@
|
||||
# YAW/2.0 — Protocol Specification
|
||||
|
||||
**Version:** `yaw/2.0` · **Status:** 🔒 **LOCKED** (frozen wire — interop baseline)
|
||||
· clean break from WASTE 1.x.
|
||||
|
||||
> **This document is frozen.** Independent implementations interoperate against
|
||||
> this exact wire and the live server. Do **not** change 2.0. New protocol work —
|
||||
> currently forward-secret signaling — lives in
|
||||
> [yaw2.1-protocol.md](yaw2.1-protocol.md), motivated by
|
||||
> [YIP-0001](proposals/yip-0001-forward-secret-signaling.md). See [README](README.md).
|
||||
|
||||
YAW/2 is a small, trusted, peer-to-peer encrypted mesh — chat, presence, and file
|
||||
transfer — that pierces NAT the way the modern web does (ICE) and rides a modern
|
||||
encrypted transport (WebRTC DataChannels). A lightweight server (the **anchor**)
|
||||
helps peers *find* and *introduce* each other but never carries their data.
|
||||
|
||||
## 0. Design goals & decisions
|
||||
|
||||
- **True peer-to-peer.** Data flows directly between peers. The anchor only does
|
||||
signaling + STUN. *No relay (TURN).* Consequence accepted: peers learn each
|
||||
other's IP on a direct link, and a minority of peers behind symmetric NAT will
|
||||
fail to connect.
|
||||
- **Modern transport.** WebRTC **DataChannels** — DTLS 1.2/1.3 (PFS, AEAD),
|
||||
congestion control, multiplexed reliable streams. File transfer is just a stream.
|
||||
- **Modern identity.** **Ed25519** identities. Trust is friend-to-friend: you talk
|
||||
to a peer only if its public key is in your keyring (exchanged out-of-band).
|
||||
- **Browser-first.** The reference client is a web app (WebRTC native). A **Tauri**
|
||||
shell wraps it for desktop (Electron is dropped); a **Python CLI** (`aiortc`)
|
||||
speaks the same protocol.
|
||||
- **The server learns as little as possible.** Signaling payloads are sealed end
|
||||
to end, so the anchor never sees SDP, candidate IPs, chat, or files — only that
|
||||
two fingerprints are online in the same (hashed) network and exchanging blobs.
|
||||
|
||||
## 1. Terminology
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **node / peer** | A participant, identified by its Ed25519 public key. |
|
||||
| **id** | Lowercase hex of the 32-byte Ed25519 public key (64 chars). The node's identity. |
|
||||
| **short id** | First 16 hex chars of `id`, grouped in 4s, for human verification. |
|
||||
| **keyring** | The set of peer `id`s you have accepted (trust). |
|
||||
| **network** | A named group. Scoped on the server by `net = hex(sha256("yaw2-net:" + name))`, so the server never sees the name. |
|
||||
| **anchor** | The server: a WebSocket **signaling** endpoint + a **STUN** server. |
|
||||
| **session** | One established WebRTC PeerConnection between two peers. |
|
||||
|
||||
## 2. Identity & trust
|
||||
|
||||
- Each node has an **Ed25519** keypair. The 32-byte public key (hex) is its `id`.
|
||||
- A node connects to another **only if that peer's `id` is in its keyring.** Keys
|
||||
are exchanged out of band (paste the hex, a QR code, or `yaw://key/<id>`).
|
||||
- Human verification: compare **short id**s ("read me yours") before accepting.
|
||||
- The keyring is the sole trust root. The anchor is *not* trusted to vouch for
|
||||
identities; it cannot impersonate a peer (§7).
|
||||
|
||||
## 3. Cryptography
|
||||
|
||||
| Purpose | Primitive |
|
||||
|---------|-----------|
|
||||
| Identity / signatures | **Ed25519** (libsodium `crypto_sign_detached`) |
|
||||
| Signaling confidentiality + sender auth | **X25519 + crypto_box** (XSalsa20-Poly1305). X25519 keys derived from the Ed25519 identity via `crypto_sign_ed25519_{pk,sk}_to_curve25519`. |
|
||||
| Transport | **WebRTC DTLS** (ECDHE, AES-GCM/ChaCha20-Poly1305) over SCTP DataChannels. Per-session forward secrecy. |
|
||||
| Hashes | SHA-256 (network scoping, file integrity) |
|
||||
|
||||
> **Why the DTLS cert is *not* your Ed25519 key.** Browsers generate their own
|
||||
> ephemeral cert for `RTCPeerConnection`; you cannot make it your identity key.
|
||||
> Instead we **bind** the identity to the session: the SDP (which contains the
|
||||
> DTLS certificate fingerprint) travels inside an Ed25519-authenticated sealed
|
||||
> box, and peers re-confirm with a signed `HELLO` over both fingerprints once the
|
||||
> channel opens (§6). The result is equivalent: the encrypted channel is provably
|
||||
> to the holder of the trusted Ed25519 key.
|
||||
|
||||
All implementations use **libsodium** (PyNaCl, libsodium.js, or a Rust binding) so
|
||||
the signing and sealing are byte-identical across clients.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
┌──────────────── anchor (server) ────────────────┐
|
||||
│ WSS signaling (relays sealed blobs by id) │
|
||||
│ STUN udp/3478 (public-address discovery) │
|
||||
└───────▲───────────────────────────▲─────────────┘
|
||||
│ sealed offer/answer/cands │
|
||||
┌───────┴───────┐ ┌───────┴───────┐
|
||||
│ peer A │◀═══════════▶│ peer B │
|
||||
│ web / cli / │ WebRTC │ web / cli / │
|
||||
│ tauri │ DataChannel │ tauri │
|
||||
└───────────────┘ (DTLS, P2P) └───────────────┘
|
||||
direct, encrypted, no server in path
|
||||
```
|
||||
|
||||
The anchor has **two** jobs and sees **no** user data:
|
||||
|
||||
1. **Signaling (WebSocket, `wss://`)** — authenticates members of a network and
|
||||
relays opaque sealed blobs between them by `id`.
|
||||
2. **STUN (UDP/3478)** — standard STUN (RFC 5389), e.g. `stun:<anchor-host>:3478`,
|
||||
used as an ICE server so peers learn their public (server-reflexive) address.
|
||||
*STUN only — no TURN relay.*
|
||||
|
||||
## 5. Signaling protocol (WebSocket, JSON)
|
||||
|
||||
One WebSocket per node. All frames are **UTF-8 text** JSON objects with a `type`
|
||||
(binary WebSocket frames are ignored).
|
||||
|
||||
**Encoding conventions (apply everywhere unless stated):**
|
||||
|
||||
- **hex** = lowercase, no separators.
|
||||
- **base64** = standard RFC 4648 **with padding** (libsodium `base64_variants.ORIGINAL`
|
||||
— *not* the url-safe / no-padding default).
|
||||
- **`net`** = `hex(sha256(b))` where `b = utf8("yaw2-net:" + name)`; the name is taken
|
||||
**verbatim** (case-sensitive, no normalization/trimming).
|
||||
- **`id`** = `hex(ed25519_public_key)` (32 bytes → 64 hex).
|
||||
- Signature inputs are raw bytes (concatenated as written); signatures are Ed25519
|
||||
detached (64 bytes), hex-encoded.
|
||||
|
||||
### 5.1 Join (authentication)
|
||||
|
||||
```
|
||||
server → { "v":"yaw/2.0", "type":"challenge", "nonce":"<32-byte hex>" }
|
||||
client → { "type":"join",
|
||||
"id": "<ed25519 pubkey hex>",
|
||||
"net": "<net hex>",
|
||||
"sig": "<ed25519 sig, hex>" }
|
||||
server → { "type":"joined", "peers":[ "<id>", ... ] } // current members of net
|
||||
```
|
||||
|
||||
`sig` is over the **exact bytes** `nonce_raw || net_ascii`, where `nonce_raw =
|
||||
hex_decode(nonce)` (32 bytes) and `net_ascii = utf8(net)` (the 64 ASCII hex
|
||||
chars). The server verifies `sig` against `id`, then registers the socket under
|
||||
`(net, id)`. A node may join only one `net` per socket. Bad signature → the server
|
||||
closes with code **4001**; a second connection for the same `(net, id)` displaces
|
||||
the first with code **4002**.
|
||||
|
||||
### 5.2 Presence
|
||||
|
||||
```
|
||||
server → { "type":"peer-join", "id":"<id>" }
|
||||
server → { "type":"peer-leave", "id":"<id>" }
|
||||
```
|
||||
|
||||
Pushed to all members of the same `net` as peers come and go.
|
||||
|
||||
### 5.3 Sealed relay
|
||||
|
||||
```
|
||||
client → { "type":"to", "to":"<id>", "box":"<base64 crypto_box>" }
|
||||
server → { "type":"from", "from":"<id>", "box":"<base64 crypto_box>" }
|
||||
```
|
||||
|
||||
The server forwards `box` verbatim to the socket registered for `(net, to)`,
|
||||
stamping the real `from`. **The server cannot read `box`.** If `to` is offline,
|
||||
the server replies `{ "type":"no-peer", "to":"<id>" }`.
|
||||
|
||||
### 5.4 Sealed payload (inside `box`)
|
||||
|
||||
`box = crypto_box(plaintext, nonce, recipient_x25519_pub, sender_x25519_priv)`
|
||||
(X25519 + XSalsa20-Poly1305), serialized as **`base64(nonce(24) || mac(16) ||
|
||||
ciphertext)`** (i.e. the 24-byte nonce prepended to libsodium's combined-mode
|
||||
output; PyNaCl's `Box.encrypt(msg, nonce)` already produces exactly this). The
|
||||
X25519 keys are derived from the Ed25519 identities (§3); the recipient uses the
|
||||
sender's, taken from the `from` id. `plaintext` is JSON:
|
||||
|
||||
```
|
||||
{ "kind": "offer" | "answer" | "candidate" | "bye",
|
||||
"sdp": "<full SDP>", // for offer/answer (candidates embedded; see §6)
|
||||
"cand": "<ICE candidate line>", "mid":"0", "mline":0 } // optional trickle (§6)
|
||||
```
|
||||
|
||||
Because the box is authenticated by the sender's identity key, a received offer's
|
||||
SDP — **including the DTLS fingerprint** — is bound to that identity.
|
||||
|
||||
## 6. Connection establishment
|
||||
|
||||
Both peers are joined to the same `net` and **each has the other's `id` in its
|
||||
keyring**. (Untrusted `id` → ignore, or hold for manual accept.)
|
||||
|
||||
**Who offers:** the peer with the lexicographically **smaller `id`** is the
|
||||
*offerer* (deterministic; avoids glare).
|
||||
|
||||
```
|
||||
A = offerer (smaller id) B = answerer
|
||||
─────────────────────────────── ───────────────────────────────
|
||||
pc = RTCPeerConnection({iceServers:[stun]}) pc = RTCPeerConnection({iceServers:[stun]})
|
||||
dc = pc.createDataChannel("yaw") pc.ondatachannel = …
|
||||
createOffer; setLocalDescription
|
||||
WAIT for ICE gathering complete ◀── candidates embedded in SDP (non-trickle)
|
||||
seal(offer.sdp) ─────────"to B"────────────────▶ verify from∈keyring; setRemoteDescription
|
||||
createAnswer; setLocalDescription
|
||||
WAIT for ICE gathering complete
|
||||
verify; setRemoteDescription ◀──"to A"─seal(answer.sdp)
|
||||
ICE connectivity checks (host + srflx) → DTLS handshake
|
||||
"yaw" DataChannel opens on both sides
|
||||
───────────────── identity confirm (mandatory) ─────────────────
|
||||
each side, on open, sends on "yaw":
|
||||
{ "type":"hello", "id":"<self id>", "nick":"…", "sig":"<hex>" }
|
||||
verify (below). Mismatch → close the connection.
|
||||
```
|
||||
|
||||
**ICE is non-trickle (baseline).** After `setLocalDescription`, wait until ICE
|
||||
gathering is `complete`, then send the SDP with candidates embedded. Sending extra
|
||||
`{kind:"candidate"}` messages is **optional** and additive; receivers MUST accept
|
||||
candidates from the SDP and SHOULD accept trickled ones. Gather **host +
|
||||
server-reflexive** (STUN) candidates; no TURN. If ICE fails (both behind symmetric
|
||||
NAT) the session is abandoned (relay is optional, §8.4).
|
||||
|
||||
**DataChannels are in-band negotiated** (`negotiated:false`): the offerer creates
|
||||
`"yaw"`; the answerer receives it via `ondatachannel`. ⚠️ *A received channel may
|
||||
already be `open` when `ondatachannel`/event fires — send your `hello` both on the
|
||||
`open` event **and** immediately if `readyState === "open"`, or you will deadlock.*
|
||||
|
||||
**Identity confirm — exact bytes.** Let `lfp`/`rfp` be the **raw 32-byte** SHA-256
|
||||
DTLS fingerprints parsed from the local/remote SDP `a=fingerprint:sha-256 …` lines
|
||||
(strip the colons, hex-decode). Implementations MUST use `sha-256` fingerprints.
|
||||
|
||||
- **Sender** signs `B = utf8("yaw/2 bind") || lfp || rfp` (its *own* local then
|
||||
remote) and sends `sig = hex(ed25519_sign(B))` in `hello`.
|
||||
- **Verifier** reconstructs the sender's bytes — which are the verifier's **remote
|
||||
then local** — i.e. checks `ed25519_verify(sig, utf8("yaw/2 bind") || rfp || lfp,
|
||||
peer_id)` **and** that `peer_id` equals the expected (keyring) id. Either check
|
||||
failing ⇒ close.
|
||||
|
||||
After `hello` verification the session is **trusted and live**.
|
||||
|
||||
## 7. Why this is safe against a malicious anchor
|
||||
|
||||
- The anchor relays only **sealed, sender-authenticated** blobs, so it cannot read
|
||||
or forge SDP/candidates, and cannot inject its own DTLS fingerprint (that would
|
||||
require an Ed25519 signature it cannot produce).
|
||||
- The `hello` confirmation re-binds the live channel to both DTLS fingerprints
|
||||
under each identity's signature.
|
||||
- Therefore a hostile anchor can: see who is online in a `net`, see *that* two ids
|
||||
exchange blobs, drop/delay messages, and learn timing. It **cannot**: read or
|
||||
alter chat/files, MITM the channel, learn candidate IPs, or recover the network
|
||||
name (only confirm a guess of it).
|
||||
|
||||
## 8. Application protocol (over the `yaw` DataChannel)
|
||||
|
||||
The `yaw` channel is reliable + ordered. Each DataChannel message is one
|
||||
UTF-8 JSON object (DataChannels are message-framed — no length prefix needed).
|
||||
Unknown `type`s and unknown fields are ignored (forward compatibility). In v1
|
||||
(full mesh, §8.4) there are no duplicates, so **`mid` is optional**; it becomes
|
||||
**required only when relay (`hops`) is used**, as a random 16-byte hex id for dedup.
|
||||
|
||||
| type | fields | meaning |
|
||||
|------|--------|---------|
|
||||
| `hello` | `id, nick, sig` (+ optional `caps[]`) | identity confirm (§6); first message |
|
||||
| `presence` | `online:bool, nick` | online/away |
|
||||
| `chat` | `room, text, ts` | group message to a room (default `#main`) |
|
||||
| `pm` | `text, ts` | private message (this link only) |
|
||||
| `file-offer` | `xid, name, size, sha256` | offer to send a file |
|
||||
| `file-accept` | `xid` | accept an offer |
|
||||
| `file-cancel` | `xid, reason` | decline / abort |
|
||||
| `file-done` | `xid, sha256` | sender finished; verify hash |
|
||||
| `bye` | — | graceful close |
|
||||
|
||||
`ts` is Unix milliseconds (advisory). `room` names are app-defined strings.
|
||||
|
||||
### 8.4 Group delivery (v1 = full mesh)
|
||||
|
||||
In v1 each peer connects **directly to every other** peer in the network (full
|
||||
mesh of sessions). `chat`/`presence` are sent to **all** open sessions; `pm` to one.
|
||||
Each message is received once per session, so no dedup is needed (and `mid` may be
|
||||
omitted). Dedup matters only once relay is enabled below.
|
||||
|
||||
> *Forward-compatible relay (optional, v1.1):* messages may carry `hops` (int, ≤4).
|
||||
> A node receiving a message with `hops>0` whose `mid` is new MAY re-send it to its
|
||||
> other peers with `hops-1`, restoring connectivity across pairs that couldn't form
|
||||
> a direct session. v1 senders set `hops:0` (no relay).
|
||||
|
||||
## 9. File transfer (over a dedicated DataChannel)
|
||||
|
||||
Files ride their own channel so a large transfer never blocks chat.
|
||||
|
||||
```
|
||||
sender receiver
|
||||
file-offer {xid,name,size,sha256} ──"yaw"───────▶ (user accepts)
|
||||
◀──"yaw"──── file-accept {xid}
|
||||
open DataChannel label="f:<xid>" (ordered,binary)
|
||||
stream raw chunks (default 64 KiB), honoring
|
||||
bufferedAmountLowThreshold for backpressure ───▶ append to file; running sha256
|
||||
close "f:<xid>" after last chunk
|
||||
file-done {xid, sha256} ────────"yaw"───────────▶ verify sha256; success/failure
|
||||
```
|
||||
|
||||
- Chunk size: **64 KiB** default — but never exceed the session's negotiated
|
||||
`a=max-message-size` (SDP); clamp down if the peer advertises a smaller limit.
|
||||
- Integrity: SHA-256 over the whole file, sent in the offer and re-asserted in
|
||||
`file-done`; the receiver verifies before accepting the file.
|
||||
- The transport (DTLS) already encrypts; no extra app-layer file encryption.
|
||||
- Either side may `file-cancel {xid}`; the data channel is closed.
|
||||
|
||||
## 10. Reference parameters
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Protocol version | `yaw/2.0` |
|
||||
| Signaling | `wss://<your-anchor>/<secret-path>/signal` (WebSocket) **[deployed & verified]** |
|
||||
| STUN | `stun:<anchor-host>:3478` (coturn, STUN-only, **deployed & verified**) |
|
||||
| Network scope | `net = hex(sha256("yaw2-net:" + name))` |
|
||||
| Identity | Ed25519; `id = hex(pubkey)` (64 chars) |
|
||||
| Signaling seal | libsodium `crypto_box`, `base64_ORIGINAL(nonce(24)||mac(16)||ct)` |
|
||||
| Bind (sign) | `utf8("yaw/2 bind") || local_fp || remote_fp` (raw 32-byte fps) |
|
||||
| Signaling close codes | 4001 = auth failed · 4002 = displaced by reconnect |
|
||||
| DataChannel (control) | label `yaw`, reliable, ordered |
|
||||
| DataChannel (file) | label `f:<xid>`, reliable, ordered, binary |
|
||||
| File chunk | 64 KiB |
|
||||
| Default room | `#main` |
|
||||
|
||||
## 11. Security considerations
|
||||
|
||||
- **IP exposure (by design).** On a direct session each peer sees the other's host
|
||||
(LAN) and server-reflexive (public) addresses. The *anchor* does not (sealed
|
||||
signaling). To reduce LAN leakage, a client MAY gather srflx-only candidates at
|
||||
some connectivity cost.
|
||||
- **Symmetric-NAT pairs may not connect** (no TURN). Optional app-relay (§8.4) or a
|
||||
future opt-in TURN can recover these.
|
||||
- **Signaling metadata.** The anchor learns presence and the contact graph within a
|
||||
`net`, plus timing. It does not learn names, content, or IPs.
|
||||
- **Signaling boxes are not forward-secret** (static X25519). They carry only
|
||||
short-lived SDP/candidates; the *session* keys are DTLS-ephemeral (PFS). A future
|
||||
revision may use ephemeral signaling keys.
|
||||
- **Trust bootstrapping is out of band.** Compromise of the keyring exchange (e.g.
|
||||
accepting a wrong `id`) defeats the system — verify short ids.
|
||||
- **Replay.** The join `nonce` is single-use (server-issued per connection); DTLS
|
||||
prevents transport replay; `mid` dedups app messages once relay is enabled.
|
||||
|
||||
## 12. Differences from YAW/1 (WASTE)
|
||||
|
||||
| | YAW/1 (WASTE-faithful) | YAW/2 |
|
||||
|--|--|--|
|
||||
| Identity | RSA + SHA-1 fingerprint | Ed25519, key = id |
|
||||
| Transport crypto | Blowfish-PCBC (legacy) | WebRTC DTLS (AEAD, PFS) |
|
||||
| Handshake | custom 30-step | ICE + DTLS + signed bind |
|
||||
| NAT traversal | none (needs reachable peer) | ICE/STUN (true P2P) |
|
||||
| Server role | rendezvous *directory* | signaling + STUN, sealed |
|
||||
| Topology | flood mesh w/ TTL | direct full mesh (relay optional) |
|
||||
| Transport library | hand-rolled sockets | WebRTC (browser/aiortc) |
|
||||
|
||||
## 13. Open questions / future
|
||||
|
||||
- Opt-in **TURN** for symmetric-NAT pairs (breaks "no relay" — explicit choice).
|
||||
- **Gossip relay** (§8.4 `hops`) for partial-connectivity resilience.
|
||||
- **Ephemeral signaling keys** for forward-secret signaling.
|
||||
- **Post-quantum**: hybrid X25519+ML-KEM once WebRTC/libsodium support is routine.
|
||||
- **Room key distribution** (anchor optionally serves a network's member ids to
|
||||
ease group bootstrapping, still keyring-gated).
|
||||
|
||||
## 14. Implementing & testing against the live server
|
||||
|
||||
The reference infra is **live**: STUN `stun:<anchor-host>:3478` and signaling
|
||||
`wss://<your-anchor>/<secret-path>/signal` (both deployed & verified). To interop:
|
||||
|
||||
1. Open the WebSocket, complete the `join` (§5.1), and you'll see `peers` /
|
||||
`peer-join` — that alone confirms your Ed25519 join signature is correct.
|
||||
2. Pick a shared **network name** with your test partner; both hash it identically
|
||||
(§5 conventions). Then run the connection flow (§6).
|
||||
3. Cross-check against the reference client: run `cli/spike_peer.py <network>`
|
||||
(Python/aiortc) — it will dial your implementation and chat/transfer a file.
|
||||
|
||||
**Three gotchas that break naïve implementations (learned the hard way):**
|
||||
|
||||
- **base64 variant.** The seal is **standard padded** base64, not libsodium's
|
||||
default url-safe/no-padding. Using `to_base64(x)` without
|
||||
`base64_variants.ORIGINAL` produces an unopenable box.
|
||||
- **bind byte order on verify.** The sender signs `prefix||local||remote`; the
|
||||
verifier must reconstruct `prefix||remote||local` (its remote = the sender's
|
||||
local). Getting this backwards verifies nothing.
|
||||
- **answerer DataChannel open-race.** The received `"yaw"` channel is often already
|
||||
`open` when you get it; send your `hello` on `readyState==="open"` too, not only
|
||||
the `open` event, or both sides wait forever.
|
||||
|
||||
> **Versioning.** 2.0 is **locked**. The one known weakness — signaling is not
|
||||
> forward-secret (§11) — is intentionally left as-is here so interop is stable. The
|
||||
> fix is specified separately as **[yaw/2.1](yaw2.1-protocol.md)** and motivated in
|
||||
> **[YIP-0001](proposals/yip-0001-forward-secret-signaling.md)**; 2.1 peers fall
|
||||
> back to 2.0 so both versions interoperate.
|
||||
|
||||
---
|
||||
|
||||
*Implement against §5–§9; everything else is rationale. Clients MUST interoperate
|
||||
at the signaling JSON, the sealed-payload format, the identity-confirm `hello`, and
|
||||
the application message types.*
|
||||
|
||||
|
||||
# YAW/2.1 — Protocol Specification (forward-secret signaling)
|
||||
|
||||
**Version:** `yaw/2.1` · **Status:** 📝 **DRAFT** (proposed) · motivated by
|
||||
[YIP-0001](proposals/yip-0001-forward-secret-signaling.md).
|
||||
|
||||
> **2.1 = [2.0](yaw2.0-protocol.md) + forward-secret signaling.** This document is a
|
||||
> **delta**: everything in [yaw2.0-protocol.md](yaw2.0-protocol.md) still applies
|
||||
> *except* the sections replaced below (§3, §5.4, §6). Identity, signaling
|
||||
> transport (§5.1–§5.3), the application protocol (§8), and file transfer (§9) are
|
||||
> **unchanged**. 2.1 peers **interoperate with 2.0** by falling back (§6.1).
|
||||
|
||||
## What changes vs 2.0
|
||||
|
||||
The only change is the key material used to seal `offer`/`answer`/`candidate`
|
||||
signaling payloads: 2.0 uses **static** X25519 keys (from the long-term Ed25519
|
||||
identity); 2.1 uses **per-session ephemeral** X25519 keys, wiped after the session,
|
||||
introduced by a new signed **`ekey`** message. This makes the signaling
|
||||
forward-secret (see the YIP for the threat it closes). Nothing else changes.
|
||||
|
||||
---
|
||||
|
||||
## §3′ Cryptography (replaces 2.0 §3)
|
||||
|
||||
Unchanged from 2.0 **except** the "Signaling confidentiality" row:
|
||||
|
||||
| Purpose | Primitive |
|
||||
|---------|-----------|
|
||||
| Identity / signatures | **Ed25519** (unchanged) |
|
||||
| **Signaling — `ekey` exchange** | sealed with **static** X25519 (`crypto_box`, keys derived from Ed25519 as in 2.0). Carries only ephemeral *public* keys. |
|
||||
| **Signaling — offer/answer/candidate** | sealed with **ephemeral** X25519: `crypto_box(plaintext, nonce, peer_epk, my_esk)`, where `(esk, epk)` is a fresh per-session X25519 keypair. |
|
||||
| Transport | **WebRTC DTLS** (unchanged; already PFS) |
|
||||
| Hashes | SHA-256 (unchanged) |
|
||||
|
||||
Each peer generates `(esk, epk) = crypto_box_keypair()` **per session** and
|
||||
**securely wipes `esk`** when the session ends or is abandoned. `epk` is exchanged
|
||||
and authenticated via the `ekey` message (§5.4′).
|
||||
|
||||
All seal serialization (`base64_ORIGINAL(nonce(24)||mac(16)||ct)`) is exactly as in
|
||||
2.0 — only the *keys* differ.
|
||||
|
||||
## §5.4′ Sealed payloads (replaces 2.0 §5.4)
|
||||
|
||||
The relay envelope (`{type:"to"/"from", box}`) is unchanged. Two keying schemes now
|
||||
exist for the inner `box`:
|
||||
|
||||
**(a) `ekey` — sealed under STATIC keys** (as in 2.0):
|
||||
|
||||
```
|
||||
{ "kind":"ekey",
|
||||
"v": "yaw/2.1",
|
||||
"epk": "<x25519 ephemeral public key, hex (32 bytes)>",
|
||||
"sig": "<ed25519 sig, hex>" }
|
||||
```
|
||||
|
||||
`sig` is over the exact bytes
|
||||
`utf8("yaw/2.1 ekey") || my_id_raw(32) || peer_id_raw(32) || epk_raw(32)`
|
||||
(`*_raw = hex_decode`). Binding both ids prevents an `ekey` from being replayed to
|
||||
a third party. The recipient verifies `sig` against the sender id and that the
|
||||
embedded `peer_id` is *itself*.
|
||||
|
||||
**(b) `offer` / `answer` / `candidate` / `bye` — sealed under EPHEMERAL keys:**
|
||||
identical JSON to 2.0 §5.4, but the `box` is `crypto_box(…, peer_epk, my_esk)`.
|
||||
|
||||
**Which key opens an incoming box?** Determined by ordering, not a plaintext tag
|
||||
(so the server learns nothing extra): a peer always sends its `ekey` *before* any
|
||||
ephemeral box, and the WebSocket preserves per-sender order. Therefore:
|
||||
|
||||
- `kind:"ekey"` → open with **static** keys.
|
||||
- any other kind → if you already hold the sender's `epk`, open with **ephemeral**
|
||||
keys; if you do not (sender sent no `ekey`), the sender is a 2.0 peer → open with
|
||||
**static** keys (§6.1). Implementations MAY also try-both (a wrong key fails the
|
||||
Poly1305 tag cleanly) for robustness.
|
||||
|
||||
## §6′ Connection establishment (replaces 2.0 §6)
|
||||
|
||||
Preconditions as in 2.0 (same `net`, peer id in keyring). Offerer = smaller id.
|
||||
|
||||
```
|
||||
A = offerer (smaller id) B = answerer
|
||||
────────────────────────────── ──────────────────────────────
|
||||
esk_A, epk_A = box_keypair() esk_B, epk_B = box_keypair()
|
||||
sealStatic(ekey{epk_A,sig}) ──"to B"───────────▶ verify ekey; store epk_A
|
||||
store epk_B ◀──────"to A"── sealStatic(ekey{epk_B,sig})
|
||||
createOffer; setLocalDescription; gather-complete
|
||||
sealEph(offer.sdp) ───"to B"───────────────────▶ verify from∈keyring; setRemoteDescription
|
||||
createAnswer; setLocalDescription; gather-complete
|
||||
verify; setRemoteDescription ◀──"to A"── sealEph(answer.sdp)
|
||||
ICE checks (host + srflx) → DTLS → "yaw" DataChannel opens
|
||||
identity-confirm `hello` exactly as in 2.0 §6
|
||||
── on session close/abandon: WIPE esk ──
|
||||
```
|
||||
|
||||
- `sealStatic(...)` = 2.0 static-key box; `sealEph(...)` = ephemeral-key box.
|
||||
- Both peers send `ekey` first (no offerer/answerer distinction for `ekey`).
|
||||
- The offerer sends the `offer` only after it holds `epk_B`; the answerer sends the
|
||||
`answer` only after it has both sent its `ekey` and received the `offer`. Because
|
||||
a sender's `ekey` precedes its ephemeral boxes and the channel is ordered, the
|
||||
recipient always holds the peer's `epk` before any ephemeral box arrives.
|
||||
- Everything after the DataChannel opens (the signed `hello`, §8, §9) is **identical
|
||||
to 2.0**.
|
||||
|
||||
### §6.1 Backward compatibility (opportunistic FS)
|
||||
|
||||
2.1 ↔ 2.0 must interoperate. Rules:
|
||||
|
||||
1. A 2.1 peer sends its `ekey`, then starts a short timer (recommended **2 s**).
|
||||
2. **2.1 offerer:** if `epk_B` arrives before the timer, send the `offer` with
|
||||
`sealEph`. If the timer fires first (no `ekey` — peer is 2.0), send the `offer`
|
||||
with `sealStatic` and mark the session **non-FS**.
|
||||
3. **2.1 answerer:** if an `offer` arrives and you hold `epk_A`, reply `sealEph`.
|
||||
If an `offer` arrives and you do **not** hold `epk_A` (2.0 offerer), reply
|
||||
`sealStatic` and mark the session **non-FS**.
|
||||
4. A 2.0 peer ignores the unknown `ekey` kind (2.0 §8: "unknown types ignored") and
|
||||
behaves exactly as 2.0.
|
||||
|
||||
A client MAY enforce a **require-FS** policy (refuse / close non-FS sessions);
|
||||
otherwise it MUST surface the non-FS status to the user.
|
||||
|
||||
## §10′ Reference parameters (additions to 2.0 §10)
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Protocol version | `yaw/2.1` (advertised in the `ekey` `v` field) |
|
||||
| Ephemeral key | X25519, `crypto_box_keypair()`, per session, `esk` wiped on close |
|
||||
| `ekey` sign input | `utf8("yaw/2.1 ekey") \|\| my_id_raw \|\| peer_id_raw \|\| epk_raw` |
|
||||
| `ekey` seal | static keys (2.0 scheme) |
|
||||
| offer/answer/candidate seal | ephemeral keys `crypto_box(·, peer_epk, my_esk)` |
|
||||
| FS-negotiation timeout | 2 s (then fall back to 2.0) |
|
||||
|
||||
## Security & compatibility notes
|
||||
|
||||
See [YIP-0001 §6](proposals/yip-0001-forward-secret-signaling.md) for the full
|
||||
analysis. In short: pure-2.1 sessions are forward-secret (recorded signaling
|
||||
unrecoverable after `esk` is wiped, even if long-term keys leak later); mixed 2.1/2.0
|
||||
sessions fall back to 2.0 security and are flagged; authentication and the
|
||||
malicious-server analysis (2.0 §7) are unchanged.
|
||||
89
QUICKSTART.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# waste — quick start
|
||||
|
||||
waste is a private, encrypted chat and file sharing app for people you trust.
|
||||
No accounts, no phone numbers, no central server that knows your messages.
|
||||
|
||||
Pick the option that fits you best.
|
||||
|
||||
---
|
||||
|
||||
## Option 1 — Just open it in your browser
|
||||
|
||||
If someone is running a waste anchor server and has shared the URL with you:
|
||||
|
||||
1. Open the URL in any modern browser
|
||||
2. Enter your name and a network name your group has agreed on
|
||||
3. Done — you're in
|
||||
|
||||
On mobile, tap **Share → Add to Home Screen** to install it as an app icon.
|
||||
|
||||
To invite someone: click the 🔗 button in the sidebar and share the link.
|
||||
|
||||
> Your identity and messages stay in your browser. Nothing is stored on the server — the server only helps peers find each other.
|
||||
|
||||
---
|
||||
|
||||
## Option 2 — Desktop app (recommended for regular use)
|
||||
|
||||
Download the latest `waste` binary for your platform from the [releases page](../../releases).
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
chmod +x waste-linux-amd64 # or waste-darwin-arm64, etc.
|
||||
./waste-linux-amd64
|
||||
```
|
||||
|
||||
**Windows:** double-click `waste-windows-amd64.exe`.
|
||||
|
||||
The app opens a window with the waste UI. Enter your name, the anchor URL, and a network name to join. Your identity is saved between sessions in your config directory (`~/.config/waste` on Linux, `~/Library/Application Support/waste` on macOS, `%APPDATA%\waste` on Windows).
|
||||
|
||||
On Linux and Windows a tray icon appears — closing the window hides to tray rather than quitting. Right-click the tray icon to reopen or quit.
|
||||
|
||||
---
|
||||
|
||||
## Option 3 — Run the daemon manually (headless / power users)
|
||||
|
||||
If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine:
|
||||
|
||||
```bash
|
||||
# Download waste-daemon from the releases page, then:
|
||||
./waste-daemon -alias yourname -anchor wss://your-anchor-server/ws
|
||||
```
|
||||
|
||||
Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`.
|
||||
|
||||
Full flag reference:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `-alias` | `anon` | Your display name |
|
||||
| `-anchor` | — | Anchor server WebSocket URL |
|
||||
| `-data-dir` | `~/.waste` | Where identity and messages are stored |
|
||||
| `-download-dir` | same as data-dir | Where received files are saved |
|
||||
| `-ipc-port` | `17337` | Local TCP IPC port |
|
||||
| `-ws-port` | `0` (off) | WebSocket IPC port (needed for web UI) |
|
||||
| `-turn-url` | — | TURN relay URL (fixes mobile/CGNAT) |
|
||||
| `-turn-secret` | — | TURN shared secret |
|
||||
|
||||
---
|
||||
|
||||
## Inviting someone
|
||||
|
||||
1. Click `Ctrl+I` in the TUI, or click **Generate invite** in the web UI
|
||||
2. Share the `waste:...` link with your friend (Signal, email, anything)
|
||||
3. They open it in a browser or pass it to `waste-daemon --join 'waste:...'`
|
||||
|
||||
Invite links encode the anchor URL and network name. The anchor never sees your messages.
|
||||
|
||||
---
|
||||
|
||||
## Running your own anchor server
|
||||
|
||||
The anchor is a tiny signaling server that helps peers find each other — it never sees plaintext messages or file contents. You need a VPS with a domain and TLS.
|
||||
|
||||
```bash
|
||||
# On your VPS:
|
||||
./waste-anchor -bind 127.0.0.1:8080
|
||||
```
|
||||
|
||||
Put it behind nginx with a `/ws` WebSocket proxy and serve the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.
|
||||
472
README.md
@@ -9,32 +9,301 @@ 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)
|
||||
├── crypto/ Ed25519 identity, nacl/box signaling, ChaCha20-Poly1305
|
||||
<EFBFBD><EFBFBD><EFBFBD>── mesh/ Connected peer state + DataChannel helpers
|
||||
├── mesh/ Connected peer state + DataChannel helpers
|
||||
├── anchor/ Anchor client — WebRTC signaling via the anchor server
|
||||
└── 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
|
||||
```
|
||||
|
||||
Or use the helper script which handles background execution and logging:
|
||||
|
||||
```bash
|
||||
./setup-anchor.sh --bg # start in background, logs to waste-anchor.log
|
||||
./setup-anchor.sh --stop # stop it
|
||||
```
|
||||
|
||||
To cross-compile and redeploy the anchor binary from your local machine:
|
||||
|
||||
```bash
|
||||
./deploy-daemon.sh
|
||||
```
|
||||
|
||||
This kills the existing anchor, uploads the new binary, and restarts it.
|
||||
|
||||
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:~/waste-www/
|
||||
```
|
||||
|
||||
Or use the deploy script (builds + rsyncs in one step):
|
||||
|
||||
```bash
|
||||
./deploy-web.sh
|
||||
```
|
||||
|
||||
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 and proxy `/` to it:
|
||||
|
||||
```bash
|
||||
nohup npx serve -s ~/waste-www -l 1337 &
|
||||
```
|
||||
|
||||
Or use `serve-web.sh` which handles PID tracking and restart:
|
||||
|
||||
```bash
|
||||
./serve-web.sh # kills existing instance, starts fresh, logs to waste-www.log
|
||||
```
|
||||
|
||||
The key requirements:
|
||||
|
||||
- `/ws` → anchor process (WebSocket, keep-alive)
|
||||
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
|
||||
|
||||
### 4. TURN relay (optional, fixes mobile / CGNAT)
|
||||
|
||||
WebRTC hole-punching fails when both peers are behind symmetric NAT — common on mobile data and some ISPs. A TURN relay fixes this. It runs directly on the VPS, not through Nginx Proxy Manager.
|
||||
|
||||
**Firewall:** open UDP 3478 (and optionally TCP 3478) on the Hetzner firewall. No NPM config needed — coturn speaks its own protocol.
|
||||
|
||||
**Install coturn:**
|
||||
|
||||
```bash
|
||||
apt install coturn
|
||||
```
|
||||
|
||||
**`/etc/turnserver.conf`:**
|
||||
|
||||
```
|
||||
listening-port=3478
|
||||
fingerprint
|
||||
use-auth-secret
|
||||
static-auth-secret=YOUR_SECRET_HERE
|
||||
realm=your-domain.com
|
||||
no-tcp-relay
|
||||
```
|
||||
|
||||
Generate your own secret (do not reuse the example above):
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
systemctl enable coturn
|
||||
systemctl start coturn
|
||||
```
|
||||
|
||||
**Update `config.js`** to tell browsers about the TURN server:
|
||||
|
||||
```js
|
||||
window.WASTE_CONFIG = {
|
||||
signalURL: 'wss://your-domain.com/ws',
|
||||
turnURL: 'turn:your-domain.com:3478',
|
||||
turnSecret: 'YOUR_SECRET_HERE',
|
||||
}
|
||||
```
|
||||
|
||||
The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs.
|
||||
|
||||
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
||||
|
||||
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
|
||||
|
||||
---
|
||||
|
||||
## 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 — or locally with `config.js` setting `signalURL` — 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.
|
||||
|
||||
**Mobile:** the web UI ships a PWA manifest (`manifest.json`) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required.
|
||||
|
||||
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
|
||||
|
||||
### 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` without a `config.js`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
|
||||
|
||||
---
|
||||
|
||||
## File sharing (browser mode)
|
||||
|
||||
File transfer runs peer-to-peer over WebRTC DataChannels — files never touch the anchor or any server.
|
||||
|
||||
### Sharing a folder
|
||||
|
||||
In the sidebar under **Sharing**, click **+ Share folder** to pick a local directory. The selected files become available for peers to browse and download. A checkbox lets you control whether subfolders are included (default: yes).
|
||||
|
||||
Multiple folders can be shared — each appears in the list with a ↺ re-pick button (to restore after a page reload) and a ✕ remove button. The share list is saved in `localStorage` so it survives reloads; you'll be prompted to re-pick any folder whose files were lost on reload.
|
||||
|
||||
> Your browser will show a warning along the lines of "really upload X files?" when you pick a folder. This is a built-in browser security prompt — **no files are uploaded anywhere.** Files are transferred directly to a peer only when they explicitly request one via the file browser.
|
||||
|
||||
### Browsing a peer's files
|
||||
|
||||
Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to request their file list. A panel opens on the right showing their shared files. Folders appear first and are clickable — navigate into them with a breadcrumb trail at the top. Sort by name or size; search to filter across all files in the current directory. Click **↓** next to any file to download it directly from that peer.
|
||||
|
||||
### Sending a file directly
|
||||
|
||||
Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival.
|
||||
|
||||
### Transfer resume (daemon mode)
|
||||
|
||||
When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A `.meta` sidecar is written alongside each in-progress `.tmp` file recording the file name, size, and SHA-256.
|
||||
|
||||
When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a `file-accept` with a non-zero `resume_offset`, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end.
|
||||
|
||||
Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the `.tmp`/`.meta` files are manually deleted.
|
||||
|
||||
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
|
||||
|
||||
### Daemon download directory
|
||||
|
||||
Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory:
|
||||
|
||||
```
|
||||
~/.waste/downloads-<network-id>/
|
||||
```
|
||||
|
||||
Pass `-download-dir` at startup to use a different base:
|
||||
|
||||
```bash
|
||||
go run ./cmd/daemon -download-dir ~/Downloads ...
|
||||
```
|
||||
|
||||
Files then land in `~/Downloads/downloads-<network-id>/`. The path can also be changed at runtime via the `set_download_dir` IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every `state_snapshot` event under `networks[].download_dir`.
|
||||
|
||||
---
|
||||
|
||||
## Desktop app (Wails)
|
||||
|
||||
`cmd/app/` is a [Wails v2](https://wails.io) shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview.
|
||||
|
||||
The daemon runs embedded in the same process. The webview connects to `ws://127.0.0.1:17338` exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (`~/Library/Application Support/waste` on macOS, `~/.config/waste` on Linux, `%APPDATA%\waste` on Windows).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24+
|
||||
- Node.js 20+
|
||||
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- Linux platform deps: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app)
|
||||
./build-app.sh
|
||||
|
||||
# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev)
|
||||
./build-app.sh dev
|
||||
|
||||
# Build without system tray (no libayatana-appindicator3-dev needed)
|
||||
CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh
|
||||
```
|
||||
|
||||
`build-app.sh` builds the React frontend, copies `web/dist/` into `cmd/app/frontend/dist/` for embedding, then runs `wails build`. The result is a single self-contained binary.
|
||||
|
||||
### What the desktop app provides
|
||||
|
||||
- **Single binary** — daemon logic is embedded, no subprocess to manage
|
||||
- **System tray** (Linux/Windows) — hide to tray, reopen from tray menu, Quit
|
||||
- **macOS** — hides to Dock on window close; full menu-bar tray is future work
|
||||
- **OS notifications** — native popup on new message or completed file transfer (uses the browser `Notification` API via Wails events; the app will request permission on first notification)
|
||||
- **Data directory** — OS-appropriate config dir (`~/.config/waste`, `~/Library/Application Support/waste`, `%APPDATA%\waste`)
|
||||
|
||||
### CI / automated builds
|
||||
|
||||
`.gitea/workflows/build.yml` runs on every `v*` tag push:
|
||||
|
||||
- **Server binaries** (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed
|
||||
- **Desktop app**: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically)
|
||||
- **Releases**: all artifacts attached to the Gitea release
|
||||
|
||||
To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the `desktop-linux` job.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -44,95 +313,144 @@ go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anc
|
||||
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.
|
||||
### Automated test
|
||||
|
||||
## IPC protocol (plain JSON over TCP)
|
||||
```bash
|
||||
./test-network.sh
|
||||
```
|
||||
|
||||
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
|
||||
|
||||
**Commands you send:**
|
||||
---
|
||||
|
||||
## Onboarding a new peer
|
||||
|
||||
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
|
||||
|
||||
```bash
|
||||
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
|
||||
# → {"type":"invite_generated","invite":"waste:eyJ..."}
|
||||
```
|
||||
|
||||
Bob joins using the invite:
|
||||
|
||||
```bash
|
||||
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJ...'
|
||||
go run ./cmd/tui --join 'waste:eyJ...'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
||||
|
||||
### Signed invites and invite-only networks (waste-go extension)
|
||||
|
||||
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
|
||||
|
||||
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
|
||||
|
||||
### "Come hang" hang links
|
||||
|
||||
The 🔗 button in the web UI copies a **hash-based hang link**:
|
||||
|
||||
```
|
||||
https://your-domain.com/#waste:eyJ...
|
||||
```
|
||||
|
||||
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
|
||||
|
||||
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
|
||||
|
||||
---
|
||||
|
||||
## IPC protocol
|
||||
|
||||
Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
|
||||
**Commands:**
|
||||
```jsonc
|
||||
{"type":"join_network","network_name":"friends"}
|
||||
{"type":"leave_network"}
|
||||
{"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":"add_share","path":"/home/alice/Music"} // global share
|
||||
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
|
||||
{"type":"remove_share","path":"/home/alice/Music"}
|
||||
{"type":"list_shares"}
|
||||
{"type":"create_room","room":"dev"}
|
||||
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
|
||||
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
|
||||
{"type":"export_identity","passphrase":"..."}
|
||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||
```
|
||||
|
||||
**Events the daemon pushes:**
|
||||
**Events:**
|
||||
```jsonc
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]}
|
||||
{"type":"peer_connected","peer":{...}}
|
||||
{"type":"session_ready","peer_id":"<hex>","nick":"alice"}
|
||||
{"type":"message_received","message":{"from":"<hex>","body":"hi","room":"general"}}
|
||||
{"type":"peer_disconnected","peer_id":"<hex>"}
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]}
|
||||
{"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":"room_created","network_id":"...","room":"dev"}
|
||||
{"type":"identity_exported","backup":"..."}
|
||||
{"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 | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
|
||||
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
|
||||
| Hashing | SHA-256 | File integrity, network name hashing |
|
||||
## Crypto
|
||||
|
||||
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
| 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 |
|
||||
|
||||
> 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.
|
||||
### Forward-secret signaling (YAW/2.1)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation
|
||||
- [x] **Proto additions** — `mid` field, signaling types, anchor wire types, `hello` message, `FileDone`
|
||||
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server (replaces TCP relay)
|
||||
- [x] **WebRTC peer connections** — pion/webrtc DataChannels replace raw TCP in `internal/mesh`
|
||||
- [x] **Anchor client** (`internal/anchor`) — ICE offer/answer/candidate lifecycle, `nacl/box` sealing
|
||||
- [x] **IPC updates** — `join_network`/`leave_network` replace `connect`; `session_ready` event added
|
||||
- [ ] **File transfer** — chunked binary DataChannel (`f:<xid>`)
|
||||
- [ ] **Message persistence** — SQLite via `modernc.org/sqlite`
|
||||
- [ ] **TUI** — Bubble Tea client consuming the IPC port
|
||||
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
|
||||
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.
|
||||
|
||||
237
WEBUI.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Web UI — Design Notes
|
||||
|
||||
## Invite URL pattern
|
||||
|
||||
The invite URL path segment is the truncated network hash — already computed
|
||||
by the daemon as the first 8 bytes of `SHA-256("yaw2-net:" + name)`.
|
||||
|
||||
```
|
||||
https://waste.dev.xplwd.com/78aa5621196bf200/
|
||||
```
|
||||
|
||||
The web UI reads `window.location.pathname`, pre-fills:
|
||||
- WebSocket URL: `wss://waste.dev.xplwd.com/ws` (or `/<hash>/signal`)
|
||||
- Network ID: extracted from the path — no separate field needed
|
||||
|
||||
### Fragment vs path
|
||||
|
||||
Using `/#78aa5621196bf200` instead of a path means the network ID never
|
||||
reaches the anchor's access logs. The anchor cannot distinguish an invite
|
||||
visit from a regular visit. Slightly more private — worth deciding before
|
||||
building the React routing layer.
|
||||
|
||||
### Per-network signal path
|
||||
|
||||
Moving the WebSocket endpoint to `/<nethash>/signal` enables nginx to route
|
||||
`/<hash>/signal` to the anchor and `/<hash>/` to a CDN or static host.
|
||||
The anchor never has to serve HTML. Keeps concerns cleanly separated.
|
||||
|
||||
### Anchor changes needed (if it serves the UI)
|
||||
|
||||
Right now `cmd/anchor` only handles WebSocket on `/ws`. To support the
|
||||
invite URL pattern it would also need to serve static files (the React
|
||||
bundle) for any other path, and optionally move the WebSocket endpoint to
|
||||
`/<nethash>/signal` for per-network isolation.
|
||||
|
||||
### Open decision: does the anchor serve the UI at all?
|
||||
|
||||
Two options — decide before building the React routing layer:
|
||||
|
||||
**A) Anchor is purely a signaling relay**
|
||||
Static files served from a CDN or separate host. Anchor only handles
|
||||
WebSocket. Simpler, easier to scale, no Go HTTP file serving code.
|
||||
nginx routes `/<hash>/signal` → anchor, everything else → static host.
|
||||
|
||||
**B) Anchor serves the React bundle**
|
||||
Single deployment, one domain. Anchor handles both WebSocket and static
|
||||
file serving. More convenient but mixes concerns and means deploying a
|
||||
new anchor binary every time the UI changes.
|
||||
|
||||
### Invite expiry
|
||||
|
||||
Encode a TTL in the invite (e.g. 72h). The anchor rejects join attempts on
|
||||
expired tokens. Permanent invites are a liability — a leaked link stays open
|
||||
forever.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & safety — URL invites / anchor
|
||||
|
||||
- **Use the network hash in the URL, not the name.** A base64'd name is
|
||||
trivially reversible. The hash reveals nothing about the network or its
|
||||
members.
|
||||
- **Link previews will betray you.** iMessage, Slack, WhatsApp etc.
|
||||
pre-fetch `https://` links for preview generation. That pre-fetch hits
|
||||
the anchor and effectively probes the network. Serve a generic preview
|
||||
(no network info in og:tags), or use a `#fragment` — fragments never
|
||||
leave the browser.
|
||||
- **The anchor is a metadata oracle.** It can't read content but sees who
|
||||
connects, when, and how often. Log as little as possible — no IPs beyond
|
||||
what's needed to route, no persistent connection records. stderr only,
|
||||
no disk writes.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & safety — identity / contact cards
|
||||
|
||||
- **Private key never leaves the device.** In Tauri, store in OS keychain
|
||||
via Tauri's secure storage — not localStorage.
|
||||
- **Make public vs private explicit in the UI.** The card is a public
|
||||
address. Never show the private key, not even "for backup."
|
||||
- **Aliases are not authenticated — say so.** Anyone can claim any alias,
|
||||
including yours. The peer ID is the real identity. Make the short 4-group
|
||||
hex ID glanceable so users build the habit of verifying it.
|
||||
- **Contact cards expose your anchor URL.** If Alice shares her card and
|
||||
later wants to cut someone off, they still know her anchor. Consider
|
||||
supporting anchor rotation or anchor-less cards for LAN scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & safety — trust model
|
||||
|
||||
- **Default-deny inbound connections.** Unknown peers get `bye` before any
|
||||
data flows. The pending prompt should show the peer ID, not just the
|
||||
claimed alias.
|
||||
- **Mutual acceptance before any messages.** Don't buffer messages from
|
||||
unaccepted peers. Nothing stored until both sides have accepted.
|
||||
- **Removal is immediate.** Close the DataChannel, remove from accepted
|
||||
list, send `bye`. Don't wait for reconnect.
|
||||
- **Block list separate from accept list.** Removing a contact means
|
||||
"not accepted." Blocking should actively refuse — important if they
|
||||
still know the anchor URL.
|
||||
|
||||
---
|
||||
|
||||
## Tauri / local daemon
|
||||
|
||||
- **IPC binds localhost only.** Already the case — keep it. In Tauri,
|
||||
use a random port chosen at startup (written to a local socket file)
|
||||
rather than a fixed port.
|
||||
- **No auto-join on startup.** Invites are processed only when the UI is
|
||||
open and the user confirms.
|
||||
- **Clear data means clear data.** Uninstall / "delete account" must wipe
|
||||
the SQLite store, the identity key, and all cached peer data. Don't rely
|
||||
on the OS.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding flow (contact card model)
|
||||
|
||||
Inspired by the Friends app pattern:
|
||||
|
||||
1. App generates an identity on first launch.
|
||||
2. User picks a nickname — advisory only, not authenticated.
|
||||
3. User copies their contact card (`yaw:<peerid>?n=alias&a=wss://anchor`).
|
||||
UI makes clear: *this is your public address, not a password.*
|
||||
4. User pastes a friend's card into an Accept box, optionally sets a local
|
||||
nickname for them.
|
||||
5. Trust is mutual — connection completes only once both sides have
|
||||
accepted each other's card.
|
||||
6. Pending inbound connections show peer ID + claimed alias; user
|
||||
approves or blocks.
|
||||
|
||||
---
|
||||
|
||||
## Daemon changes needed
|
||||
|
||||
- `accepted_peers` table in SQLite
|
||||
- `accept_peer` / `remove_peer` / `block_peer` IPC commands
|
||||
- After hello verification: check allowlist — send `bye` and close if
|
||||
not accepted; emit `pending_peer` event if unknown
|
||||
- Network concept may simplify to "your contact list" for the personal
|
||||
use case; named group networks remain as a separate concept for group
|
||||
chats
|
||||
|
||||
---
|
||||
|
||||
## End-game stack
|
||||
|
||||
- **React + Tauri** standalone desktop application
|
||||
- Go daemon runs as a Tauri sidecar
|
||||
- React talks to daemon via existing IPC (local TCP, bridged through
|
||||
Tauri's invoke API)
|
||||
- Anchor stays as a lightweight relay — no content, minimal metadata
|
||||
|
||||
---
|
||||
|
||||
## Anchor host as onboarding hub
|
||||
|
||||
The anchor host serves a web UI regardless of which client the user ends
|
||||
up on. It is the universal entry point:
|
||||
|
||||
- New user follows an invite link → lands on the web UI → creates an
|
||||
identity → joins the network
|
||||
- Existing TUI user wants to switch to Tauri client → exports identity
|
||||
from current client → imports into new one
|
||||
- Mobile user with no install → uses the web UI directly
|
||||
|
||||
nginx serves the static React bundle at `/`. The anchor handles WebSocket
|
||||
only. No Go HTTP file serving needed — clean separation.
|
||||
|
||||
---
|
||||
|
||||
## Identity portability
|
||||
|
||||
The identity (Ed25519 keypair + alias) is the one thing that ties all
|
||||
clients together. It must be portable, stable, and independently
|
||||
documented.
|
||||
|
||||
### Portable identity format
|
||||
|
||||
Use the same format as the sister project for interoperability:
|
||||
|
||||
```json
|
||||
{
|
||||
"yaw": "yaw-key-backup-1",
|
||||
"id": "<hex peer id>",
|
||||
"alg": "argon2id-secretbox",
|
||||
"ops": 2,
|
||||
"mem": 67108864,
|
||||
"salt": "<base64>",
|
||||
"nonce": "<base64>",
|
||||
"ct": "<base64 ciphertext>"
|
||||
}
|
||||
```
|
||||
|
||||
- `yaw` is the format version tag
|
||||
- `id` is the public peer ID (hex) — visible without decrypting, useful
|
||||
for confirming you're importing the right identity
|
||||
- `alg` signals argon2id KDF + nacl secretbox encryption
|
||||
- `ops`/`mem` are argon2id parameters
|
||||
- `ct` unseals to the raw Ed25519 private key + alias
|
||||
|
||||
The passphrase is the only secret — the file itself is safe to copy
|
||||
anywhere. Same format means credentials backed up via the sister project
|
||||
can be imported directly into waste and vice versa.
|
||||
|
||||
### Migration flows
|
||||
|
||||
**TUI → Tauri client**
|
||||
1. `waste-daemon export-identity --out identity.enc` (or IPC command)
|
||||
2. Copy file to new machine, import in Tauri onboarding screen
|
||||
|
||||
**Web UI → any client**
|
||||
1. Web UI shows "export your identity" → downloads the encrypted file
|
||||
2. User imports into TUI or Tauri with passphrase
|
||||
|
||||
**New user via web UI, later installs Tauri**
|
||||
1. Creates identity in browser (stored in secure browser storage)
|
||||
2. Exports encrypted file at any point
|
||||
3. Imports into Tauri — same peer ID, same contacts, history syncs
|
||||
via peers (not server)
|
||||
|
||||
**QR code transfer (mobile / LAN)**
|
||||
- Encrypted identity blob encoded as QR
|
||||
- Scan on new device, enter passphrase
|
||||
- No file transfer needed
|
||||
|
||||
### Open decisions
|
||||
|
||||
- Does the web UI generate and hold the private key in-browser, or does
|
||||
it proxy through a server-side session? (In-browser is safer — key
|
||||
never leaves the device even via the anchor host.)
|
||||
- Browser storage for the key: IndexedDB + WebCrypto non-extractable key,
|
||||
or just the encrypted blob with passphrase re-entry on each session?
|
||||
- History portability: messages are local-only today. Cross-client sync
|
||||
would require either exporting the SQLite file or accepting that history
|
||||
starts fresh on each new client.
|
||||
30
build-app.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the waste desktop app (Wails + embedded React frontend).
|
||||
#
|
||||
# Prerequisites:
|
||||
# go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
# Node.js 20+
|
||||
#
|
||||
# Usage:
|
||||
# ./build-app.sh # production build → bin/waste (or bin/waste.exe)
|
||||
# ./build-app.sh dev # dev mode (hot-reload; requires Vite dev server)
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-build}"
|
||||
|
||||
echo "==> Building web frontend..."
|
||||
(cd web && npm install --silent && npm run build)
|
||||
|
||||
echo "==> Copying frontend dist into app package..."
|
||||
rm -rf cmd/app/frontend/dist
|
||||
cp -r web/dist cmd/app/frontend/dist
|
||||
|
||||
if [ "$MODE" = "dev" ]; then
|
||||
echo "==> Starting Wails dev mode (start 'cd web && npm run dev' in another terminal)..."
|
||||
(cd cmd/app && wails dev)
|
||||
else
|
||||
echo "==> Running Wails build..."
|
||||
mkdir -p bin
|
||||
(cd cmd/app && wails build -o "$(pwd)/../../bin/waste")
|
||||
echo "==> Done: bin/waste"
|
||||
fi
|
||||
@@ -170,9 +170,8 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("anchor: join: bad sig from %s", r.RemoteAddr)
|
||||
continue
|
||||
}
|
||||
// Sig covers nonce || net (both as raw bytes decoded from hex/plaintext).
|
||||
netBytes, _ := hex.DecodeString(msg.Net)
|
||||
signed := append(nonce, netBytes...)
|
||||
// §5.1: sig covers nonce_raw || net_ascii (net as 64-char hex UTF-8 string)
|
||||
signed := append(nonce, []byte(msg.Net)...)
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubBytes), signed, sigBytes) {
|
||||
log.Printf("anchor: join: sig verification failed for %s", msg.ID[:min(8, len(msg.ID))])
|
||||
continue
|
||||
|
||||
130
cmd/app/app.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/netmgr"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const wsPort = 17338
|
||||
|
||||
// App is the Wails application backend.
|
||||
// It embeds the daemon directly — no subprocess needed.
|
||||
// The React frontend connects to the daemon's WebSocket IPC at ws://127.0.0.1:17338,
|
||||
// exactly as it does in browser-daemon mode.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
mgr *netmgr.Manager
|
||||
}
|
||||
|
||||
func newApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// startup is called when the Wails window is ready. It initialises the daemon,
|
||||
// starts the WebSocket IPC listener, sets up the system tray, and begins
|
||||
// forwarding message/file events to the webview as OS notifications.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
dir := dataDir()
|
||||
id, err := crypto.LoadOrCreate(dir, "")
|
||||
if err != nil {
|
||||
log.Printf("app: identity: %v", err)
|
||||
runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.ErrorDialog,
|
||||
Title: "waste — startup error",
|
||||
Message: "Failed to load identity: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("app: peer id: %s alias: %s", id.PeerID().Short(), id.Alias)
|
||||
|
||||
a.mgr = netmgr.New(netmgr.Config{
|
||||
MasterIdentity: id,
|
||||
StoreDir: dir,
|
||||
})
|
||||
|
||||
// Forward daemon events to the webview and generate OS notifications.
|
||||
go a.watchEvents()
|
||||
|
||||
// Start the WebSocket IPC server; the webview connects here (daemon mode).
|
||||
go func() {
|
||||
if err := ipc.RunWS(a.mgr, wsPort); err != nil {
|
||||
log.Printf("app: IPC: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("app: WebSocket IPC listening on 127.0.0.1:%d", wsPort)
|
||||
|
||||
// System tray (Linux/Windows; no-op on macOS — see tray_darwin.go).
|
||||
a.startTray()
|
||||
}
|
||||
|
||||
// shutdown is called when the Wails app exits.
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
if a.mgr != nil {
|
||||
a.mgr.LeaveAll()
|
||||
}
|
||||
}
|
||||
|
||||
// watchEvents subscribes to all daemon events and emits OS notifications for
|
||||
// incoming messages and completed file transfers. The "notify" event is received
|
||||
// by the frontend via Wails EventsOn and displayed using the browser Notification API.
|
||||
func (a *App) watchEvents() {
|
||||
if a.mgr == nil {
|
||||
return
|
||||
}
|
||||
events := a.mgr.Subscribe()
|
||||
defer a.mgr.Unsubscribe(events)
|
||||
|
||||
for evt := range events {
|
||||
switch evt.Type {
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message == nil {
|
||||
continue
|
||||
}
|
||||
// Don't notify for messages sent by the local peer.
|
||||
if a.mgr.MasterIdentity() != nil && evt.Message.From == a.mgr.MasterIdentity().PeerID() {
|
||||
continue
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "notify", map[string]string{
|
||||
"title": "waste — new message",
|
||||
"body": evt.Message.Text,
|
||||
})
|
||||
|
||||
case proto.EvtFileComplete:
|
||||
runtime.EventsEmit(a.ctx, "notify", map[string]string{
|
||||
"title": "waste — file received",
|
||||
"body": filepath.Base(evt.Path),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dataDir returns the OS-appropriate config directory for identity and stores.
|
||||
// macOS: ~/Library/Application Support/waste
|
||||
// Linux: ~/.config/waste
|
||||
// Windows: %APPDATA%\waste
|
||||
func dataDir() string {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".waste")
|
||||
}
|
||||
return ".waste"
|
||||
}
|
||||
dir := filepath.Join(base, "waste")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
log.Printf("app: mkdir %s: %v", dir, err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
BIN
cmd/app/appicon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
0
cmd/app/frontend/dist/.gitkeep
vendored
Normal file
66
cmd/app/main.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// waste desktop app — Wails shell wrapping the daemon and React UI.
|
||||
// In dev mode the UI is served from the Vite dev server (http://localhost:5173).
|
||||
// In production the compiled frontend is embedded from frontend/dist/.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/logger"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
app := newApp()
|
||||
|
||||
logLevel := logger.INFO
|
||||
if os.Getenv("WASTE_DEBUG") != "" {
|
||||
logLevel = logger.DEBUG
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "waste",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
DisableResize: false,
|
||||
Fullscreen: false,
|
||||
LogLevel: logLevel,
|
||||
LogLevelProduction: logger.ERROR,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
Bind: []interface{}{app},
|
||||
// Hide the window instead of quitting when the close button is clicked.
|
||||
// The user can quit via the app menu or by stopping the process.
|
||||
HideWindowOnClose: true,
|
||||
Mac: &mac.Options{
|
||||
TitleBar: mac.TitleBarHiddenInset(),
|
||||
About: &mac.AboutInfo{
|
||||
Title: "waste",
|
||||
Message: "Decentralized friend-to-friend encrypted mesh networking.",
|
||||
},
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: false,
|
||||
WindowIsTranslucent: false,
|
||||
},
|
||||
Linux: &linux.Options{},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
35
cmd/app/tray.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build !darwin && !notray
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
"github.com/getlantern/systray"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
//go:embed appicon.png
|
||||
var trayIcon []byte
|
||||
|
||||
func (a *App) startTray() {
|
||||
go systray.Run(func() {
|
||||
systray.SetIcon(trayIcon)
|
||||
systray.SetTooltip("waste")
|
||||
|
||||
mShow := systray.AddMenuItem("Open waste", "Show the waste window")
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("Quit", "Quit waste")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-mShow.ClickedCh:
|
||||
runtime.WindowShow(a.ctx)
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
runtime.Quit(a.ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
}, func() {})
|
||||
}
|
||||
14
cmd/app/tray_darwin.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
// On macOS, Cocoa requires the system tray to run on the main thread, which
|
||||
// Wails already owns. Full menu-bar tray integration is future work and would
|
||||
// require an NSStatusItem helper or a fork of systray that supports
|
||||
// RunWithExternalLoop on macOS.
|
||||
//
|
||||
// For now the app hides to the Dock when the window is closed (HideWindowOnClose)
|
||||
// and users can reopen it from the Dock or Cmd+Tab. The standard macOS Cmd+Q
|
||||
// binding quits cleanly via Wails.
|
||||
|
||||
func (a *App) startTray() {}
|
||||
8
cmd/app/tray_stub.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build notray
|
||||
|
||||
package main
|
||||
|
||||
// Build with -tags notray to omit the system tray (no libayatana-appindicator3-dev needed).
|
||||
// Used for headless builds and CI environments that haven't installed the GTK tray headers.
|
||||
|
||||
func (a *App) startTray() {}
|
||||
12
cmd/app/wails.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "waste",
|
||||
"outputfilename": "waste",
|
||||
"frontend:install": "echo 'run: cd web && npm install'",
|
||||
"frontend:build": "echo 'run: ./build-app.sh'",
|
||||
"frontend:dev:watcher": "",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"author": {
|
||||
"name": "waste-go"
|
||||
}
|
||||
}
|
||||
@@ -3,46 +3,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/netmgr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
||||
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
||||
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
|
||||
wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled")
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||
downloadDir := flag.String("download-dir", "", "base directory for received files; defaults to data-dir, split by network")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
|
||||
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
|
||||
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||
flag.Parse()
|
||||
|
||||
id, err := crypto.LoadOrCreate(expandHome(*dataDir), *alias)
|
||||
dir := expandHome(*dataDir)
|
||||
|
||||
// --import-identity: decrypt backup and write identity.json, then exit.
|
||||
if *importBackup != "" {
|
||||
if *importPassword == "" {
|
||||
log.Fatal("--import-passphrase is required with --import-identity")
|
||||
}
|
||||
raw, err := os.ReadFile(*importBackup)
|
||||
if err != nil {
|
||||
log.Fatalf("import-identity: read file: %v", err)
|
||||
}
|
||||
imported, err := crypto.ImportIdentity(raw, *importPassword)
|
||||
if err != nil {
|
||||
log.Fatalf("import-identity: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Fatalf("import-identity: mkdir: %v", err)
|
||||
}
|
||||
if err := crypto.SaveIdentity(dir, imported); err != nil {
|
||||
log.Fatalf("import-identity: save: %v", err)
|
||||
}
|
||||
fmt.Printf("identity imported: %s\n", imported.PeerID())
|
||||
return
|
||||
}
|
||||
|
||||
id, err := crypto.LoadOrCreate(dir, *alias)
|
||||
if err != nil {
|
||||
log.Fatalf("identity: %v", err)
|
||||
}
|
||||
log.Printf("daemon: local peer id: %s alias: %s", id.PeerID().Short(), id.Alias)
|
||||
|
||||
m := mesh.New(id)
|
||||
|
||||
// joinFn is passed to the IPC layer; it's called when the UI sends join_network.
|
||||
joinFn := func(ctx context.Context, networkName string) {
|
||||
if *anchorURL == "" {
|
||||
log.Printf("daemon: join_network: no -anchor flag set")
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtError, ErrorMessage: "no anchor configured — start daemon with -anchor <url>"})
|
||||
return
|
||||
// --join overrides/sets the anchor URL and triggers an auto-join.
|
||||
var autoJoinNetwork string
|
||||
if *joinInvite != "" {
|
||||
inv, err := invite.Decode(*joinInvite)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid invite: %v", err)
|
||||
}
|
||||
log.Printf("daemon: joining network %q via %s", networkName, *anchorURL)
|
||||
anchor.Run(ctx, *anchorURL, networkName, id, m)
|
||||
log.Printf("daemon: left network %q", networkName)
|
||||
*anchorURL = inv.Anchor
|
||||
autoJoinNetwork = inv.Network
|
||||
log.Printf("daemon: invite decoded — anchor=%s network=%s", inv.Anchor, inv.Network)
|
||||
}
|
||||
|
||||
if err := ipc.Run(m, *ipcPort, joinFn); err != nil {
|
||||
mgr := netmgr.New(netmgr.Config{
|
||||
MasterIdentity: id,
|
||||
StoreDir: dir,
|
||||
AnchorURL: *anchorURL,
|
||||
ShareDir: expandHome(*shareDir),
|
||||
DownloadDir: expandHome(*downloadDir),
|
||||
TurnURL: *turnURL,
|
||||
TurnSecret: *turnSecret,
|
||||
})
|
||||
|
||||
if autoJoinNetwork != "" {
|
||||
if _, err := mgr.Join(autoJoinNetwork, ""); err != nil {
|
||||
log.Fatalf("auto-join: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() { errCh <- ipc.Run(mgr, *ipcPort) }()
|
||||
|
||||
if *wsPort != 0 {
|
||||
go func() { errCh <- ipc.RunWS(mgr, *wsPort) }()
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
log.Fatalf("ipc: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -55,4 +109,3 @@ func expandHome(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
657
cmd/tui/main.go
Normal file
@@ -0,0 +1,657 @@
|
||||
// Package main is the waste-go terminal UI.
|
||||
// It connects to a running daemon's IPC port, joins a named network, and
|
||||
// renders a three-pane layout: rooms (left), messages (centre), peers (right).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||
styleRoom = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
|
||||
stylePeer = lipgloss.NewStyle().Foreground(lipgloss.Color("72"))
|
||||
styleSelf = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))
|
||||
styleMsgFrom = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleMsgMe = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86"))
|
||||
styleMsgTime = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33"))
|
||||
styleBorder = lipgloss.Color("238")
|
||||
styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
||||
)
|
||||
|
||||
const sideW = 22 // total width of each sidebar box (inner = sideW-2)
|
||||
|
||||
// ── tea messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
type connectedMsg struct {
|
||||
conn net.Conn
|
||||
reader *lineReader
|
||||
}
|
||||
type ipcLineMsg struct{ line []byte }
|
||||
type connectErrMsg struct{ err error }
|
||||
type readErrMsg struct{ err error }
|
||||
|
||||
// lineReader pumps a TCP connection through a channel so a single bufio.Scanner
|
||||
// is alive for the lifetime of the connection (avoids read-ahead data loss when
|
||||
// a new scanner is created on each call).
|
||||
type lineReader struct {
|
||||
ch chan []byte
|
||||
}
|
||||
|
||||
func newLineReader(conn net.Conn) *lineReader {
|
||||
lr := &lineReader{ch: make(chan []byte, 64)}
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
b := make([]byte, len(scanner.Bytes()))
|
||||
copy(b, scanner.Bytes())
|
||||
lr.ch <- b
|
||||
}
|
||||
close(lr.ch)
|
||||
}()
|
||||
return lr
|
||||
}
|
||||
|
||||
func (lr *lineReader) next() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
b, ok := <-lr.ch
|
||||
if !ok {
|
||||
return readErrMsg{fmt.Errorf("daemon closed connection")}
|
||||
}
|
||||
return ipcLineMsg{b}
|
||||
}
|
||||
}
|
||||
|
||||
// ── model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type entry struct {
|
||||
from string
|
||||
body string
|
||||
at time.Time
|
||||
fromMe bool
|
||||
}
|
||||
|
||||
type model struct {
|
||||
ipcPort int
|
||||
networkName string
|
||||
|
||||
width, height int
|
||||
|
||||
conn net.Conn
|
||||
enc *json.Encoder
|
||||
reader *lineReader
|
||||
|
||||
localID proto.PeerID
|
||||
localAlias string
|
||||
|
||||
rooms []string // "general" always first; DM rooms appended
|
||||
activeRoom int
|
||||
messages map[string][]entry
|
||||
unread map[string]bool // rooms with messages since last viewed
|
||||
|
||||
peers map[proto.PeerID]string // connected peers: id → alias
|
||||
peerOrder []proto.PeerID
|
||||
|
||||
input textinput.Model
|
||||
viewport viewport.Model
|
||||
vpReady bool
|
||||
|
||||
status string
|
||||
errMsg string
|
||||
invitePopup string // non-empty = show invite overlay
|
||||
}
|
||||
|
||||
func newModel(ipcPort int, network string) model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Type a message…"
|
||||
ti.Focus()
|
||||
ti.CharLimit = 2000
|
||||
|
||||
return model{
|
||||
ipcPort: ipcPort,
|
||||
networkName: network,
|
||||
rooms: []string{"general"},
|
||||
messages: make(map[string][]entry),
|
||||
unread: make(map[string]bool),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(connectCmd(m.ipcPort), textinput.Blink)
|
||||
}
|
||||
|
||||
// ── commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func connectCmd(port int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return connectErrMsg{err}
|
||||
}
|
||||
return connectedMsg{conn: conn, reader: newLineReader(conn)}
|
||||
}
|
||||
}
|
||||
|
||||
func sendIPC(enc *json.Encoder, msg proto.IpcMessage) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
enc.Encode(msg) //nolint:errcheck
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m = m.resizeViewport()
|
||||
|
||||
case connectErrMsg:
|
||||
m.errMsg = fmt.Sprintf("cannot connect to daemon on :%d — is it running?", m.ipcPort)
|
||||
m.status = "disconnected"
|
||||
|
||||
case connectedMsg:
|
||||
m.conn = msg.conn
|
||||
m.enc = json.NewEncoder(msg.conn)
|
||||
m.reader = msg.reader
|
||||
m.status = "joining " + m.networkName + "…"
|
||||
cmds = append(cmds,
|
||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.networkName}),
|
||||
sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}),
|
||||
m.reader.next(),
|
||||
)
|
||||
|
||||
case ipcLineMsg:
|
||||
var evt proto.IpcMessage
|
||||
if json.Unmarshal(msg.line, &evt) == nil {
|
||||
m = m.applyEvent(evt)
|
||||
}
|
||||
cmds = append(cmds, m.reader.next())
|
||||
|
||||
case readErrMsg:
|
||||
m.errMsg = "IPC disconnected: " + msg.err.Error()
|
||||
m.status = "disconnected"
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.Type == tea.KeyEsc:
|
||||
if m.invitePopup != "" {
|
||||
m.invitePopup = ""
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case msg.String() == "ctrl+i":
|
||||
if m.enc != nil {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite}))
|
||||
}
|
||||
case msg.Type == tea.KeyEnter:
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
case msg.Type == tea.KeyShiftTab:
|
||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
default:
|
||||
var tiCmd tea.Cmd
|
||||
m.input, tiCmd = m.input.Update(msg)
|
||||
cmds = append(cmds, tiCmd)
|
||||
}
|
||||
|
||||
default:
|
||||
// Let viewport handle scroll events.
|
||||
if m.vpReady {
|
||||
var vpCmd tea.Cmd
|
||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, vpCmd)
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
switch evt.Type {
|
||||
|
||||
case proto.EvtStateSnapshot:
|
||||
if evt.LocalPeer != nil {
|
||||
m.localID = evt.LocalPeer.ID
|
||||
m.localAlias = evt.LocalPeer.Alias
|
||||
}
|
||||
m.peers = make(map[proto.PeerID]string)
|
||||
m.peerOrder = nil
|
||||
for _, p := range evt.ConnectedPeers {
|
||||
m.peers[p.ID] = p.Alias
|
||||
m.peerOrder = append(m.peerOrder, p.ID)
|
||||
}
|
||||
for _, r := range evt.Rooms {
|
||||
m = m.addRoom(r)
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtRoomCreated:
|
||||
m = m.addRoom(evt.Room)
|
||||
m = m.refreshViewport()
|
||||
|
||||
case proto.EvtSessionReady:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
}
|
||||
alias := evt.Nick
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
m.peers[pid] = alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerConnected:
|
||||
if evt.Peer != nil {
|
||||
pid := evt.Peer.ID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
}
|
||||
m.peers[pid] = evt.Peer.Alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerDisconnected:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
delete(m.peers, pid)
|
||||
m.peerOrder = filterIDs(m.peerOrder, pid)
|
||||
}
|
||||
|
||||
case proto.EvtInviteGenerated:
|
||||
m.invitePopup = evt.InviteGenerated
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message != nil {
|
||||
msg := evt.Message
|
||||
e := entry{
|
||||
from: m.aliasOf(msg.From),
|
||||
body: msg.Text,
|
||||
at: time.UnixMilli(msg.Ts),
|
||||
fromMe: msg.From == m.localID,
|
||||
}
|
||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||
m = m.addRoom(msg.Room)
|
||||
if msg.Room != m.activeRoomName() {
|
||||
m.unread[msg.Room] = true
|
||||
}
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
body := strings.TrimSpace(m.input.Value())
|
||||
if body == "" || m.enc == nil {
|
||||
return m, cmds
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
if strings.HasPrefix(body, "/room ") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
||||
if name != "" {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
room := m.rooms[m.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
ipcMsg.To = &recipID
|
||||
}
|
||||
cmds = append(cmds, sendIPC(m.enc, ipcMsg))
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// ── layout helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// vpContentWidth returns the inner width of the centre pane (available to the viewport).
|
||||
func (m model) vpContentWidth() int {
|
||||
// Two sidebar boxes (sideW total each) + centre box (borders 2 = -2 from inner).
|
||||
w := m.width - sideW*2 - 2
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// vpHeight returns the viewport height (lines of messages shown).
|
||||
func (m model) vpHeight() int {
|
||||
// Full height minus: top border(1) + title(1) + divider(1) + bottom border(1) +
|
||||
// input box (3 lines incl borders) + status bar(1) = 8 total overhead.
|
||||
h := m.height - 8
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m model) resizeViewport() model {
|
||||
if !m.vpReady {
|
||||
m.viewport = viewport.New(m.vpContentWidth(), m.vpHeight())
|
||||
m.vpReady = true
|
||||
} else {
|
||||
m.viewport.Width = m.vpContentWidth()
|
||||
m.viewport.Height = m.vpHeight()
|
||||
}
|
||||
return m.refreshViewport()
|
||||
}
|
||||
|
||||
func (m model) refreshViewport() model {
|
||||
if !m.vpReady {
|
||||
return m
|
||||
}
|
||||
room := m.activeRoomName()
|
||||
w := m.vpContentWidth()
|
||||
var sb strings.Builder
|
||||
for _, e := range m.messages[room] {
|
||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
||||
var from string
|
||||
if e.fromMe {
|
||||
from = styleMsgMe.Render(e.from)
|
||||
} else {
|
||||
from = styleMsgFrom.Render(e.from)
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %s", ts, from, e.body)
|
||||
// Crude wrap: if line > w, just truncate (viewport handles horizontal scroll).
|
||||
_ = w
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
m.viewport.SetContent(sb.String())
|
||||
m.viewport.GotoBottom()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) activeRoomName() string {
|
||||
if m.activeRoom < len(m.rooms) {
|
||||
return m.rooms[m.activeRoom]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) addRoom(room string) model {
|
||||
for _, r := range m.rooms {
|
||||
if r == room {
|
||||
return m
|
||||
}
|
||||
}
|
||||
m.rooms = append(m.rooms, room)
|
||||
return m
|
||||
}
|
||||
|
||||
// ── view ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…\n"
|
||||
}
|
||||
|
||||
innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar
|
||||
if innerH < 4 {
|
||||
innerH = 4
|
||||
}
|
||||
|
||||
// ── left: rooms ───────────────────────────────────────────────────────────
|
||||
leftBox := m.renderRooms(innerH)
|
||||
|
||||
// ── right: peers ──────────────────────────────────────────────────────────
|
||||
rightBox := m.renderPeers(innerH)
|
||||
|
||||
// ── centre: title + messages ──────────────────────────────────────────────
|
||||
centreBox := m.renderCentre(innerH)
|
||||
|
||||
mainRow := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, centreBox, rightBox)
|
||||
|
||||
// ── input ─────────────────────────────────────────────────────────────────
|
||||
inputBox := lipgloss.NewStyle().
|
||||
Width(m.width - 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(styleBorder).
|
||||
Render(m.input.View())
|
||||
|
||||
// ── status bar ────────────────────────────────────────────────────────────
|
||||
var statusLine string
|
||||
if m.errMsg != "" {
|
||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||
} else {
|
||||
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
|
||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||
}
|
||||
|
||||
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
||||
|
||||
// ── invite popup (full-screen overlay) ───────────────────────────────────
|
||||
if m.invitePopup != "" {
|
||||
label := styleActive.Render("Invite — share this with anyone you want to add:")
|
||||
code := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("238")).
|
||||
Padding(0, 1).
|
||||
Width(m.width - 6).
|
||||
Render(m.invitePopup)
|
||||
hint := styleStatus.Render("Esc to close · ctrl+c to quit")
|
||||
popup := lipgloss.NewStyle().
|
||||
Width(m.width).
|
||||
Height(m.height).
|
||||
Align(lipgloss.Center, lipgloss.Center).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, label, "", code, "", hint))
|
||||
return popup
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (m model) renderRooms(boxH int) string {
|
||||
innerW := sideW - 2
|
||||
contentH := boxH - 2 // subtract top+bottom border
|
||||
var lines []string
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Rooms"))
|
||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
||||
for i, room := range m.rooms {
|
||||
label := roomLabel(room, m.peers)
|
||||
if i == m.activeRoom {
|
||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||
} else {
|
||||
prefix := " "
|
||||
if m.unread[room] {
|
||||
prefix = "* "
|
||||
}
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
|
||||
}
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
}
|
||||
content := strings.Join(lines[:min(len(lines), contentH)], "\n")
|
||||
return lipgloss.NewStyle().
|
||||
Width(sideW - 2).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (m model) renderPeers(boxH int) string {
|
||||
innerW := sideW - 2
|
||||
contentH := boxH - 2
|
||||
var lines []string
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Peers"))
|
||||
lines = append(lines, lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW)))
|
||||
// Local peer first
|
||||
if m.localAlias != "" {
|
||||
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+m.localAlias+" (me)"))
|
||||
}
|
||||
for _, pid := range m.peerOrder {
|
||||
alias := m.peers[pid]
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
lines = append(lines, stylePeer.Width(innerW).Render("● "+alias))
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
}
|
||||
content := strings.Join(lines[:min(len(lines), contentH)], "\n")
|
||||
return lipgloss.NewStyle().
|
||||
Width(sideW - 2).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (m model) renderCentre(boxH int) string {
|
||||
innerW := m.vpContentWidth()
|
||||
room := m.activeRoomName()
|
||||
title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers))
|
||||
divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
|
||||
|
||||
vpView := ""
|
||||
if m.vpReady {
|
||||
vpView = m.viewport.View()
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(innerW).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) aliasOf(id proto.PeerID) string {
|
||||
if id == m.localID {
|
||||
if m.localAlias != "" {
|
||||
return m.localAlias
|
||||
}
|
||||
return "me"
|
||||
}
|
||||
if a, ok := m.peers[id]; ok && a != "" {
|
||||
return a
|
||||
}
|
||||
return shortID(id)
|
||||
}
|
||||
|
||||
func shortID(id proto.PeerID) string {
|
||||
s := string(id)
|
||||
if len(s) > 8 {
|
||||
return s[:8]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func roomLabel(room string, peers map[proto.PeerID]string) string {
|
||||
if room == "general" {
|
||||
return "#general"
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
return "#" + room
|
||||
}
|
||||
|
||||
func roomTitle(room string, peers map[proto.PeerID]string) string {
|
||||
if room == "general" {
|
||||
return "#general"
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
return "#" + room
|
||||
}
|
||||
|
||||
func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
||||
out := ids[:0]
|
||||
for _, v := range ids {
|
||||
if v != remove {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||
network := flag.String("network", "", "network name to join on startup")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
|
||||
flag.Parse()
|
||||
|
||||
// --join overrides --network.
|
||||
if *joinInvite != "" {
|
||||
inv, err := invite.Decode(*joinInvite)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: invalid invite:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
*network = inv.Network
|
||||
}
|
||||
|
||||
if *network == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: -network or -join is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
newModel(*ipcPort, *network),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tui:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
73
go.mod
@@ -1,16 +1,62 @@
|
||||
module github.com/waste-go
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
golang.org/x/crypto v0.33.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||
github.com/getlantern/systray v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
@@ -26,11 +72,26 @@ require (
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pion/webrtc/v3 v3.3.6 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/wailsapp/wails/v2 v2.12.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
187
go.sum
@@ -1,14 +1,113 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
@@ -44,25 +143,56 @@ github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLh
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
|
||||
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -71,10 +201,15 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
@@ -83,22 +218,33 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -108,20 +254,57 @@ golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package anchor implements the YAW/2 anchor client.
|
||||
// Package anchor implements the YAW/2 anchor client (with YAW/2.1 forward-secret signaling).
|
||||
// It connects to the anchor WebSocket, handles challenge/join, and routes
|
||||
// sealed signaling payloads. It manages PeerConnection lifecycle and delegates
|
||||
// DataChannel handling to internal/mesh.
|
||||
@@ -25,10 +25,47 @@ import (
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const ekeyTimeout = 2 * time.Second
|
||||
|
||||
// peerSession holds per-peer state for one (potential or live) connection.
|
||||
type peerSession struct {
|
||||
pc *webrtc.PeerConnection
|
||||
ekey *crypto.EphemeralKey // our ephemeral keypair for this session
|
||||
peerEPK *[32]byte // peer's ephemeral pubkey (nil until ekey received)
|
||||
fs bool // forward-secret session (both sides exchanged ekey)
|
||||
ekeyRx chan struct{} // closed when peerEPK is set
|
||||
}
|
||||
|
||||
func newSession(pc *webrtc.PeerConnection) (*peerSession, error) {
|
||||
ek, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &peerSession{
|
||||
pc: pc,
|
||||
ekey: ek,
|
||||
ekeyRx: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *peerSession) close() {
|
||||
if s.ekey != nil {
|
||||
s.ekey.Wipe()
|
||||
}
|
||||
if s.pc != nil {
|
||||
s.pc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Run connects to anchorURL, joins networkName, and blocks handling signaling.
|
||||
// Reconnects automatically on disconnect. Cancel ctx to stop.
|
||||
func Run(ctx context.Context, anchorURL, networkName string, id *crypto.Identity, m *mesh.Mesh) {
|
||||
netHash := hashNetName(networkName)
|
||||
RunByHash(ctx, anchorURL, hashNetName(networkName), id, m)
|
||||
}
|
||||
|
||||
// RunByHash is like Run but accepts the pre-computed full 64-char hex network hash
|
||||
// directly. Use this when joining by hash rather than by name.
|
||||
func RunByHash(ctx context.Context, anchorURL, netHash string, id *crypto.Identity, m *mesh.Mesh) {
|
||||
for {
|
||||
if err := runOnce(ctx, anchorURL, netHash, id, m); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -62,11 +99,50 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
}()
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
pcs = make(map[proto.PeerID]*webrtc.PeerConnection)
|
||||
mu sync.RWMutex
|
||||
sessions = make(map[proto.PeerID]*peerSession)
|
||||
)
|
||||
|
||||
sender := &sender{id: id, sendCh: sendCh}
|
||||
s := &sender{id: id, sendCh: sendCh}
|
||||
|
||||
// Drain gossip-discovered peers and initiate offers. Stopped when runOnce
|
||||
// returns (via drainDone) so stale sessions from a dead connection are never reused.
|
||||
drainDone := make(chan struct{})
|
||||
defer close(drainDone)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case pid, ok := <-m.PendingConnect:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mu.RLock()
|
||||
_, already := sessions[pid]
|
||||
mu.RUnlock()
|
||||
if already {
|
||||
continue
|
||||
}
|
||||
// Lower ID offers (matches yaw2/browser convention).
|
||||
if strings.Compare(string(id.PeerID()), string(pid)) >= 0 {
|
||||
continue
|
||||
}
|
||||
go func(pid proto.PeerID) {
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: gossip offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
case <-drainDone:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg proto.AnchorMessage
|
||||
@@ -81,8 +157,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad challenge nonce: %w", err)
|
||||
}
|
||||
netBytes, _ := hex.DecodeString(netHash)
|
||||
sig := id.Sign(append(nonceBytes, netBytes...))
|
||||
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
|
||||
sendCh <- proto.AnchorMessage{
|
||||
Type: proto.AnchorJoin,
|
||||
ID: string(id.PeerID()),
|
||||
@@ -94,15 +169,15 @@ 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) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
@@ -111,15 +186,15 @@ 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) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
@@ -127,21 +202,61 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
case proto.AnchorPeerLeave:
|
||||
pid := proto.PeerID(msg.ID)
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[pid]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, pid)
|
||||
if sess, ok := sessions[pid]; ok {
|
||||
sess.close()
|
||||
delete(sessions, pid)
|
||||
}
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: peer left: %s", pid.Short())
|
||||
|
||||
case proto.AnchorFrom:
|
||||
fromID := proto.PeerID(msg.From)
|
||||
payload, err := openBox(msg.Box, fromID, id)
|
||||
|
||||
mu.RLock()
|
||||
sess := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
|
||||
// Determine which key to try for opening the box.
|
||||
// If we already have the peer's ephemeral key, try ephemeral first.
|
||||
payload, usedEph, err := openBoxAuto(msg.Box, fromID, id, sess)
|
||||
if err != nil {
|
||||
log.Printf("anchor: open box from %s: %v", fromID.Short(), err)
|
||||
continue
|
||||
}
|
||||
dispatchSignaling(ctx, payload, fromID, id, m, sender, pcs, &mu)
|
||||
|
||||
if payload.Kind == proto.SigEkey {
|
||||
// Process ekey under static keys (we opened it correctly above).
|
||||
mu.Lock()
|
||||
if sess == nil {
|
||||
// Answerer: we haven't created a session yet, do it now.
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: new PC for answerer: %v", err)
|
||||
continue
|
||||
}
|
||||
sess, err = newSession(pc)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: ephemeral key gen: %v", err)
|
||||
continue
|
||||
}
|
||||
sessions[fromID] = sess
|
||||
// Send our ekey back immediately.
|
||||
go sendEkey(fromID, sess, id, s)
|
||||
}
|
||||
if err := receiveEkey(payload, fromID, id, sess); err != nil {
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: bad ekey from %s: %v", fromID.Short(), err)
|
||||
continue
|
||||
}
|
||||
mu.Unlock()
|
||||
_ = usedEph
|
||||
continue
|
||||
}
|
||||
|
||||
dispatchSignaling(ctx, payload, usedEph, fromID, id, m, s, sessions, &mu)
|
||||
|
||||
case proto.AnchorNoPeer:
|
||||
log.Printf("anchor: no such peer: %s", proto.PeerID(msg.ID).Short())
|
||||
@@ -149,41 +264,118 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
}
|
||||
}
|
||||
|
||||
// ── ekey helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// sendEkey seals and transmits our ephemeral public key to peerID.
|
||||
func sendEkey(peerID proto.PeerID, sess *peerSession, id *crypto.Identity, s *sender) {
|
||||
epkHex := hex.EncodeToString(sess.ekey.PublicRaw()[:])
|
||||
sig := signEkey(id, peerID, sess.ekey.PublicRaw())
|
||||
payload := proto.SignalingPayload{
|
||||
Kind: proto.SigEkey,
|
||||
V: "yaw/2.1",
|
||||
EPK: epkHex,
|
||||
EkeySig: sig,
|
||||
}
|
||||
// ekey is always sealed under static keys (§5.4′ (a)).
|
||||
if err := s.SendTo(peerID, payload); err != nil {
|
||||
log.Printf("anchor: send ekey to %s: %v", peerID.Short(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// receiveEkey validates and stores the peer's ephemeral public key.
|
||||
func receiveEkey(payload proto.SignalingPayload, from proto.PeerID, id *crypto.Identity, sess *peerSession) error {
|
||||
epkBytes, err := hex.DecodeString(payload.EPK)
|
||||
if err != nil || len(epkBytes) != 32 {
|
||||
return fmt.Errorf("bad epk hex")
|
||||
}
|
||||
// Verify sig: "yaw/2.1 ekey" || from_id_raw(32) || our_id_raw(32) || epk_raw(32)
|
||||
fromRaw, err := hex.DecodeString(string(from))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad from id")
|
||||
}
|
||||
ourRaw, err := hex.DecodeString(string(id.PeerID()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad own id")
|
||||
}
|
||||
msg := append([]byte("yaw/2.1 ekey"), fromRaw...)
|
||||
msg = append(msg, ourRaw...)
|
||||
msg = append(msg, epkBytes...)
|
||||
if err := crypto.Verify(string(from), msg, payload.EkeySig); err != nil {
|
||||
return fmt.Errorf("ekey sig: %w", err)
|
||||
}
|
||||
var epk [32]byte
|
||||
copy(epk[:], epkBytes)
|
||||
sess.peerEPK = &epk
|
||||
sess.fs = true
|
||||
close(sess.ekeyRx) // signal waiters
|
||||
return nil
|
||||
}
|
||||
|
||||
// signEkey produces the Ed25519 signature for our ekey message.
|
||||
// Input: "yaw/2.1 ekey" || our_id_raw(32) || peer_id_raw(32) || epk_raw(32)
|
||||
func signEkey(id *crypto.Identity, peerID proto.PeerID, epk *[32]byte) string {
|
||||
ourRaw, _ := hex.DecodeString(string(id.PeerID()))
|
||||
peerRaw, _ := hex.DecodeString(string(peerID))
|
||||
msg := append([]byte("yaw/2.1 ekey"), ourRaw...)
|
||||
msg = append(msg, peerRaw...)
|
||||
msg = append(msg, epk[:]...)
|
||||
return id.Sign(msg)
|
||||
}
|
||||
|
||||
// ── signaling dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
func dispatchSignaling(
|
||||
ctx context.Context,
|
||||
payload proto.SignalingPayload,
|
||||
usedEph bool,
|
||||
fromID proto.PeerID,
|
||||
id *crypto.Identity,
|
||||
m *mesh.Mesh,
|
||||
s *sender,
|
||||
pcs map[proto.PeerID]*webrtc.PeerConnection,
|
||||
sessions map[proto.PeerID]*peerSession,
|
||||
mu *sync.RWMutex,
|
||||
) {
|
||||
switch payload.Kind {
|
||||
|
||||
case proto.SigOffer:
|
||||
go func() {
|
||||
pc, err := answer(payload, fromID, id, m, s)
|
||||
mu.Lock()
|
||||
sess := sessions[fromID]
|
||||
mu.Unlock()
|
||||
|
||||
pc, err := answerOffer(ctx, payload, fromID, id, m, s, sess)
|
||||
if err != nil {
|
||||
log.Printf("anchor: answer to %s: %v", fromID.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[fromID] = pc
|
||||
if existing, ok := sessions[fromID]; ok && existing != sess {
|
||||
// Session was already replaced; close the new PC.
|
||||
pc.Close()
|
||||
} else {
|
||||
if sess == nil {
|
||||
// No session yet (2.0 peer — no ekey): create a minimal one.
|
||||
sess2, _ := newSession(pc)
|
||||
if sess2 != nil {
|
||||
sess2.fs = false
|
||||
sessions[fromID] = sess2
|
||||
}
|
||||
} else {
|
||||
sess.pc = pc
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
case proto.SigAnswer:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
sess, ok := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
if !ok || sess.pc == nil {
|
||||
log.Printf("anchor: answer from %s but no PeerConnection", fromID.Short())
|
||||
return
|
||||
}
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
if err := sess.pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: payload.SDP,
|
||||
}); err != nil {
|
||||
@@ -192,12 +384,12 @@ func dispatchSignaling(
|
||||
|
||||
case proto.SigCandidate:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
sess, ok := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
if !ok || sess.pc == nil {
|
||||
return
|
||||
}
|
||||
if err := pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||
if err := sess.pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||
Candidate: payload.Cand,
|
||||
SDPMid: strPtr(payload.Mid),
|
||||
SDPMLineIndex: uint16Ptr(uint16(payload.MLine)),
|
||||
@@ -207,9 +399,9 @@ func dispatchSignaling(
|
||||
|
||||
case proto.SigBye:
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[fromID]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, fromID)
|
||||
if sess, ok := sessions[fromID]; ok {
|
||||
sess.close()
|
||||
delete(sessions, fromID)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -217,39 +409,82 @@ func dispatchSignaling(
|
||||
|
||||
// ── offer / answer helpers ────────────────────────────────────────────────────
|
||||
|
||||
func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
|
||||
sess, err := newSession(pc)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
|
||||
if err != nil {
|
||||
sess.close()
|
||||
return nil, err
|
||||
}
|
||||
mesh.WireDataChannel(dc, pc, peerID, id, m)
|
||||
mesh.WireCandidateTrickle(pc, peerID, s)
|
||||
|
||||
sdpOffer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpOffer); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(peerID, proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP})
|
||||
// Handle file DataChannels opened by the remote peer.
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
if strings.HasPrefix(dc.Label(), "f:") {
|
||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||
m.HandleInboundFileDC(dc, xid, peerID)
|
||||
}
|
||||
})
|
||||
|
||||
// Send our ekey immediately.
|
||||
sendEkey(peerID, sess, id, s)
|
||||
|
||||
// Build the offer in a goroutine so we don't block the read loop.
|
||||
go func() {
|
||||
// Wait for peer's ekey or fall back after timeout.
|
||||
select {
|
||||
case <-sess.ekeyRx:
|
||||
log.Printf("anchor: 2.1 FS offer to %s", peerID.Short())
|
||||
case <-time.After(ekeyTimeout):
|
||||
log.Printf("anchor: 2.0 fallback offer to %s (no ekey received)", peerID.Short())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
sdpOffer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
log.Printf("anchor: create offer to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpOffer); err != nil {
|
||||
log.Printf("anchor: set local offer to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
payload := proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP}
|
||||
if err := s.sealAndSend(peerID, payload, sess); err != nil {
|
||||
log.Printf("anchor: send offer to %s: %v", peerID.Short(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
||||
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
if dc.Label() == "yaw" {
|
||||
switch {
|
||||
case dc.Label() == "yaw":
|
||||
mesh.WireDataChannel(dc, pc, fromID, id, m)
|
||||
case strings.HasPrefix(dc.Label(), "f:"):
|
||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||
m.HandleInboundFileDC(dc, xid, fromID)
|
||||
}
|
||||
})
|
||||
mesh.WireCandidateTrickle(pc, fromID, s)
|
||||
@@ -270,7 +505,13 @@ func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Iden
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(fromID, proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP})
|
||||
|
||||
answerPayload := proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP}
|
||||
if err := s.sealAndSend(fromID, answerPayload, sess); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// ── sender implements mesh.Anchor ────────────────────────────────────────────
|
||||
@@ -280,6 +521,7 @@ type sender struct {
|
||||
sendCh chan proto.AnchorMessage
|
||||
}
|
||||
|
||||
// SendTo seals with STATIC keys (used for ekey and 2.0 fallback).
|
||||
func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error {
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -290,8 +532,33 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
|
||||
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
|
||||
}
|
||||
sealed := crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
|
||||
return s.enqueue(peerID, sealed)
|
||||
}
|
||||
|
||||
// sealAndSend seals with ephemeral keys if available, static otherwise.
|
||||
func (s *sender) sealAndSend(peerID proto.PeerID, payload proto.SignalingPayload, sess *peerSession) error {
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sealed string
|
||||
if sess != nil && sess.peerEPK != nil {
|
||||
// Ephemeral seal (2.1 FS).
|
||||
sealed = crypto.SignalingBox(plaintext, sess.peerEPK, sess.ekey.PrivateRaw())
|
||||
} else {
|
||||
// Static seal (2.0 compatible).
|
||||
recipientCurve, err := curveFromPeerID(peerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
|
||||
}
|
||||
sealed = crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
|
||||
}
|
||||
return s.enqueue(peerID, sealed)
|
||||
}
|
||||
|
||||
func (s *sender) enqueue(peerID proto.PeerID, box string) error {
|
||||
select {
|
||||
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: sealed}:
|
||||
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: box}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("send queue full")
|
||||
@@ -300,23 +567,37 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
|
||||
|
||||
func (s *sender) LocalID() proto.PeerID { return s.id.PeerID() }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── box opening ───────────────────────────────────────────────────────────────
|
||||
|
||||
func openBox(b64box string, fromID proto.PeerID, localID *crypto.Identity) (proto.SignalingPayload, error) {
|
||||
// openBoxAuto opens an incoming box, trying ephemeral keys first (if available),
|
||||
// then falling back to static. Returns the payload and whether ephemeral was used.
|
||||
func openBoxAuto(b64box string, fromID proto.PeerID, localID *crypto.Identity, sess *peerSession) (proto.SignalingPayload, bool, error) {
|
||||
senderCurve, err := curveFromPeerID(fromID)
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
return proto.SignalingPayload{}, false, err
|
||||
}
|
||||
|
||||
// Try ephemeral first if we have the peer's epk.
|
||||
if sess != nil && sess.peerEPK != nil {
|
||||
if pt, err := crypto.SignalingOpen(b64box, sess.peerEPK, sess.ekey.PrivateRaw()); err == nil {
|
||||
var p proto.SignalingPayload
|
||||
if err := json.Unmarshal(pt, &p); err == nil {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to static keys.
|
||||
plaintext, err := crypto.SignalingOpen(b64box, senderCurve, localID.CurvePrivateKey())
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
return proto.SignalingPayload{}, false, fmt.Errorf("open box: %w", err)
|
||||
}
|
||||
var p proto.SignalingPayload
|
||||
return p, json.Unmarshal(plaintext, &p)
|
||||
return p, false, json.Unmarshal(plaintext, &p)
|
||||
}
|
||||
|
||||
// curveFromPeerID derives an X25519 public key from a hex Ed25519 peer id
|
||||
// using the Montgomery conversion, identical to crypto.Identity.CurvePublicKey().
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func curveFromPeerID(id proto.PeerID) (*[32]byte, error) {
|
||||
pubBytes, err := hex.DecodeString(string(id))
|
||||
if err != nil || len(pubBytes) != 32 {
|
||||
@@ -337,12 +618,11 @@ func hashNetName(name string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newPC() (*webrtc.PeerConnection, error) {
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
||||
})
|
||||
func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
func uint16Ptr(v uint16) *uint16 { return &v }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
func uint16Ptr(v uint16) *uint16 { return &v }
|
||||
|
||||
@@ -15,14 +15,18 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
@@ -97,6 +101,21 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) {
|
||||
return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil
|
||||
}
|
||||
|
||||
// SaveIdentity writes the identity to dataDir/identity.json, overwriting any existing file.
|
||||
// Used by --import-identity to commit an imported backup to disk.
|
||||
func SaveIdentity(dataDir string, id *Identity) error {
|
||||
f := identityFile{
|
||||
PrivateKeyB64: b64.EncodeToString(id.privateKey),
|
||||
Alias: id.Alias,
|
||||
}
|
||||
raw, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dataDir, "identity.json")
|
||||
return os.WriteFile(path, raw, 0600)
|
||||
}
|
||||
|
||||
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2).
|
||||
func (id *Identity) PeerID() proto.PeerID {
|
||||
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
||||
@@ -112,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// PeerIDHex satisfies the invite.Signer interface.
|
||||
func (id *Identity) PeerIDHex() string { return string(id.PeerID()) }
|
||||
|
||||
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(id.privateKey, data)
|
||||
@@ -179,6 +201,24 @@ func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeriveForNetwork returns a new in-memory Identity derived from the master key
|
||||
// and the given network hash (hex). Same master + same network hash always
|
||||
// produces the same keypair, so the peer ID is stable across restarts.
|
||||
// Different networks produce different peer IDs, preventing cross-network correlation.
|
||||
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
|
||||
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
|
||||
var seed [32]byte
|
||||
if _, err := io.ReadFull(r, seed[:]); err != nil {
|
||||
return nil, fmt.Errorf("deriving network identity: %w", err)
|
||||
}
|
||||
priv := ed25519.NewKeyFromSeed(seed[:])
|
||||
return &Identity{
|
||||
privateKey: priv,
|
||||
PublicKey: priv.Public().(ed25519.PublicKey),
|
||||
Alias: master.Alias,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── X25519 ECDH ───────────────────────────────────────────────────────────────
|
||||
|
||||
// EphemeralKey is an X25519 keypair used for a single session.
|
||||
@@ -211,6 +251,21 @@ func (ek *EphemeralKey) PublicKeyB64() string {
|
||||
return b64.EncodeToString(ek.public[:])
|
||||
}
|
||||
|
||||
// PublicRaw returns a pointer to the raw 32-byte X25519 public key.
|
||||
// The returned pointer is valid for the lifetime of the EphemeralKey.
|
||||
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
|
||||
|
||||
// PrivateRaw returns a pointer to the raw 32-byte X25519 private key.
|
||||
// Use only when you need to pass it directly to SignalingBox.
|
||||
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
|
||||
|
||||
// Wipe zeroes the private key. Call when the session ends.
|
||||
func (ek *EphemeralKey) Wipe() {
|
||||
for i := range ek.private {
|
||||
ek.private[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SharedSecret performs ECDH with the other party's public key.
|
||||
// Returns a 32-byte shared secret suitable for use as an AEAD key.
|
||||
func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
|
||||
@@ -228,6 +283,127 @@ func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ── Identity backup / restore ─────────────────────────────────────────────────
|
||||
|
||||
// keyBackup is the on-disk / exportable format, matching the sister project's
|
||||
// yaw-key-backup-1 schema for cross-app portability.
|
||||
type keyBackup struct {
|
||||
Yaw string `json:"yaw"`
|
||||
ID string `json:"id"`
|
||||
Alg string `json:"alg"`
|
||||
Ops uint32 `json:"ops"`
|
||||
Mem uint32 `json:"mem"`
|
||||
Salt string `json:"salt"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ct string `json:"ct"`
|
||||
}
|
||||
|
||||
// backupPlaintext is what gets sealed inside the backup.
|
||||
type backupPlaintext struct {
|
||||
PrivateKey string `json:"priv"` // base64url Ed25519 private key (64 bytes)
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
const (
|
||||
backupArgonOps = 2
|
||||
backupArgonMem = 64 * 1024 * 1024 // 64 MiB
|
||||
)
|
||||
|
||||
// ExportIdentity encrypts the identity with passphrase and returns a JSON
|
||||
// backup blob in the yaw-key-backup-1 format.
|
||||
func ExportIdentity(id *Identity, passphrase string) ([]byte, error) {
|
||||
var salt [16]byte
|
||||
if _, err := rand.Read(salt[:]); err != nil {
|
||||
return nil, fmt.Errorf("generating salt: %w", err)
|
||||
}
|
||||
|
||||
key := deriveKey(passphrase, salt[:])
|
||||
|
||||
pt, err := json.Marshal(backupPlaintext{
|
||||
PrivateKey: base64.StdEncoding.EncodeToString(id.privateKey),
|
||||
Alias: id.Alias,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nonce [24]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("generating nonce: %w", err)
|
||||
}
|
||||
|
||||
ct := secretbox.Seal(nil, pt, &nonce, &key)
|
||||
|
||||
backup := keyBackup{
|
||||
Yaw: "yaw-key-backup-1",
|
||||
ID: string(id.PeerID()),
|
||||
Alg: "argon2id-secretbox",
|
||||
Ops: backupArgonOps,
|
||||
Mem: backupArgonMem,
|
||||
Salt: base64.StdEncoding.EncodeToString(salt[:]),
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
|
||||
Ct: base64.StdEncoding.EncodeToString(ct),
|
||||
}
|
||||
return json.MarshalIndent(backup, "", " ")
|
||||
}
|
||||
|
||||
// ImportIdentity decrypts a yaw-key-backup-1 blob and returns the Identity.
|
||||
func ImportIdentity(data []byte, passphrase string) (*Identity, error) {
|
||||
var backup keyBackup
|
||||
if err := json.Unmarshal(data, &backup); err != nil {
|
||||
return nil, fmt.Errorf("parsing backup: %w", err)
|
||||
}
|
||||
if backup.Yaw != "yaw-key-backup-1" {
|
||||
return nil, fmt.Errorf("unsupported backup format %q", backup.Yaw)
|
||||
}
|
||||
|
||||
salt, err := base64.StdEncoding.DecodeString(backup.Salt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding salt: %w", err)
|
||||
}
|
||||
nonceBytes, err := base64.StdEncoding.DecodeString(backup.Nonce)
|
||||
if err != nil || len(nonceBytes) != 24 {
|
||||
return nil, fmt.Errorf("decoding nonce: %w", err)
|
||||
}
|
||||
ct, err := base64.StdEncoding.DecodeString(backup.Ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding ciphertext: %w", err)
|
||||
}
|
||||
|
||||
key := deriveKey(passphrase, salt)
|
||||
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], nonceBytes)
|
||||
pt, ok := secretbox.Open(nil, ct, &nonce, &key)
|
||||
if !ok {
|
||||
return nil, errors.New("decryption failed — wrong passphrase?")
|
||||
}
|
||||
|
||||
var plain backupPlaintext
|
||||
if err := json.Unmarshal(pt, &plain); err != nil {
|
||||
return nil, fmt.Errorf("parsing backup plaintext: %w", err)
|
||||
}
|
||||
|
||||
privBytes, err := base64.StdEncoding.DecodeString(plain.PrivateKey)
|
||||
if err != nil || len(privBytes) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("invalid private key in backup")
|
||||
}
|
||||
|
||||
priv := ed25519.PrivateKey(privBytes)
|
||||
return &Identity{
|
||||
privateKey: priv,
|
||||
PublicKey: priv.Public().(ed25519.PublicKey),
|
||||
Alias: plain.Alias,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func deriveKey(passphrase string, salt []byte) [32]byte {
|
||||
raw := argon2.IDKey([]byte(passphrase), salt, backupArgonOps, backupArgonMem/1024, 1, 32)
|
||||
var key [32]byte
|
||||
copy(key[:], raw)
|
||||
return key
|
||||
}
|
||||
|
||||
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
||||
|
||||
// Session holds the symmetric key for an established peer session.
|
||||
|
||||
@@ -60,6 +60,44 @@ func TestSignalingBoxTamperedFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportImportRoundtrip(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
passphrase := "correct horse battery staple"
|
||||
|
||||
blob, err := ExportIdentity(id, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportIdentity: %v", err)
|
||||
}
|
||||
|
||||
// Exported blob must contain the public peer ID in plaintext.
|
||||
if !strings.Contains(string(blob), string(id.PeerID())) {
|
||||
t.Fatal("exported blob does not contain peer ID")
|
||||
}
|
||||
|
||||
imported, err := ImportIdentity(blob, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportIdentity: %v", err)
|
||||
}
|
||||
|
||||
if imported.PeerID() != id.PeerID() {
|
||||
t.Fatalf("peer ID mismatch: got %s, want %s", imported.PeerID(), id.PeerID())
|
||||
}
|
||||
if imported.Alias != id.Alias {
|
||||
t.Fatalf("alias mismatch: got %q, want %q", imported.Alias, id.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWrongPassphraseFails(t *testing.T) {
|
||||
id := newTestIdentity(t)
|
||||
blob, err := ExportIdentity(id, "correct")
|
||||
if err != nil {
|
||||
t.Fatalf("ExportIdentity: %v", err)
|
||||
}
|
||||
if _, err := ImportIdentity(blob, "wrong"); err == nil {
|
||||
t.Fatal("expected error with wrong passphrase, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIdentity creates a fresh in-memory identity for testing.
|
||||
func newTestIdentity(t *testing.T) *Identity {
|
||||
t.Helper()
|
||||
|
||||
130
internal/invite/invite.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package invite encodes and decodes waste invite strings.
|
||||
//
|
||||
// Format: "waste:<url-safe-base64(json)>"
|
||||
//
|
||||
// The JSON payload is compatible with yaw2: the `net` field carries the full
|
||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
||||
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||
// the same network without knowing the plaintext name.
|
||||
//
|
||||
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
|
||||
// the invite was issued by a known peer. Receiving peers that enforce
|
||||
// RequireInvite will reject hellos that carry no valid signed invite.
|
||||
// YAW/2-only peers ignore both fields.
|
||||
package invite
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "waste:"
|
||||
|
||||
// Invite holds the information needed to join a network.
|
||||
type Invite struct {
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
|
||||
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
|
||||
}
|
||||
|
||||
// IsSigned reports whether the invite carries a signature.
|
||||
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
|
||||
|
||||
// Signer can sign data and report its own peer ID.
|
||||
type Signer interface {
|
||||
Sign(data []byte) string
|
||||
PeerIDHex() string
|
||||
}
|
||||
|
||||
// Verifier verifies an Ed25519 signature given a hex public key.
|
||||
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
|
||||
|
||||
// Encode returns an unsigned waste: invite string (backward compatible).
|
||||
func Encode(anchor, network string) (string, error) {
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
Network: network,
|
||||
Net: NetHash(network),
|
||||
})
|
||||
}
|
||||
|
||||
// EncodeSigned returns a signed waste: invite string.
|
||||
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
|
||||
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
|
||||
if anchor == "" {
|
||||
return "", fmt.Errorf("anchor URL is required")
|
||||
}
|
||||
if network == "" {
|
||||
return "", fmt.Errorf("network name is required")
|
||||
}
|
||||
inviter := signer.PeerIDHex()
|
||||
net := NetHash(network)
|
||||
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
Network: network,
|
||||
Net: net,
|
||||
Inviter: inviter,
|
||||
Sig: sig,
|
||||
})
|
||||
}
|
||||
|
||||
// Verify checks the invite signature and that the inviter is in the trusted set.
|
||||
// Unsigned invites return nil — the caller decides whether to accept them.
|
||||
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
|
||||
if !inv.IsSigned() {
|
||||
return nil
|
||||
}
|
||||
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
|
||||
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
|
||||
return fmt.Errorf("invite signature invalid: %w", err)
|
||||
}
|
||||
if !trusted[inv.Inviter] {
|
||||
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode parses a waste: invite string and returns the Invite.
|
||||
func Decode(s string) (Invite, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return Invite{}, fmt.Errorf("not a waste invite (expected 'waste:' prefix)")
|
||||
}
|
||||
b, err := base64.URLEncoding.DecodeString(strings.TrimPrefix(s, prefix))
|
||||
if err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite: %w", err)
|
||||
}
|
||||
var inv Invite
|
||||
if err := json.Unmarshal(b, &inv); err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite payload: %w", err)
|
||||
}
|
||||
if inv.Anchor == "" || inv.Network == "" {
|
||||
return Invite{}, fmt.Errorf("invite is missing anchor or network name")
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// NetHash returns the full 64-char hex network hash for the given name.
|
||||
func NetHash(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func marshal(inv Invite) (string, error) {
|
||||
b, err := json.Marshal(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func sigPayload(anchor, network, net, inviter string) []byte {
|
||||
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
|
||||
}
|
||||
38
internal/invite/invite_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package invite
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
s, err := Encode("ws://my-vps:17339/ws", "friends")
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
if len(s) < 7 || s[:6] != "waste:" {
|
||||
t.Fatalf("expected waste: prefix, got %q", s)
|
||||
}
|
||||
|
||||
inv, err := Decode(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if inv.Anchor != "ws://my-vps:17339/ws" {
|
||||
t.Fatalf("anchor = %q", inv.Anchor)
|
||||
}
|
||||
if inv.Network != "friends" {
|
||||
t.Fatalf("network = %q", inv.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"notaninvite",
|
||||
"waste:!!!",
|
||||
"waste:" + "eyJhbmNob3IiOiIifQ==", // missing network
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := Decode(c); err == nil {
|
||||
t.Errorf("Decode(%q) expected error, got nil", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package ipc implements the local IPC server.
|
||||
// The UI (or any local tool) connects to 127.0.0.1:17337 and speaks
|
||||
// newline-delimited JSON: send IpcMessage commands, receive IpcMessage events.
|
||||
//
|
||||
// Backward compat: commands without network_id route to the first joined network.
|
||||
package ipc
|
||||
|
||||
import (
|
||||
@@ -12,20 +14,41 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/netmgr"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/shares"
|
||||
)
|
||||
|
||||
// JoinFunc is called when the UI issues a join_network command.
|
||||
type JoinFunc func(ctx context.Context, networkName string)
|
||||
// RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort.
|
||||
// Each WebSocket connection gets the same handleClient treatment as TCP.
|
||||
// The OriginPatterns option allows connections from local dev servers.
|
||||
func RunWS(mgr *netmgr.Manager, wsPort int) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", wsPort)
|
||||
log.Printf("ipc: WS listening on %s", addr)
|
||||
return http.ListenAndServe(addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ipc: ws accept: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("ipc: WS client connected")
|
||||
nc := websocket.NetConn(r.Context(), conn, websocket.MessageText)
|
||||
handleClient(nc, mgr)
|
||||
}))
|
||||
}
|
||||
|
||||
// Run starts the IPC listener. Blocks until the listener fails.
|
||||
// Network join/leave state is daemon-scoped (shared across all IPC clients).
|
||||
func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
func Run(mgr *netmgr.Manager, port int) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -33,54 +56,29 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
}
|
||||
log.Printf("ipc: listening on %s", addr)
|
||||
|
||||
// networkCancel is shared across all clients — any client can join/leave,
|
||||
// and the join persists even after the commanding client disconnects.
|
||||
var (
|
||||
networkMu sync.Mutex
|
||||
networkCancel context.CancelFunc
|
||||
)
|
||||
|
||||
doJoin := func(name string) {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
networkMu.Unlock()
|
||||
go join(ctx, name)
|
||||
}
|
||||
|
||||
doLeave := func() {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
}
|
||||
networkMu.Unlock()
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc accept: %w", err)
|
||||
}
|
||||
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
|
||||
go handleClient(conn, m, doJoin, doLeave)
|
||||
go handleClient(conn, mgr)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func()) {
|
||||
func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
defer conn.Close()
|
||||
|
||||
events := m.Subscribe()
|
||||
defer m.Unsubscribe(events)
|
||||
events := mgr.Subscribe()
|
||||
defer mgr.Unsubscribe(events)
|
||||
|
||||
writeCh := make(chan []byte, 128)
|
||||
done := make(chan struct{})
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Writer goroutine — sole owner of the write side of the connection.
|
||||
// Writer goroutine.
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
w := bufio.NewWriter(conn)
|
||||
for line := range writeCh {
|
||||
line = append(line, '\n')
|
||||
@@ -91,9 +89,7 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
|
||||
}
|
||||
}()
|
||||
|
||||
// Event pusher — forwards mesh events to the UI client.
|
||||
// recover() guards against the rare race where writeCh is closed while a
|
||||
// send is in flight (closed channel panics even inside select).
|
||||
// Event pusher — forwards Manager events to the UI client.
|
||||
go func() {
|
||||
defer func() { recover() }() //nolint:errcheck
|
||||
for {
|
||||
@@ -129,12 +125,8 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
|
||||
}
|
||||
}
|
||||
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
// Send initial state snapshot.
|
||||
send(stateSnapshot(mgr))
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
@@ -146,43 +138,277 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
|
||||
|
||||
switch cmd.Type {
|
||||
|
||||
case proto.CmdSendMessage:
|
||||
msg := &proto.ChatMessage{
|
||||
Mid: randomHex(16),
|
||||
ID: uuid.NewString(),
|
||||
From: m.Identity.PeerID(),
|
||||
To: cmd.To,
|
||||
Room: cmd.Room,
|
||||
Body: cmd.Body,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
payload, err := json.Marshal(proto.PeerMessage{Type: proto.MsgChat, Chat: msg})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.Broadcast(payload)
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg})
|
||||
|
||||
case proto.CmdJoinNetwork:
|
||||
if cmd.NetworkName == "" {
|
||||
send(errMsg("join_network: network_name is required"))
|
||||
var (
|
||||
netID string
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case cmd.NetworkName != "":
|
||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||
case len(cmd.NetworkHash) == 64:
|
||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||
default:
|
||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||
continue
|
||||
}
|
||||
doJoin(cmd.NetworkName)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||
continue
|
||||
}
|
||||
if n, ok := mgr.Get(netID); ok {
|
||||
if cmd.RequireInvite {
|
||||
n.Mesh.RequireInvite = true
|
||||
}
|
||||
if cmd.InviteString != "" {
|
||||
n.Mesh.InviteString = cmd.InviteString
|
||||
}
|
||||
}
|
||||
_ = netID
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
doLeave()
|
||||
if cmd.NetworkID != "" {
|
||||
mgr.Leave(cmd.NetworkID)
|
||||
} else {
|
||||
// Backward compat: leave the first joined network.
|
||||
if n := mgr.Default(); n != nil {
|
||||
mgr.Leave(n.ID)
|
||||
}
|
||||
}
|
||||
|
||||
case proto.CmdSendMessage:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("send_message: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
ts := time.Now().UnixMilli()
|
||||
if cmd.To != nil {
|
||||
// DM → spec "pm" type: flat {type, mid, text, ts} on the wire
|
||||
mid := randomHex(16)
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgPm,
|
||||
Mid: mid,
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Mesh.SendTo(*cmd.To, wire)
|
||||
// Store locally with dm:<short-id> room convention
|
||||
local := &proto.ChatMessage{
|
||||
Mid: mid,
|
||||
From: n.Identity.PeerID(),
|
||||
To: cmd.To,
|
||||
Room: "dm:" + (*cmd.To).Short(),
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
}
|
||||
n.Mesh.SaveMessage(local)
|
||||
n.Mesh.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtMessageReceived,
|
||||
NetworkID: n.ID,
|
||||
Message: local,
|
||||
})
|
||||
} else {
|
||||
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
||||
mid := randomHex(16)
|
||||
msgID := proto.ComputeMsgID(n.Identity.PeerID(), cmd.Room, ts, cmd.Body)
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgChat,
|
||||
Mid: mid,
|
||||
Room: cmd.Room,
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Mesh.Broadcast(wire)
|
||||
local := &proto.ChatMessage{
|
||||
Mid: mid,
|
||||
MsgID: msgID,
|
||||
From: n.Identity.PeerID(),
|
||||
Room: cmd.Room,
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
}
|
||||
n.Mesh.SaveMessage(local)
|
||||
n.Mesh.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtMessageReceived,
|
||||
NetworkID: n.ID,
|
||||
Message: local,
|
||||
})
|
||||
}
|
||||
|
||||
case proto.CmdCreateRoom:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("create_room: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(cmd.Room)
|
||||
if name == "" || name == "general" {
|
||||
send(errMsg("create_room: room name is required and cannot be 'general'"))
|
||||
continue
|
||||
}
|
||||
if err := n.Store.SaveRoom(name); err != nil {
|
||||
send(errMsg(fmt.Sprintf("create_room: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{Type: proto.EvtRoomCreated, NetworkID: n.ID, Room: name})
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(stateSnapshot(mgr))
|
||||
|
||||
case proto.CmdGetFileList:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("get_file_list: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.PeerID == nil || *cmd.PeerID == n.Identity.PeerID() {
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtFileList,
|
||||
NetworkID: n.ID,
|
||||
PeerID: ptr(n.Identity.PeerID()),
|
||||
Files: mgr.ScanAllShares(n.ID),
|
||||
})
|
||||
} else {
|
||||
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !n.Mesh.SendTo(*cmd.PeerID, req) {
|
||||
send(errMsg(fmt.Sprintf("get_file_list: peer %s not connected", (*cmd.PeerID).Short())))
|
||||
}
|
||||
}
|
||||
|
||||
case proto.CmdAddShare:
|
||||
if cmd.Path == "" {
|
||||
send(errMsg("add_share: path is required"))
|
||||
continue
|
||||
}
|
||||
networks := cmd.ShareNetworks
|
||||
if len(networks) == 0 {
|
||||
networks = []string{"*"}
|
||||
}
|
||||
if err := mgr.Shares.Add(shares.Share{Path: cmd.Path, Networks: networks}); err != nil {
|
||||
send(errMsg(fmt.Sprintf("add_share: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(sharesListMsg(mgr))
|
||||
|
||||
case proto.CmdRemoveShare:
|
||||
if cmd.Path == "" {
|
||||
send(errMsg("remove_share: path is required"))
|
||||
continue
|
||||
}
|
||||
if err := mgr.Shares.Remove(cmd.Path); err != nil {
|
||||
send(errMsg(fmt.Sprintf("remove_share: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(sharesListMsg(mgr))
|
||||
|
||||
case proto.CmdListShares:
|
||||
send(sharesListMsg(mgr))
|
||||
|
||||
case proto.CmdGenerateInvite:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("generate_invite: not currently joined to a network"))
|
||||
continue
|
||||
}
|
||||
if mgr.AnchorURL() == "" {
|
||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||
continue
|
||||
}
|
||||
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
Type: proto.EvtInviteGenerated,
|
||||
NetworkID: n.ID,
|
||||
InviteGenerated: inv,
|
||||
})
|
||||
|
||||
case proto.CmdSetShareDir:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("set_share_dir: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if !mgr.SetShareDir(n.ID, cmd.Path) {
|
||||
send(errMsg("set_share_dir: network not found"))
|
||||
continue
|
||||
}
|
||||
|
||||
case proto.CmdSetDownloadDir:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("set_download_dir: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.Path == "" {
|
||||
send(errMsg("set_download_dir: path is required"))
|
||||
continue
|
||||
}
|
||||
if !mgr.SetDownloadDir(n.ID, cmd.Path) {
|
||||
send(errMsg("set_download_dir: network not found"))
|
||||
continue
|
||||
}
|
||||
send(stateSnapshot(mgr))
|
||||
|
||||
case proto.CmdSendFile:
|
||||
send(errMsg("file transfer not yet implemented"))
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("send_file: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.PeerID == nil {
|
||||
send(errMsg("send_file: peer_id is required"))
|
||||
continue
|
||||
}
|
||||
if cmd.Path == "" {
|
||||
send(errMsg("send_file: path is required"))
|
||||
continue
|
||||
}
|
||||
if err := n.Mesh.OfferFile(*cmd.PeerID, cmd.Path); err != nil {
|
||||
send(errMsg(fmt.Sprintf("send_file: %v", err)))
|
||||
}
|
||||
|
||||
case proto.CmdExportIdentity:
|
||||
if cmd.Passphrase == "" {
|
||||
send(errMsg("export_identity: passphrase is required"))
|
||||
continue
|
||||
}
|
||||
blob, err := crypto.ExportIdentity(mgr.MasterIdentity(), cmd.Passphrase)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("export_identity: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtIdentityExported,
|
||||
Backup: string(blob),
|
||||
})
|
||||
|
||||
case proto.CmdImportIdentity:
|
||||
if cmd.Passphrase == "" || cmd.Backup == "" {
|
||||
send(errMsg("import_identity: passphrase and backup are required"))
|
||||
continue
|
||||
}
|
||||
_, err := crypto.ImportIdentity([]byte(cmd.Backup), cmd.Passphrase)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("import_identity: %v", err)))
|
||||
continue
|
||||
}
|
||||
// Import is intentionally read-only here: returns the decrypted identity
|
||||
// for the caller to verify before committing. Actual on-disk replacement
|
||||
// requires a daemon restart with --import flag (see cmd/daemon).
|
||||
send(proto.IpcMessage{Type: proto.EvtIdentityImported})
|
||||
|
||||
default:
|
||||
send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
@@ -191,13 +417,67 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
|
||||
|
||||
close(done)
|
||||
close(writeCh)
|
||||
<-writerDone // wait for writer to flush before conn.Close() fires
|
||||
log.Printf("ipc: UI client disconnected")
|
||||
}
|
||||
|
||||
// stateSnapshot builds a state_snapshot covering all joined networks.
|
||||
// Backward compat: local_peer and connected_peers are populated from the first network.
|
||||
func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
||||
all := mgr.All()
|
||||
|
||||
master := mgr.MasterIdentity()
|
||||
msg := proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
Rooms: []string{"general"},
|
||||
MasterAlias: master.Alias,
|
||||
MasterID: string(master.PeerID()),
|
||||
}
|
||||
|
||||
var netInfos []proto.NetworkInfo
|
||||
for _, n := range all {
|
||||
pi := n.Identity.PeerInfo()
|
||||
netInfos = append(netInfos, proto.NetworkInfo{
|
||||
NetworkID: n.ID,
|
||||
NetworkName: n.Name,
|
||||
LocalPeer: &pi,
|
||||
ShareDir: n.Mesh.ShareDir,
|
||||
DownloadDir: n.Mesh.DownloadDir,
|
||||
})
|
||||
}
|
||||
msg.Networks = netInfos
|
||||
|
||||
// Backward-compat fields — populated from the first network when one exists.
|
||||
if len(all) > 0 {
|
||||
pi := all[0].Identity.PeerInfo()
|
||||
msg.LocalPeer = &pi
|
||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
||||
if extra, err := all[0].Store.Rooms(); err == nil {
|
||||
for _, r := range extra {
|
||||
msg.Rooms = append(msg.Rooms, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
func errMsg(s string) proto.IpcMessage {
|
||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||
}
|
||||
|
||||
func sharesListMsg(mgr *netmgr.Manager) proto.IpcMessage {
|
||||
all := mgr.Shares.All()
|
||||
entries := make([]proto.ShareEntry, len(all))
|
||||
for i, sh := range all {
|
||||
entries[i] = proto.ShareEntry{Path: sh.Path, Networks: sh.Networks}
|
||||
}
|
||||
return proto.IpcMessage{Type: proto.EvtSharesList, Shares: entries}
|
||||
}
|
||||
|
||||
// ensure shares import is used
|
||||
var _ = shares.Share{}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func randomHex(n int) string {
|
||||
@@ -205,3 +485,12 @@ func randomHex(n int) string {
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// autoJoin is called by the daemon when --join is provided at startup.
|
||||
// It joins the network before the IPC listener starts accepting clients.
|
||||
func AutoJoin(ctx context.Context, mgr *netmgr.Manager, networkName string) {
|
||||
_ = ctx // Manager owns the context internally
|
||||
if _, err := mgr.Join(networkName, ""); err != nil {
|
||||
log.Printf("ipc: auto-join %q failed: %v", networkName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,61 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/store"
|
||||
)
|
||||
|
||||
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
|
||||
type ICEServer = webrtc.ICEServer
|
||||
|
||||
// PeerConn is a live connection to one peer.
|
||||
type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
// Send a line of JSON to this peer (pre-encrypted by the sender goroutine).
|
||||
Send chan<- []byte
|
||||
// PC is the underlying PeerConnection, used to open additional DataChannels.
|
||||
PC *webrtc.PeerConnection
|
||||
}
|
||||
|
||||
// Mesh is the shared state of the local node.
|
||||
// All methods are safe to call from multiple goroutines.
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
Identity *crypto.Identity
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
|
||||
InviteString string // the invite this peer used to join (sent in hello to other peers)
|
||||
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||
// multi-share scanning without the mesh needing to know about shares.json.
|
||||
ScanFiles func() []proto.FileEntry
|
||||
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
|
||||
// file transfer state
|
||||
transferMu sync.Mutex
|
||||
outbound map[string]*outboundTransfer // xid → pending outbound
|
||||
inbound map[string]*inboundTransfer // xid → pending inbound
|
||||
|
||||
// PendingConnect receives peer IDs discovered via gossip that we should
|
||||
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
||||
PendingConnect chan proto.PeerID
|
||||
|
||||
// historyRequested tracks rooms for which we have already sent a history_request
|
||||
// this session. Reset on reconnect is intentional (new peers may have newer history).
|
||||
historyMu sync.Mutex
|
||||
historyRequested map[string]bool // room → true
|
||||
historyFirstPeer proto.PeerID // ID of the peer we requested history from
|
||||
|
||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||
subMu sync.Mutex
|
||||
@@ -29,17 +64,78 @@ type Mesh struct {
|
||||
}
|
||||
|
||||
// New creates an empty mesh with the given identity.
|
||||
func New(id *crypto.Identity) *Mesh {
|
||||
// Pass a non-nil store to enable message and peer persistence.
|
||||
func New(id *crypto.Identity, st *store.Store) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
Identity: id,
|
||||
Store: st,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
outbound: make(map[string]*outboundTransfer),
|
||||
inbound: make(map[string]*inboundTransfer),
|
||||
PendingConnect: make(chan proto.PeerID, 32),
|
||||
historyRequested: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// trustedPeerIDs returns a set of peer IDs trusted on this network:
|
||||
// all currently connected peers plus all peers in the persistent store.
|
||||
func (m *Mesh) trustedPeerIDs() map[string]bool {
|
||||
trusted := map[string]bool{}
|
||||
// Own identity is always trusted.
|
||||
trusted[string(m.Identity.PeerID())] = true
|
||||
// Connected peers.
|
||||
m.mu.RLock()
|
||||
for id := range m.peers {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
// Previously seen peers from the store.
|
||||
if m.Store != nil {
|
||||
if known, err := m.Store.KnownPeers(); err == nil {
|
||||
for id := range known {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// ScanShareDir returns the list of files in the local share directory.
|
||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||
if m.ShareDir == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(m.ShareDir)
|
||||
if err != nil {
|
||||
log.Printf("mesh: scan share dir %s: %v", m.ShareDir, err)
|
||||
return nil
|
||||
}
|
||||
var files []proto.FileEntry
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, proto.FileEntry{Name: e.Name(), SizeBytes: info.Size()})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// ── Peer management ───────────────────────────────────────────────────────────
|
||||
|
||||
// AddPeer registers a connected peer and notifies subscribers.
|
||||
func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
// Seed alias from cache so returning peers resolve immediately (before hello).
|
||||
if m.Store != nil {
|
||||
if cached := m.Store.PeerAlias(conn.Info.ID); cached != "" {
|
||||
conn.Info.Alias = cached
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.peers[conn.Info.ID] = conn
|
||||
m.mu.Unlock()
|
||||
@@ -50,6 +146,26 @@ func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
})
|
||||
}
|
||||
|
||||
// SaveMessage persists a chat message if a store is configured.
|
||||
// Duplicate mids are silently dropped.
|
||||
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
||||
if m.Store == nil || msg == nil {
|
||||
return
|
||||
}
|
||||
if err := m.Store.SaveMessage(msg); err != nil {
|
||||
log.Printf("mesh: store message %s: %v", msg.Mid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePeerAlias updates the cached alias for a peer after hello verification.
|
||||
func (m *Mesh) UpdatePeerAlias(id proto.PeerID, alias string) {
|
||||
if m.Store != nil && alias != "" {
|
||||
if err := m.Store.SavePeer(id, alias); err != nil {
|
||||
log.Printf("mesh: update peer alias %s: %v", id.Short(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemovePeer unregisters a peer and notifies subscribers.
|
||||
func (m *Mesh) RemovePeer(id proto.PeerID) {
|
||||
m.mu.Lock()
|
||||
@@ -127,6 +243,122 @@ func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestHistoryFrom sends history_request messages to peerID for all rooms
|
||||
// we know about but haven't yet requested this session. Only contacts the first
|
||||
// peer we connect to, to avoid fan-out amplification.
|
||||
func (m *Mesh) RequestHistoryFrom(peerID proto.PeerID) {
|
||||
if m.Store == nil {
|
||||
return
|
||||
}
|
||||
m.historyMu.Lock()
|
||||
if m.historyFirstPeer != "" && m.historyFirstPeer != peerID {
|
||||
m.historyMu.Unlock()
|
||||
return // only request from the first peer
|
||||
}
|
||||
m.historyFirstPeer = peerID
|
||||
m.historyMu.Unlock()
|
||||
|
||||
rooms, err := m.Store.Rooms()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Always include "general" even if not explicitly created.
|
||||
roomSet := map[string]bool{"general": true}
|
||||
for _, r := range rooms {
|
||||
roomSet[r] = true
|
||||
}
|
||||
|
||||
m.historyMu.Lock()
|
||||
var toRequest []string
|
||||
for r := range roomSet {
|
||||
if !m.historyRequested[r] {
|
||||
m.historyRequested[r] = true
|
||||
toRequest = append(toRequest, r)
|
||||
}
|
||||
}
|
||||
m.historyMu.Unlock()
|
||||
|
||||
for _, room := range toRequest {
|
||||
req, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgHistoryRequest,
|
||||
Room: room,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.SendTo(peerID, req)
|
||||
log.Printf("mesh: sent history_request room=%s to %s", room, peerID.Short())
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHistoryRequest responds to a history_request from a peer.
|
||||
func (m *Mesh) HandleHistoryRequest(from proto.PeerID, room string, sinceMs int64, limit int) {
|
||||
if m.Store == nil {
|
||||
return
|
||||
}
|
||||
msgs, err := m.Store.RecentMessagesSince(room, sinceMs, limit)
|
||||
if err != nil {
|
||||
log.Printf("mesh: history_request from %s room=%s: %v", from.Short(), room, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Look up aliases for from_peer values.
|
||||
entries := make([]proto.HistoryEntry, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
entries = append(entries, proto.HistoryEntry{
|
||||
Mid: msg.Mid,
|
||||
From: string(msg.From),
|
||||
FromAlias: m.Store.PeerAlias(msg.From),
|
||||
Text: msg.Text,
|
||||
Ts: msg.Ts,
|
||||
})
|
||||
}
|
||||
|
||||
chunk, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgHistoryChunk,
|
||||
Room: room,
|
||||
History: entries,
|
||||
HistoryDone: true,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.SendTo(from, chunk)
|
||||
log.Printf("mesh: sent history_chunk room=%s to %s: %d msgs", room, from.Short(), len(entries))
|
||||
}
|
||||
|
||||
// HandleHistoryChunk saves received history messages and emits history_loaded.
|
||||
func (m *Mesh) HandleHistoryChunk(room string, entries []proto.HistoryEntry) {
|
||||
if m.Store == nil || len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
var saved []proto.ChatMessage
|
||||
for _, e := range entries {
|
||||
msg := &proto.ChatMessage{
|
||||
Mid: e.Mid,
|
||||
MsgID: e.Mid, // mid is already content-addressed for gossipped messages
|
||||
From: proto.PeerID(e.From),
|
||||
Room: room,
|
||||
Text: e.Text,
|
||||
Ts: e.Ts,
|
||||
}
|
||||
if err := m.Store.SaveMessage(msg); err != nil {
|
||||
continue
|
||||
}
|
||||
saved = append(saved, *msg)
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
return
|
||||
}
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtHistoryLoaded,
|
||||
Room: room,
|
||||
Messages: saved,
|
||||
})
|
||||
log.Printf("mesh: history_chunk room=%s: %d/%d new messages", room, len(saved), len(entries))
|
||||
}
|
||||
|
||||
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||
m.emit(msg)
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
@@ -21,7 +26,7 @@ type Anchor interface {
|
||||
}
|
||||
|
||||
// WireDataChannel sets up open/message/close handlers on a "yaw" DataChannel.
|
||||
// Must be called before the DataChannel opens.
|
||||
// Safe to call whether the channel is already open or not (§6 open-race).
|
||||
func WireDataChannel(
|
||||
dc *webrtc.DataChannel,
|
||||
pc *webrtc.PeerConnection,
|
||||
@@ -31,18 +36,20 @@ func WireDataChannel(
|
||||
) {
|
||||
sendCh := make(chan []byte, 64)
|
||||
|
||||
dc.OnOpen(func() {
|
||||
var once sync.Once
|
||||
doOpen := func() {
|
||||
log.Printf("peer: DataChannel open with %s", peerID.Short())
|
||||
|
||||
// Send hello — bind our identity to this DTLS session.
|
||||
localFP, remoteFP := dtlsFingerprints(pc)
|
||||
bindBytes := proto.HelloBindString(localFP, remoteFP)
|
||||
hello := proto.HelloMessage{
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
Invite: m.InviteString,
|
||||
}
|
||||
helloJSON, _ := json.Marshal(hello)
|
||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||
@@ -53,9 +60,15 @@ func WireDataChannel(
|
||||
peerConn := &PeerConn{
|
||||
Info: proto.PeerInfo{ID: peerID, Alias: string(peerID.Short())},
|
||||
Send: sendCh,
|
||||
PC: pc,
|
||||
}
|
||||
m.AddPeer(peerConn)
|
||||
|
||||
// Request the peer's file list immediately after connect.
|
||||
if req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq}); err == nil {
|
||||
sendCh <- req
|
||||
}
|
||||
|
||||
go func() {
|
||||
for payload := range sendCh {
|
||||
if err := dc.SendText(string(payload)); err != nil {
|
||||
@@ -64,7 +77,13 @@ func WireDataChannel(
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
dc.OnOpen(func() { once.Do(doOpen) })
|
||||
// §6 gotcha: answerer's DC may already be open when OnDataChannel fires.
|
||||
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
once.Do(doOpen)
|
||||
}
|
||||
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
@@ -78,6 +97,17 @@ func WireDataChannel(
|
||||
m.RemovePeer(peerID)
|
||||
pc.Close()
|
||||
})
|
||||
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerStatus,
|
||||
PeerID: &peerID,
|
||||
ConnState: state.String(),
|
||||
})
|
||||
if state == webrtc.PeerConnectionStateConnected {
|
||||
go emitICEStats(pc, peerID, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WireCandidateTrickle seals and forwards each ICE candidate via the anchor as it arrives.
|
||||
@@ -121,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||
return
|
||||
}
|
||||
|
||||
// Invite enforcement (waste-go extension).
|
||||
if m.RequireInvite {
|
||||
if hello.Invite == "" {
|
||||
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
inv, err := invite.Decode(hello.Invite)
|
||||
if err != nil || !inv.IsSigned() {
|
||||
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
trusted := m.trustedPeerIDs()
|
||||
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
|
||||
log.Printf("peer: rejecting %s — %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update alias once we have the verified nick.
|
||||
m.mu.Lock()
|
||||
if conn, ok := m.peers[from]; ok {
|
||||
@@ -128,11 +189,16 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
conn.Info.PublicKey = hello.ID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.UpdatePeerAlias(from, hello.Nick)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtSessionReady,
|
||||
PeerID: peerIDPtr(from),
|
||||
Nick: hello.Nick,
|
||||
})
|
||||
// Tell the new peer about everyone we can currently see.
|
||||
go m.sendGossipTo(from)
|
||||
// Request message history from this peer (EXT-007).
|
||||
go m.RequestHistoryFrom(from)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,30 +213,158 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
switch msg.Type {
|
||||
case proto.MsgChat:
|
||||
if msg.Chat != nil {
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg.Chat})
|
||||
chat := &proto.ChatMessage{
|
||||
Mid: midOrRandom(msg.Mid),
|
||||
MsgID: proto.ComputeMsgID(from, msg.Room, msg.Ts, msg.Text),
|
||||
From: from,
|
||||
Room: msg.Room,
|
||||
Text: msg.Text,
|
||||
Ts: msg.Ts,
|
||||
}
|
||||
m.SaveMessage(chat)
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
|
||||
|
||||
case proto.MsgPm:
|
||||
// Private message — reconstruct as ChatMessage for IPC/storage using dm:<short-id> room.
|
||||
chat := &proto.ChatMessage{
|
||||
Mid: midOrRandom(msg.Mid),
|
||||
From: from,
|
||||
Room: "dm:" + from.Short(),
|
||||
Text: msg.Text,
|
||||
Ts: msg.Ts,
|
||||
}
|
||||
m.SaveMessage(chat)
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
|
||||
|
||||
case proto.MsgFileListReq:
|
||||
var files []proto.FileEntry
|
||||
if m.ScanFiles != nil {
|
||||
files = m.ScanFiles()
|
||||
} else {
|
||||
files = m.ScanShareDir()
|
||||
}
|
||||
resp, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileListResp,
|
||||
FileListResp: &proto.FileListResp{Files: files},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.SendTo(from, resp)
|
||||
|
||||
case proto.MsgFileListResp:
|
||||
if msg.FileListResp != nil {
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileList,
|
||||
PeerID: peerIDPtr(from),
|
||||
Files: msg.FileListResp.Files,
|
||||
})
|
||||
}
|
||||
|
||||
case proto.MsgFileOffer:
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtIncomingFile,
|
||||
PeerID: peerIDPtr(from),
|
||||
Offer: &proto.FileOffer{Xid: msg.Xid, Name: msg.Name, Size: msg.Size, SHA256: msg.SHA256},
|
||||
})
|
||||
m.acceptIncoming(msg, from)
|
||||
|
||||
case proto.MsgFileAccept:
|
||||
m.startSend(msg.Xid, from, msg.ResumeOffset)
|
||||
|
||||
case proto.MsgFileCancel:
|
||||
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
||||
|
||||
case proto.MsgFileDone:
|
||||
log.Printf("mesh: file-done from %s xid=%s", from.Short(), msg.Xid)
|
||||
|
||||
case proto.MsgPeerGossip:
|
||||
if msg.Gossip != nil {
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Short(), len(msg.Gossip.Peers))
|
||||
if msg.Gossip == nil {
|
||||
return
|
||||
}
|
||||
// Build set of peers we already know about (connected + self).
|
||||
connected := m.ConnectedPeers()
|
||||
known := make(map[proto.PeerID]bool, len(connected)+1)
|
||||
known[m.Identity.PeerID()] = true
|
||||
for _, p := range connected {
|
||||
known[p.ID] = true
|
||||
}
|
||||
newPeers := 0
|
||||
for _, entry := range msg.Gossip.Peers {
|
||||
if known[entry.Peer.ID] {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.PendingConnect <- entry.Peer.ID:
|
||||
newPeers++
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
||||
case proto.MsgHistoryRequest:
|
||||
go m.HandleHistoryRequest(from, msg.Room, msg.Since, msg.Limit)
|
||||
|
||||
case proto.MsgHistoryChunk:
|
||||
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
log.Printf("mesh: pong from %s", from.Short())
|
||||
case proto.MsgFileOffer:
|
||||
if msg.FileOffer != nil {
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtIncomingFile,
|
||||
PeerID: peerIDPtr(from),
|
||||
Offer: msg.FileOffer,
|
||||
})
|
||||
}
|
||||
default:
|
||||
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Short())
|
||||
}
|
||||
}
|
||||
|
||||
// emitICEStats reads the active (nominated) ICE candidate pair from pion's stats
|
||||
// and emits a peer_status event with the candidate type and remote address.
|
||||
func emitICEStats(pc *webrtc.PeerConnection, peerID proto.PeerID, m *Mesh) {
|
||||
stats := pc.GetStats()
|
||||
candidateType := "unknown"
|
||||
remoteAddress := ""
|
||||
|
||||
// Find the nominated candidate pair and its remote candidate.
|
||||
for _, s := range stats {
|
||||
pair, ok := s.(webrtc.ICECandidatePairStats)
|
||||
if !ok || !pair.Nominated {
|
||||
continue
|
||||
}
|
||||
// Look up remote candidate by its ID.
|
||||
if rc, ok2 := stats[pair.RemoteCandidateID]; ok2 {
|
||||
remote, ok3 := rc.(webrtc.ICECandidateStats)
|
||||
if ok3 {
|
||||
candidateType = remote.CandidateType.String()
|
||||
if remote.Port > 0 {
|
||||
remoteAddress = fmt.Sprintf("%s:%d", remote.IP, remote.Port)
|
||||
} else {
|
||||
remoteAddress = remote.IP
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerStatus,
|
||||
PeerID: &peerID,
|
||||
CandidateType: candidateType,
|
||||
RemoteAddress: remoteAddress,
|
||||
})
|
||||
}
|
||||
|
||||
// midOrRandom returns mid if non-empty, otherwise generates a random 16-byte hex string.
|
||||
// Ensures every stored message has a unique mid even from peers that don't send one.
|
||||
func midOrRandom(mid string) string {
|
||||
if mid != "" {
|
||||
return mid
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte(time.Now().String()))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func dtlsFingerprints(pc *webrtc.PeerConnection) (local, remote []byte) {
|
||||
if ld := pc.LocalDescription(); ld != nil {
|
||||
local = fingerprintFromSDP(ld.SDP)
|
||||
@@ -194,4 +388,29 @@ func fingerprintFromSDP(sdp string) []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendGossipTo sends the current live peer list to peerID so they can discover
|
||||
// and connect to peers they haven't met yet. Called after hello is verified.
|
||||
func (m *Mesh) sendGossipTo(peerID proto.PeerID) {
|
||||
peers := m.ConnectedPeers()
|
||||
entries := make([]proto.GossipEntry, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if p.ID == peerID {
|
||||
continue // don't include the recipient in their own gossip
|
||||
}
|
||||
entries = append(entries, proto.GossipEntry{Peer: p})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgPeerGossip,
|
||||
Gossip: &proto.PeerGossip{Peers: entries},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.SendTo(peerID, wire)
|
||||
log.Printf("mesh: sent gossip to %s: %d peer(s)", peerID.Short(), len(entries))
|
||||
}
|
||||
|
||||
func peerIDPtr(p proto.PeerID) *proto.PeerID { return &p }
|
||||
|
||||
473
internal/mesh/transfer.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
fileChunkSize = 64 * 1024 // 64 KiB per chunk
|
||||
fileBufferHighWater = 512 * 1024 // pause sending above this
|
||||
fileBufferLowWater = 256 * 1024 // resume when it drops here
|
||||
)
|
||||
|
||||
type outboundTransfer struct {
|
||||
peerID proto.PeerID
|
||||
path string
|
||||
size int64
|
||||
sha256 string
|
||||
}
|
||||
|
||||
type inboundTransfer struct {
|
||||
from proto.PeerID
|
||||
name string
|
||||
size int64
|
||||
sha256 string
|
||||
mu sync.Mutex
|
||||
tmp *os.File
|
||||
hasher hash.Hash
|
||||
written int64 // total bytes received; starts at resumeOffset when resuming
|
||||
resumePath string // path to existing .tmp when resuming; empty for new transfers
|
||||
resumeOffset int64 // bytes already present in resumePath
|
||||
metaPath string // path to the .tmp.meta sidecar
|
||||
}
|
||||
|
||||
// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file.
|
||||
// It survives interruptions so the receiver can resume later.
|
||||
type partialMeta struct {
|
||||
Name string `json:"name"`
|
||||
SHA256 string `json:"sha256"`
|
||||
From string `json:"from"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches.
|
||||
// Returns (tmpPath, metaPath, offset) — all empty/zero if no match.
|
||||
func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) {
|
||||
metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta"))
|
||||
for _, mp := range metas {
|
||||
data, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m partialMeta
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
if m.SHA256 != sha256hex {
|
||||
continue
|
||||
}
|
||||
tp := strings.TrimSuffix(mp, ".meta")
|
||||
info, err := os.Stat(tp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return tp, mp, info.Size()
|
||||
}
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
func writePartialMeta(path string, t *inboundTransfer) {
|
||||
data, _ := json.Marshal(partialMeta{
|
||||
Name: t.name,
|
||||
SHA256: t.sha256,
|
||||
From: string(t.from),
|
||||
Size: t.size,
|
||||
})
|
||||
os.WriteFile(path, data, 0o644) //nolint:errcheck
|
||||
}
|
||||
|
||||
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||
// file-offer to peerID over the existing "yaw" DataChannel.
|
||||
func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
||||
if m.ShareDir == "" {
|
||||
return fmt.Errorf("no share directory configured")
|
||||
}
|
||||
if strings.ContainsAny(filename, "/\\") || filename == ".." {
|
||||
return fmt.Errorf("invalid filename %q", filename)
|
||||
}
|
||||
path := filepath.Join(m.ShareDir, filename)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %q: %w", filename, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("hash %q: %w", filename, err)
|
||||
}
|
||||
shaHex := hex.EncodeToString(h.Sum(nil))
|
||||
xid := newXid()
|
||||
|
||||
m.transferMu.Lock()
|
||||
m.outbound[xid] = &outboundTransfer{
|
||||
peerID: peerID,
|
||||
path: path,
|
||||
size: info.Size(),
|
||||
sha256: shaHex,
|
||||
}
|
||||
m.transferMu.Unlock()
|
||||
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileOffer,
|
||||
Xid: xid,
|
||||
Name: filename,
|
||||
Size: info.Size(),
|
||||
SHA256: shaHex,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.SendTo(peerID, wire) {
|
||||
m.transferMu.Lock()
|
||||
delete(m.outbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
return fmt.Errorf("peer %s not connected", peerID.Short())
|
||||
}
|
||||
log.Printf("transfer: offered %s (%d bytes) to %s xid=%s", filename, info.Size(), peerID.Short(), xid[:8])
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptIncoming is called when we receive file-offer from a peer.
|
||||
// Checks for a resumable partial download matching the offer's sha256.
|
||||
// Sends file-accept immediately, with a non-zero resume_offset when resuming.
|
||||
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
|
||||
t := &inboundTransfer{
|
||||
from: from,
|
||||
name: msg.Name,
|
||||
size: msg.Size,
|
||||
sha256: msg.SHA256,
|
||||
}
|
||||
|
||||
var resumeOffset int64
|
||||
if m.DownloadDir != "" {
|
||||
if tp, mp, off := findPartial(m.DownloadDir, msg.SHA256); tp != "" {
|
||||
t.resumePath = tp
|
||||
t.metaPath = mp
|
||||
t.resumeOffset = off
|
||||
t.written = off
|
||||
resumeOffset = off
|
||||
log.Printf("transfer: found partial for %s at %s (%d/%d bytes)", msg.Name, tp, off, msg.Size)
|
||||
}
|
||||
}
|
||||
|
||||
m.transferMu.Lock()
|
||||
m.inbound[msg.Xid] = t
|
||||
m.transferMu.Unlock()
|
||||
|
||||
accept, _ := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileAccept,
|
||||
Xid: msg.Xid,
|
||||
ResumeOffset: resumeOffset,
|
||||
})
|
||||
m.SendTo(from, accept)
|
||||
if resumeOffset > 0 {
|
||||
log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8])
|
||||
} else {
|
||||
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
||||
}
|
||||
}
|
||||
|
||||
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
||||
// Called when we receive file-accept from the peer. resumeOffset is non-zero
|
||||
// when the receiver is resuming a previous partial download.
|
||||
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.outbound[xid]
|
||||
m.transferMu.Unlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: file-accept for unknown xid %s", xid[:8])
|
||||
return
|
||||
}
|
||||
if t.peerID != from {
|
||||
log.Printf("transfer: file-accept from unexpected peer %s", from.Short())
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
conn, ok := m.peers[from]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: peer %s not connected for xid %s", from.Short(), xid[:8])
|
||||
return
|
||||
}
|
||||
|
||||
ordered := true
|
||||
dc, err := conn.PC.CreateDataChannel("f:"+xid, &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if err != nil {
|
||||
log.Printf("transfer: create file DC xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for receiver to signal "ok" before sending — this ensures the receiver
|
||||
// has its OnMessage/OnClose handlers registered before any data arrives.
|
||||
dc.OnOpen(func() {
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString && string(msg.Data) == "ok" {
|
||||
go m.sendFileChunks(dc, t, xid, resumeOffset)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string, offset int64) {
|
||||
defer func() {
|
||||
m.transferMu.Lock()
|
||||
delete(m.outbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
}()
|
||||
|
||||
f, err := os.Open(t.path)
|
||||
if err != nil {
|
||||
log.Printf("transfer: open file xid=%s: %v", xid[:8], err)
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
log.Printf("transfer: seek to %d xid=%s: %v", offset, xid[:8], err)
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
log.Printf("transfer: resuming send from byte %d xid=%s", offset, xid[:8])
|
||||
}
|
||||
|
||||
// Backpressure: block when the DC buffer is full.
|
||||
resume := make(chan struct{}, 1)
|
||||
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
||||
dc.OnBufferedAmountLow(func() {
|
||||
select {
|
||||
case resume <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
buf := make([]byte, fileChunkSize)
|
||||
sent := offset // start progress reporting from where the receiver left off
|
||||
|
||||
for {
|
||||
n, readErr := f.Read(buf)
|
||||
if n > 0 {
|
||||
for dc.BufferedAmount() > fileBufferHighWater {
|
||||
<-resume
|
||||
}
|
||||
if err := dc.Send(buf[:n]); err != nil {
|
||||
log.Printf("transfer: send chunk xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
sent += int64(n)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileProgress,
|
||||
TransferID: xid,
|
||||
BytesReceived: sent,
|
||||
TotalBytes: t.size,
|
||||
})
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
log.Printf("transfer: read file xid=%s: %v", xid[:8], readErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dc.Close()
|
||||
|
||||
done, _ := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileDone,
|
||||
Xid: xid,
|
||||
SHA256: t.sha256,
|
||||
})
|
||||
m.SendTo(t.peerID, done)
|
||||
log.Printf("transfer: sent %s (%d bytes) xid=%s", filepath.Base(t.path), sent, xid[:8])
|
||||
}
|
||||
|
||||
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
||||
// arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
|
||||
// existing partial), verifies the SHA-256 on close, and emits EvtFileComplete.
|
||||
// Interrupted transfers keep their .tmp and .meta files for future resume.
|
||||
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.inbound[xid]
|
||||
m.transferMu.Unlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: no pending inbound for xid %s", xid[:8])
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
doOpen := func() {
|
||||
if err := os.MkdirAll(m.DownloadDir, 0o755); err != nil {
|
||||
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resumePath != "" {
|
||||
// Resume: open existing .tmp in read-write mode, seek to end for appending.
|
||||
f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("transfer: open resume file %s: %v", t.resumePath, err)
|
||||
return
|
||||
}
|
||||
// Restore the hasher state by re-hashing the bytes already on disk.
|
||||
h := sha256.New()
|
||||
if _, err := f.Seek(0, io.SeekStart); err == nil {
|
||||
io.Copy(h, f) //nolint:errcheck
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err)
|
||||
f.Close()
|
||||
return
|
||||
}
|
||||
t.tmp = f
|
||||
t.hasher = h
|
||||
log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8])
|
||||
} else {
|
||||
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||
if err != nil {
|
||||
log.Printf("transfer: create temp file: %v", err)
|
||||
return
|
||||
}
|
||||
t.tmp = tmp
|
||||
t.hasher = sha256.New()
|
||||
t.metaPath = tmp.Name() + ".meta"
|
||||
writePartialMeta(t.metaPath, t)
|
||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||
}
|
||||
|
||||
// Signal sender that we are ready — it will not start streaming until
|
||||
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
||||
dc.SendText("ok") //nolint:errcheck
|
||||
}
|
||||
|
||||
// Register OnMessage first so pion queues any early binary chunks.
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
return // "ok" echo or any other text; ignore
|
||||
}
|
||||
t.mu.Lock()
|
||||
tmp, h := t.tmp, t.hasher
|
||||
t.mu.Unlock()
|
||||
if tmp == nil {
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(msg.Data); err != nil {
|
||||
log.Printf("transfer: write xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
h.Write(msg.Data)
|
||||
t.mu.Lock()
|
||||
t.written += int64(len(msg.Data))
|
||||
written := t.written
|
||||
t.mu.Unlock()
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileProgress,
|
||||
TransferID: xid,
|
||||
BytesReceived: written,
|
||||
TotalBytes: t.size,
|
||||
})
|
||||
})
|
||||
|
||||
dc.OnOpen(func() { once.Do(doOpen) })
|
||||
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
once.Do(doOpen)
|
||||
}
|
||||
|
||||
dc.OnClose(func() {
|
||||
t.mu.Lock()
|
||||
tmp := t.tmp
|
||||
metaPath := t.metaPath
|
||||
actualSha := ""
|
||||
if t.hasher != nil {
|
||||
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
||||
}
|
||||
name, expectedSha, size, written := t.name, t.sha256, t.size, t.written
|
||||
t.mu.Unlock()
|
||||
|
||||
m.transferMu.Lock()
|
||||
delete(m.inbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
|
||||
if tmp == nil {
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
tmp.Close()
|
||||
|
||||
// Incomplete — keep .tmp and .meta for future resume.
|
||||
if written < size {
|
||||
log.Printf("transfer: interrupted %s at %d/%d bytes xid=%s (resumable)", name, written, size, xid[:8])
|
||||
return
|
||||
}
|
||||
|
||||
// Full receive — verify integrity.
|
||||
if actualSha != expectedSha {
|
||||
os.Remove(tmpName)
|
||||
if metaPath != "" {
|
||||
os.Remove(metaPath)
|
||||
}
|
||||
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
TransferID: xid,
|
||||
ErrorMessage: fmt.Sprintf("file %s: sha256 mismatch", name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Success — remove sidecar and move to final path.
|
||||
if metaPath != "" {
|
||||
os.Remove(metaPath)
|
||||
}
|
||||
final := filepath.Join(m.DownloadDir, name)
|
||||
if _, err := os.Stat(final); err == nil {
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
||||
}
|
||||
if err := os.Rename(tmpName, final); err != nil {
|
||||
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
log.Printf("transfer: saved %s -> %s", name, final)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileComplete,
|
||||
TransferID: xid,
|
||||
Path: final,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func newXid() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
489
internal/netmgr/manager.go
Normal file
@@ -0,0 +1,489 @@
|
||||
// Package netmgr manages multiple concurrent network contexts.
|
||||
// Each network gets its own derived identity, mesh, store, and anchor connection.
|
||||
// The Manager fans out events from all networks to IPC subscribers, tagging each
|
||||
// event with the network_id so clients can route them appropriately.
|
||||
package netmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/shares"
|
||||
"github.com/waste-go/internal/store"
|
||||
)
|
||||
|
||||
// Config holds the Manager's startup configuration.
|
||||
type Config struct {
|
||||
MasterIdentity *crypto.Identity
|
||||
StoreDir string // base directory for per-network SQLite files
|
||||
DownloadDir string // base directory for received files; defaults to StoreDir if empty
|
||||
AnchorURL string // WebSocket anchor URL used for all networks
|
||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
||||
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
|
||||
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
|
||||
}
|
||||
|
||||
// Network is a single joined network context.
|
||||
type Network struct {
|
||||
ID string // first 8 hex chars of networkHash — stable, short, opaque
|
||||
Name string
|
||||
Hash string // full SHA-256("yaw2-net:"+name), hex
|
||||
|
||||
Identity *crypto.Identity
|
||||
Mesh *mesh.Mesh
|
||||
Store *store.Store
|
||||
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// ShareDir returns the share directory for this network (from the mesh).
|
||||
func (n *Network) ShareDir() string { return n.Mesh.ShareDir }
|
||||
|
||||
// Manager owns all joined networks and fans out their events to IPC subscribers.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.RWMutex
|
||||
networks map[string]*Network // keyed by Network.ID
|
||||
order []string // insertion order for "default" lookups
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs []chan proto.IpcMessage
|
||||
|
||||
Shares *shares.Store // persistent multi-share configuration
|
||||
}
|
||||
|
||||
// New creates a Manager from the given config.
|
||||
// Loads shares.json from StoreDir if it exists.
|
||||
func New(cfg Config) *Manager {
|
||||
sh, err := shares.Load(cfg.StoreDir)
|
||||
if err != nil {
|
||||
log.Printf("netmgr: loading shares.json: %v (starting empty)", err)
|
||||
sh, _ = shares.Load("") // fallback to empty
|
||||
}
|
||||
return &Manager{
|
||||
networks: make(map[string]*Network),
|
||||
cfg: cfg,
|
||||
Shares: sh,
|
||||
}
|
||||
}
|
||||
|
||||
// Join creates or rejoins a named network. Returns the network ID.
|
||||
// If the network is already joined the existing ID is returned immediately.
|
||||
// shareDir overrides the global default for this network; pass "" to use the default.
|
||||
func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
||||
netHash := hashNetName(name)
|
||||
netID := netHash[:8]
|
||||
|
||||
mgr.mu.Lock()
|
||||
if _, exists := mgr.networks[netID]; exists {
|
||||
mgr.mu.Unlock()
|
||||
return netID, nil
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
// Derive a network-specific identity.
|
||||
netID_full := netID
|
||||
derived, err := crypto.DeriveForNetwork(mgr.cfg.MasterIdentity, netHash)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive identity for %q: %w", name, err)
|
||||
}
|
||||
|
||||
// Open per-network store.
|
||||
dbPath := filepath.Join(mgr.cfg.StoreDir, "messages-"+netID_full+".db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open store for %q: %w", name, err)
|
||||
}
|
||||
|
||||
m := mesh.New(derived, st)
|
||||
// Per-network share dir takes precedence over the global default.
|
||||
if shareDir != "" {
|
||||
m.ShareDir = shareDir
|
||||
} else if mgr.cfg.ShareDir != "" {
|
||||
m.ShareDir = mgr.cfg.ShareDir
|
||||
}
|
||||
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID_full)
|
||||
capturedNetID := netID
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||
meshEvents := m.Subscribe()
|
||||
go func() {
|
||||
for evt := range meshEvents {
|
||||
evt.NetworkID = netID
|
||||
mgr.emit(evt)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
net := &Network{
|
||||
ID: netID,
|
||||
Name: name,
|
||||
Hash: netHash,
|
||||
Identity: derived,
|
||||
Mesh: m,
|
||||
Store: st,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.networks[netID] = net
|
||||
mgr.order = append(mgr.order, netID)
|
||||
mgr.mu.Unlock()
|
||||
|
||||
log.Printf("netmgr: joining network %q (id=%s peer=%s)", name, netID, derived.PeerID().Short())
|
||||
|
||||
if mgr.cfg.AnchorURL != "" {
|
||||
go func() {
|
||||
anchor.Run(ctx, mgr.cfg.AnchorURL, name, derived, m)
|
||||
log.Printf("netmgr: left network %q", name)
|
||||
}()
|
||||
}
|
||||
|
||||
mgr.emit(proto.IpcMessage{
|
||||
Type: proto.EvtNetworkJoined,
|
||||
NetworkID: netID,
|
||||
NetworkName: name,
|
||||
LocalPeer: peerPtr(derived.PeerInfo()),
|
||||
ShareDir: m.ShareDir,
|
||||
})
|
||||
|
||||
return netID, nil
|
||||
}
|
||||
|
||||
// JoinByHash joins a network using its pre-computed full 64-char hex hash
|
||||
// (yaw2 `net` field) instead of the plaintext name. The network is stored
|
||||
// with an empty name; the network_id (first 8 bytes) is used for display.
|
||||
// This enables joining networks whose names are unknown — e.g. from a yaw2
|
||||
// invite URL that only contains the hash.
|
||||
func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
||||
if len(netHash64) != 64 {
|
||||
return "", fmt.Errorf("netHash must be 64 hex chars, got %d", len(netHash64))
|
||||
}
|
||||
netID := netHash64[:16] // first 8 bytes = 16 hex chars
|
||||
|
||||
mgr.mu.Lock()
|
||||
if _, exists := mgr.networks[netID]; exists {
|
||||
mgr.mu.Unlock()
|
||||
return netID, nil
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
derived, err := crypto.DeriveForNetwork(mgr.cfg.MasterIdentity, netHash64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive identity for net %s: %w", netID, err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(mgr.cfg.StoreDir, "messages-"+netID+".db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open store for net %s: %w", netID, err)
|
||||
}
|
||||
|
||||
m := mesh.New(derived, st)
|
||||
if shareDir != "" {
|
||||
m.ShareDir = shareDir
|
||||
} else if mgr.cfg.ShareDir != "" {
|
||||
m.ShareDir = mgr.cfg.ShareDir
|
||||
}
|
||||
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID)
|
||||
capturedNetID2 := netID
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
meshEvents := m.Subscribe()
|
||||
go func() {
|
||||
for evt := range meshEvents {
|
||||
evt.NetworkID = netID
|
||||
mgr.emit(evt)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
net := &Network{
|
||||
ID: netID,
|
||||
Name: netID, // display as short hash when name is unknown
|
||||
Hash: netHash64,
|
||||
Identity: derived,
|
||||
Mesh: m,
|
||||
Store: st,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
mgr.mu.Lock()
|
||||
mgr.networks[netID] = net
|
||||
mgr.order = append(mgr.order, netID)
|
||||
mgr.mu.Unlock()
|
||||
|
||||
log.Printf("netmgr: joining network by hash id=%s peer=%s", netID, derived.PeerID().Short())
|
||||
|
||||
if mgr.cfg.AnchorURL != "" {
|
||||
go func() {
|
||||
anchor.RunByHash(ctx, mgr.cfg.AnchorURL, netHash64, derived, m)
|
||||
log.Printf("netmgr: left network %s", netID)
|
||||
}()
|
||||
}
|
||||
|
||||
mgr.emit(proto.IpcMessage{
|
||||
Type: proto.EvtNetworkJoined,
|
||||
NetworkID: netID,
|
||||
NetworkName: netID,
|
||||
LocalPeer: peerPtr(derived.PeerInfo()),
|
||||
ShareDir: m.ShareDir,
|
||||
})
|
||||
|
||||
return netID, nil
|
||||
}
|
||||
|
||||
// Leave cancels a network context by ID. Closes its store.
|
||||
func (mgr *Manager) Leave(netID string) {
|
||||
mgr.mu.Lock()
|
||||
net, ok := mgr.networks[netID]
|
||||
if ok {
|
||||
delete(mgr.networks, netID)
|
||||
mgr.order = removeStr(mgr.order, netID)
|
||||
}
|
||||
mgr.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
net.cancel()
|
||||
net.Store.Close()
|
||||
net.Mesh.Unsubscribe(net.Mesh.Subscribe()) // drain + close subscription
|
||||
log.Printf("netmgr: left network %q (id=%s)", net.Name, netID)
|
||||
mgr.emit(proto.IpcMessage{Type: proto.EvtNetworkLeft, NetworkID: netID})
|
||||
}
|
||||
|
||||
// SetShareDir updates the share directory for an already-joined network.
|
||||
// Changes take effect immediately for subsequent file-list requests and offers.
|
||||
func (mgr *Manager) SetShareDir(netID, path string) bool {
|
||||
mgr.mu.RLock()
|
||||
net, ok := mgr.networks[netID]
|
||||
mgr.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
net.Mesh.ShareDir = path
|
||||
log.Printf("netmgr: share dir for %q set to %q", net.Name, path)
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadBase returns the configured download base directory, falling back to StoreDir.
|
||||
func (mgr *Manager) downloadBase() string {
|
||||
if mgr.cfg.DownloadDir != "" {
|
||||
return mgr.cfg.DownloadDir
|
||||
}
|
||||
return mgr.cfg.StoreDir
|
||||
}
|
||||
|
||||
// SetDownloadDir updates the download directory for an already-joined network.
|
||||
// Changes take effect for the next incoming file transfer on that network.
|
||||
func (mgr *Manager) SetDownloadDir(netID, path string) bool {
|
||||
mgr.mu.RLock()
|
||||
net, ok := mgr.networks[netID]
|
||||
mgr.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
net.Mesh.DownloadDir = path
|
||||
log.Printf("netmgr: download dir for %q set to %q", net.Name, path)
|
||||
return true
|
||||
}
|
||||
|
||||
// LeaveAll leaves every joined network.
|
||||
func (mgr *Manager) LeaveAll() {
|
||||
mgr.mu.RLock()
|
||||
ids := make([]string, len(mgr.order))
|
||||
copy(ids, mgr.order)
|
||||
mgr.mu.RUnlock()
|
||||
for _, id := range ids {
|
||||
mgr.Leave(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a network by ID.
|
||||
func (mgr *Manager) Get(netID string) (*Network, bool) {
|
||||
mgr.mu.RLock()
|
||||
defer mgr.mu.RUnlock()
|
||||
n, ok := mgr.networks[netID]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Default returns the first joined network, or nil if none are joined.
|
||||
// Used to route commands that carry no network_id (backward compat).
|
||||
func (mgr *Manager) Default() *Network {
|
||||
mgr.mu.RLock()
|
||||
defer mgr.mu.RUnlock()
|
||||
if len(mgr.order) == 0 {
|
||||
return nil
|
||||
}
|
||||
return mgr.networks[mgr.order[0]]
|
||||
}
|
||||
|
||||
// Resolve returns the network for netID, or the default if netID is empty.
|
||||
func (mgr *Manager) Resolve(netID string) *Network {
|
||||
if netID == "" {
|
||||
return mgr.Default()
|
||||
}
|
||||
n, _ := mgr.Get(netID)
|
||||
return n
|
||||
}
|
||||
|
||||
// All returns a snapshot of all joined networks in join order.
|
||||
func (mgr *Manager) All() []*Network {
|
||||
mgr.mu.RLock()
|
||||
defer mgr.mu.RUnlock()
|
||||
out := make([]*Network, 0, len(mgr.order))
|
||||
for _, id := range mgr.order {
|
||||
out = append(out, mgr.networks[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScanAllShares returns file entries from all share roots visible to networkID,
|
||||
// combining the network's legacy ShareDir with entries from shares.json.
|
||||
func (mgr *Manager) ScanAllShares(netID string) []proto.FileEntry {
|
||||
var files []proto.FileEntry
|
||||
|
||||
// Legacy single-dir share from the network mesh.
|
||||
if n := mgr.Resolve(netID); n != nil {
|
||||
files = append(files, n.Mesh.ScanShareDir()...)
|
||||
}
|
||||
|
||||
// Additional shares from shares.json.
|
||||
if mgr.Shares != nil {
|
||||
for _, sh := range mgr.Shares.ForNetwork(netID) {
|
||||
files = append(files, scanDir(sh.Path)...)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// scanDir recursively walks a directory and returns FileEntry for each file.
|
||||
func scanDir(root string) []proto.FileEntry {
|
||||
var files []proto.FileEntry
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = d.Name()
|
||||
}
|
||||
files = append(files, proto.FileEntry{
|
||||
Name: d.Name(),
|
||||
SizeBytes: info.Size(),
|
||||
Path: rel,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
// AnchorURL returns the configured anchor URL.
|
||||
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
|
||||
|
||||
// MasterIdentity returns the master identity (not network-derived).
|
||||
func (mgr *Manager) MasterIdentity() *crypto.Identity { return mgr.cfg.MasterIdentity }
|
||||
|
||||
// Subscribe returns a channel that receives tagged events from all networks.
|
||||
func (mgr *Manager) Subscribe() <-chan proto.IpcMessage {
|
||||
ch := make(chan proto.IpcMessage, 128)
|
||||
mgr.subsMu.Lock()
|
||||
mgr.subs = append(mgr.subs, ch)
|
||||
mgr.subsMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes and closes a subscription channel.
|
||||
func (mgr *Manager) Unsubscribe(ch <-chan proto.IpcMessage) {
|
||||
mgr.subsMu.Lock()
|
||||
defer mgr.subsMu.Unlock()
|
||||
for i, s := range mgr.subs {
|
||||
if s == ch {
|
||||
mgr.subs = append(mgr.subs[:i], mgr.subs[i+1:]...)
|
||||
close(s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mgr *Manager) emit(msg proto.IpcMessage) {
|
||||
mgr.subsMu.Lock()
|
||||
defer mgr.subsMu.Unlock()
|
||||
for _, ch := range mgr.subs {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// turnICEServers returns TURN ICE servers if TurnURL and TurnSecret are set,
|
||||
// using coturn's use-auth-secret HMAC-SHA1 time-limited credential scheme.
|
||||
// Returns nil if TURN is not configured.
|
||||
func (mgr *Manager) turnICEServers() []webrtc.ICEServer {
|
||||
if mgr.cfg.TurnURL == "" || mgr.cfg.TurnSecret == "" {
|
||||
return nil
|
||||
}
|
||||
// Username = Unix timestamp 1 hour from now.
|
||||
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||
mac := hmac.New(sha1.New, []byte(mgr.cfg.TurnSecret))
|
||||
mac.Write([]byte(expiry))
|
||||
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return []webrtc.ICEServer{{
|
||||
URLs: []string{mgr.cfg.TurnURL},
|
||||
Username: expiry,
|
||||
Credential: credential,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func hashNetName(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func removeStr(ss []string, s string) []string {
|
||||
out := ss[:0]
|
||||
for _, v := range ss {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func peerPtr(p proto.PeerInfo) *proto.PeerInfo { return &p }
|
||||
@@ -3,7 +3,11 @@
|
||||
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,38 +38,94 @@ type PeerInfo struct {
|
||||
type MsgType string
|
||||
|
||||
const (
|
||||
MsgChat MsgType = "chat"
|
||||
MsgPeerGossip MsgType = "peer_gossip"
|
||||
MsgFileOffer MsgType = "file_offer"
|
||||
MsgFileResp MsgType = "file_response"
|
||||
MsgFileDone MsgType = "file_done"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
MsgChat MsgType = "chat"
|
||||
MsgPm MsgType = "pm" // private message, §8
|
||||
MsgPeerGossip MsgType = "peer_gossip"
|
||||
MsgFileListReq MsgType = "file_list_req"
|
||||
MsgFileListResp MsgType = "file_list_resp"
|
||||
MsgFileOffer MsgType = "file-offer" // §9, hyphenated per spec
|
||||
MsgFileAccept MsgType = "file-accept"
|
||||
MsgFileCancel MsgType = "file-cancel"
|
||||
MsgFileDone MsgType = "file-done"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
MsgHistoryRequest MsgType = "history_request"
|
||||
MsgHistoryChunk MsgType = "history_chunk"
|
||||
)
|
||||
|
||||
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
||||
// The sender/receiver are implicit from the DataChannel; no room or from fields on the wire.
|
||||
type PmMessage struct {
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix milliseconds
|
||||
}
|
||||
|
||||
// PeerMessage is the top-level container sent over the "yaw" DataChannel.
|
||||
// The spec types (hello, chat, pm, file-offer …) are flat JSON objects; we
|
||||
// embed the fields directly using inline structs where needed, but for structured
|
||||
// types we include the payload pointer. Unknown fields are ignored (forward compat).
|
||||
// File chunks go over a separate binary DataChannel labeled "f:<xid>".
|
||||
type PeerMessage struct {
|
||||
Type MsgType `json:"type"`
|
||||
|
||||
// Only one of these will be set, depending on Type.
|
||||
Chat *ChatMessage `json:"chat,omitempty"`
|
||||
Gossip *PeerGossip `json:"gossip,omitempty"`
|
||||
FileOffer *FileOffer `json:"file_offer,omitempty"`
|
||||
FileResp *FileResponse `json:"file_response,omitempty"`
|
||||
FileDone *FileDone `json:"file_done,omitempty"`
|
||||
Seq *uint64 `json:"seq,omitempty"` // for ping/pong
|
||||
// chat / pm fields (flat on the wire per spec §8)
|
||||
Mid string `json:"mid,omitempty"` // optional dedup id; required when relay hops > 0
|
||||
Room string `json:"room,omitempty"` // chat only
|
||||
Text string `json:"text,omitempty"` // chat and pm
|
||||
Ts int64 `json:"ts,omitempty"` // chat and pm (Unix ms)
|
||||
|
||||
// Non-spec extensions (unknown types are silently ignored by other impls)
|
||||
Gossip *PeerGossip `json:"gossip,omitempty"`
|
||||
FileListResp *FileListResp `json:"file_list_resp,omitempty"`
|
||||
|
||||
// file transfer (§9) — fields are flat on the wire
|
||||
Xid string `json:"xid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
ResumeOffset int64 `json:"resume_offset,omitempty"` // waste-go ext: non-zero in file-accept when resuming
|
||||
|
||||
// file-done / file-cancel / file-accept just need xid (already above)
|
||||
Reason string `json:"reason,omitempty"` // file-cancel
|
||||
|
||||
Seq *uint64 `json:"seq,omitempty"` // ping/pong
|
||||
|
||||
// history_request fields
|
||||
Since int64 `json:"since,omitempty"` // Unix ms; 0 = no lower bound
|
||||
Limit int `json:"limit,omitempty"`
|
||||
|
||||
// history_chunk fields
|
||||
History []HistoryEntry `json:"history,omitempty"`
|
||||
HistoryDone bool `json:"history_done,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage is a message to a room or a DM.
|
||||
// HistoryEntry is one message in a history_chunk response.
|
||||
type HistoryEntry struct {
|
||||
Mid string `json:"mid"`
|
||||
From string `json:"from"` // peer ID hex
|
||||
FromAlias string `json:"from_alias"` // advisory
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix ms
|
||||
}
|
||||
|
||||
// ChatMessage is a group chat message (wire type "chat", §8).
|
||||
// Also used internally for persisting PMs after they are received.
|
||||
type ChatMessage struct {
|
||||
Mid string `json:"mid"` // random 16-byte hex, for deduplication (YAW/2 §8)
|
||||
ID string `json:"id"` // internal uuid, kept for local use
|
||||
From PeerID `json:"from"`
|
||||
To *PeerID `json:"to,omitempty"` // nil = broadcast to room
|
||||
Room string `json:"room"`
|
||||
Body string `json:"body"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
||||
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
|
||||
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
|
||||
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
||||
Room string `json:"room"`
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix milliseconds
|
||||
}
|
||||
|
||||
// ComputeMsgID returns the EXT-007 content-addressed ID for a message.
|
||||
// sha256(fromID \x00 room \x00 ts_decimal \x00 text)
|
||||
func ComputeMsgID(fromID PeerID, room string, ts int64, text string) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%s", string(fromID), room, ts, text)
|
||||
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// PeerGossip shares known peer addresses.
|
||||
@@ -80,39 +140,48 @@ type GossipEntry struct {
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// FileOffer initiates a file transfer.
|
||||
type FileOffer struct {
|
||||
Mid string `json:"mid"` // dedup id
|
||||
Xid string `json:"xid"` // transfer id, used as DataChannel label "f:<xid>"
|
||||
Filename string `json:"filename"`
|
||||
// FileEntry describes a single file in a peer's shared directory.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"` // hex
|
||||
Path string `json:"path,omitempty"` // relative path including filename
|
||||
}
|
||||
|
||||
// FileResponse accepts or declines a FileOffer.
|
||||
type FileResponse struct {
|
||||
Mid string `json:"mid"`
|
||||
Xid string `json:"xid"`
|
||||
Accepted bool `json:"accepted"`
|
||||
// ShareEntry describes one persistent share root.
|
||||
type ShareEntry struct {
|
||||
Path string `json:"path"`
|
||||
Networks []string `json:"networks"` // ["*"] = global
|
||||
}
|
||||
|
||||
// FileDone signals that all chunks have been sent. Receiver verifies SHA256.
|
||||
type FileDone struct {
|
||||
Mid string `json:"mid"`
|
||||
// FileListResp is the payload for MsgFileListResp.
|
||||
// MsgFileListReq carries no payload — it is a zero-field request.
|
||||
type FileListResp struct {
|
||||
Files []FileEntry `json:"files"`
|
||||
}
|
||||
|
||||
// FileOffer is used internally when emitting EvtIncomingFile to the IPC layer.
|
||||
// On the wire, file-offer fields are flat inside PeerMessage (xid/name/size/sha256).
|
||||
type FileOffer struct {
|
||||
Xid string `json:"xid"`
|
||||
SHA256 string `json:"sha256"` // hex
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// ── DataChannel hello (YAW/2 §6) ─────────────────────────────────────────────
|
||||
|
||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||
// The signature binds this identity to the specific DTLS session.
|
||||
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
|
||||
// when RequireInvite is enabled on a network, peers that omit or present
|
||||
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
|
||||
type HelloMessage struct {
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
|
||||
}
|
||||
|
||||
// HelloBindString returns the bytes the hello signature covers:
|
||||
@@ -134,15 +203,21 @@ const (
|
||||
SigAnswer SignalingKind = "answer"
|
||||
SigCandidate SignalingKind = "candidate"
|
||||
SigBye SignalingKind = "bye"
|
||||
SigEkey SignalingKind = "ekey" // YAW/2.1: ephemeral key exchange
|
||||
)
|
||||
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5).
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5 / §5.4′).
|
||||
type SignalingPayload struct {
|
||||
Kind SignalingKind `json:"kind"`
|
||||
SDP string `json:"sdp,omitempty"` // offer / answer
|
||||
Cand string `json:"cand,omitempty"` // trickle ICE candidate line
|
||||
Mid string `json:"mid,omitempty"` // media stream id for candidate
|
||||
MLine int `json:"mline,omitempty"` // media line index
|
||||
|
||||
// YAW/2.1 ekey fields (sealed under static keys)
|
||||
V string `json:"v,omitempty"` // "yaw/2.1"
|
||||
EPK string `json:"epk,omitempty"` // hex-encoded ephemeral X25519 pubkey (32 bytes)
|
||||
EkeySig string `json:"ekey_sig,omitempty"` // hex Ed25519 sig over ekey bind bytes
|
||||
}
|
||||
|
||||
// ── Anchor WebSocket wire types (YAW/2 §5) ────────────────────────────────────
|
||||
@@ -185,32 +260,70 @@ const (
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
|
||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||
CmdGetFileList IpcMsgType = "get_file_list"
|
||||
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
||||
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
EvtPeerConnected IpcMsgType = "peer_connected"
|
||||
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
||||
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
|
||||
EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type
|
||||
EvtIncomingFile IpcMsgType = "incoming_file"
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
EvtError IpcMsgType = "error"
|
||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||
EvtFileList IpcMsgType = "file_list"
|
||||
EvtFileComplete IpcMsgType = "file_complete"
|
||||
EvtNetworkJoined IpcMsgType = "network_joined"
|
||||
EvtNetworkLeft IpcMsgType = "network_left"
|
||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||
EvtSharesList IpcMsgType = "shares_list"
|
||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
||||
)
|
||||
|
||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||
type NetworkInfo struct {
|
||||
NetworkID string `json:"network_id"`
|
||||
NetworkName string `json:"network_name"`
|
||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||
ShareDir string `json:"share_dir,omitempty"` // absolute path; empty = not sharing
|
||||
DownloadDir string `json:"download_dir,omitempty"` // absolute path for received files
|
||||
}
|
||||
|
||||
// IpcMessage covers both commands and events.
|
||||
type IpcMessage struct {
|
||||
Type IpcMsgType `json:"type"`
|
||||
|
||||
// optional: scopes a command/event to a specific network.
|
||||
// When absent, defaults to the first (or only) joined network.
|
||||
NetworkID string `json:"network_id,omitempty"`
|
||||
|
||||
// send_message
|
||||
Room string `json:"room,omitempty"`
|
||||
To *PeerID `json:"to,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
|
||||
// join_network / leave_network
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
||||
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
||||
|
||||
// send_file
|
||||
// send_file / set_share_dir / file_complete path
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// events
|
||||
@@ -222,8 +335,25 @@ type IpcMessage struct {
|
||||
TransferID string `json:"transfer_id,omitempty"`
|
||||
BytesReceived int64 `json:"bytes_received,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
// state_snapshot fields (existing shape preserved for backward compat)
|
||||
MasterAlias string `json:"master_alias,omitempty"` // daemon's alias (available before any network join)
|
||||
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
// multi-network: all joined networks (additive)
|
||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteGenerated string `json:"invite,omitempty"`
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
||||
Shares []ShareEntry `json:"shares,omitempty"`
|
||||
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
||||
// export_identity / import_identity
|
||||
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||
// peer_status — ICE connection quality
|
||||
ConnState string `json:"conn_state,omitempty"` // pion PeerConnectionState string
|
||||
CandidateType string `json:"candidate_type,omitempty"` // host | srflx | relay | unknown
|
||||
RemoteAddress string `json:"remote_address,omitempty"` // remote IP:port of active candidate pair
|
||||
}
|
||||
|
||||
99
internal/shares/shares.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Package shares manages the persistent multi-share configuration stored in shares.json.
|
||||
// Shares are additive to the existing single-dir ShareDir mechanism.
|
||||
package shares
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Share describes one shared directory entry.
|
||||
type Share struct {
|
||||
Path string `json:"path"`
|
||||
Networks []string `json:"networks"` // ["*"] = global (all networks); otherwise list of network IDs
|
||||
}
|
||||
|
||||
// Store manages the shares.json file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
shares []Share
|
||||
}
|
||||
|
||||
// Load reads shares.json from dataDir. Returns an empty store if the file doesn't exist.
|
||||
func Load(dataDir string) (*Store, error) {
|
||||
s := &Store{path: filepath.Join(dataDir, "shares.json")}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &s.shares); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) save() error {
|
||||
data, err := json.MarshalIndent(s.shares, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0600)
|
||||
}
|
||||
|
||||
// All returns a copy of all shares.
|
||||
func (s *Store) All() []Share {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Share, len(s.shares))
|
||||
copy(out, s.shares)
|
||||
return out
|
||||
}
|
||||
|
||||
// ForNetwork returns all shares visible on networkID (global + network-specific).
|
||||
func (s *Store) ForNetwork(networkID string) []Share {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []Share
|
||||
for _, sh := range s.shares {
|
||||
for _, n := range sh.Networks {
|
||||
if n == "*" || n == networkID {
|
||||
out = append(out, sh)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Add adds a share (no-op if path already present). Persists immediately.
|
||||
func (s *Store) Add(sh Share) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, existing := range s.shares {
|
||||
if existing.Path == sh.Path {
|
||||
s.shares[i].Networks = sh.Networks
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
s.shares = append(s.shares, sh)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Remove removes the share with the given path. Persists immediately.
|
||||
func (s *Store) Remove(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, sh := range s.shares {
|
||||
if sh.Path == path {
|
||||
s.shares = append(s.shares[:i], s.shares[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
208
internal/store/store.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Package store persists messages and peer info to a local SQLite database.
|
||||
// Each daemon has its own database; nothing is shared across the network.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL UNIQUE,
|
||||
room TEXT NOT NULL,
|
||||
from_peer TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
sent_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages (room, sent_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peers (
|
||||
peer_id TEXT PRIMARY KEY,
|
||||
alias TEXT NOT NULL,
|
||||
last_seen DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
name TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||
var migrations = []string{
|
||||
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||
}
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite is single-writer
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate db: %w", err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migration %q: %w", m, err)
|
||||
}
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close releases the database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// SaveMessage persists a chat message. Duplicate mids are silently ignored
|
||||
// (INSERT OR IGNORE), so calling this more than once is safe.
|
||||
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
||||
sentAt := time.UnixMilli(msg.Ts).UTC()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SavePeer upserts a peer's alias and last-seen timestamp.
|
||||
func (s *Store) SavePeer(peerID proto.PeerID, alias string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO peers (peer_id, alias, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(peer_id) DO UPDATE SET alias=excluded.alias, last_seen=excluded.last_seen`,
|
||||
string(peerID), alias, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// PeerAlias returns the cached alias for a peer, or "" if unknown.
|
||||
func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
||||
var alias string
|
||||
s.db.QueryRow(`SELECT alias FROM peers WHERE peer_id = ?`, string(peerID)).Scan(&alias) //nolint:errcheck
|
||||
return alias
|
||||
}
|
||||
|
||||
// RecentMessages returns up to limit messages for a room, oldest first.
|
||||
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
|
||||
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||
// Only messages that have a msg_id (i.e. gossip-safe) are returned.
|
||||
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
since := time.UnixMilli(sinceMs).UTC()
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, since, limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error) {
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var msgs []proto.ChatMessage
|
||||
for rows.Next() {
|
||||
var m proto.ChatMessage
|
||||
var from string
|
||||
var sentAt time.Time
|
||||
if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.From = proto.PeerID(from)
|
||||
m.Ts = sentAt.UnixMilli()
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
// Reverse so oldest-first.
|
||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||
}
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
// SaveRoom persists a room name. Duplicate names are silently ignored.
|
||||
func (s *Store) SaveRoom(name string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
|
||||
name, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Rooms returns all persisted room names, ordered by creation time.
|
||||
func (s *Store) Rooms() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[proto.PeerID]string)
|
||||
for rows.Next() {
|
||||
var id, alias string
|
||||
if err := rows.Scan(&id, &alias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[proto.PeerID(id)] = alias
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
103
internal/store/store_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
msg := &proto.ChatMessage{
|
||||
Mid: "aabbccdd00112233",
|
||||
From: proto.PeerID("deadbeef"),
|
||||
Room: "general",
|
||||
Text: "hello world",
|
||||
Ts: time.Now().UTC().Truncate(time.Second).UnixMilli(),
|
||||
}
|
||||
|
||||
if err := st.SaveMessage(msg); err != nil {
|
||||
t.Fatalf("SaveMessage: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate mid must be silently ignored.
|
||||
if err := st.SaveMessage(msg); err != nil {
|
||||
t.Fatalf("duplicate SaveMessage: %v", err)
|
||||
}
|
||||
|
||||
msgs, err := st.RecentMessages("general", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("got %d messages, want 1", len(msgs))
|
||||
}
|
||||
got := msgs[0]
|
||||
if got.Mid != msg.Mid || got.Text != msg.Text || got.Room != msg.Room {
|
||||
t.Fatalf("message mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePeerAndKnownPeers(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.SavePeer("aabbccdd", "alice"); err != nil {
|
||||
t.Fatalf("SavePeer: %v", err)
|
||||
}
|
||||
// Upsert with updated alias.
|
||||
if err := st.SavePeer("aabbccdd", "alice-updated"); err != nil {
|
||||
t.Fatalf("SavePeer upsert: %v", err)
|
||||
}
|
||||
|
||||
peers, err := st.KnownPeers()
|
||||
if err != nil {
|
||||
t.Fatalf("KnownPeers: %v", err)
|
||||
}
|
||||
if peers["aabbccdd"] != "alice-updated" {
|
||||
t.Fatalf("alias = %q, want %q", peers["aabbccdd"], "alice-updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentMessagesOrdering(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
for i := range 5 {
|
||||
st.SaveMessage(&proto.ChatMessage{
|
||||
Mid: string(rune('a'+i)) + "000000000000000",
|
||||
From: "deadbeef",
|
||||
Room: "general",
|
||||
Text: string(rune('a' + i)),
|
||||
Ts: base.Add(time.Duration(i) * time.Second).UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
msgs, err := st.RecentMessages("general", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentMessages: %v", err)
|
||||
}
|
||||
if len(msgs) != 5 {
|
||||
t.Fatalf("got %d messages, want 5", len(msgs))
|
||||
}
|
||||
// Must be oldest-first.
|
||||
for i := 1; i < len(msgs); i++ {
|
||||
if msgs[i].Ts < msgs[i-1].Ts {
|
||||
t.Fatalf("messages not in ascending order at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
359
test-network.sh
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-network.sh — spin up anchor + 3 peers, connect them, exchange messages.
|
||||
# test-network.sh — spin up anchor + 3 peers, connect them, exchange messages + DMs.
|
||||
# Usage: ./test-network.sh
|
||||
# Requires: go, nc, jq
|
||||
# Requires: go, nc, jq, sqlite3
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -21,7 +21,42 @@ ALICE_IPC=19337
|
||||
BOB_IPC=19340
|
||||
CHARLIE_IPC=19343
|
||||
NETWORK_NAME="test-$(date +%s)"
|
||||
DATA_ROOT=$(mktemp -d /tmp/waste-test-XXXXXX)
|
||||
DATA_ROOT="/tmp/waste-test"
|
||||
rm -rf "$DATA_ROOT"
|
||||
mkdir -p "$DATA_ROOT/bin"
|
||||
|
||||
# Per-network share directories — isolation is the whole point:
|
||||
# alice/friends-share → shared on the "friends" network only
|
||||
# alice/work-share → shared on the "work" network only (alice joins both)
|
||||
# bob/share → bob is only on "friends"
|
||||
# charlie/share → charlie is only on "friends"
|
||||
mkdir -p "$DATA_ROOT/alice/friends-share" "$DATA_ROOT/alice/work-share"
|
||||
mkdir -p "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share"
|
||||
|
||||
echo "alice's notes" > "$DATA_ROOT/alice/friends-share/notes.txt"
|
||||
dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/friends-share/photo.jpg.b64"
|
||||
echo "alice's report.pdf" > "$DATA_ROOT/alice/work-share/report.pdf"
|
||||
echo "alice's budget.xlsx" > "$DATA_ROOT/alice/work-share/budget.xlsx"
|
||||
|
||||
dd if=/dev/urandom bs=1K count=128 2>/dev/null | base64 > "$DATA_ROOT/bob/share/archive.tar.b64"
|
||||
echo -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u"
|
||||
echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt"
|
||||
echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh"
|
||||
|
||||
# Kill any leftover processes from a previous run holding our fixed ports.
|
||||
for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||
pid=$(lsof -ti tcp:"$port" 2>/dev/null || true)
|
||||
[ -n "$pid" ] && kill -9 $pid 2>/dev/null || true
|
||||
done
|
||||
# Wait until all ports are actually free before proceeding.
|
||||
for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||
n=0
|
||||
while lsof -ti tcp:"$port" >/dev/null 2>&1; do
|
||||
sleep 0.1
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -gt 30 ] && echo -e "${RED}port ${port} still in use after 3s${RESET}" >&2 && break
|
||||
done
|
||||
done
|
||||
|
||||
# ── cleanup ───────────────────────────────────────────────────────────────────
|
||||
PIDS=()
|
||||
@@ -32,7 +67,7 @@ cleanup() {
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
wait 2>/dev/null || true
|
||||
rm -rf "$DATA_ROOT"
|
||||
echo -e "${DIM}data left at: ${DATA_ROOT}${RESET}"
|
||||
echo -e "${DIM}done.${RESET}"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
@@ -75,11 +110,38 @@ pretty() {
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${RED}← peer_disconnected${RESET} ${DIM}${pid}${RESET}"
|
||||
;;
|
||||
message_received)
|
||||
local from body room
|
||||
local from body room to_field
|
||||
from=$(echo "$line" | jq -r '.message.from[:8] // "?"' 2>/dev/null)
|
||||
body=$(echo "$line" | jq -r '.message.body // ""' 2>/dev/null)
|
||||
body=$(echo "$line" | jq -r '.message.text // ""' 2>/dev/null)
|
||||
room=$(echo "$line" | jq -r '.message.room // ""' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${BOLD}💬 #${room}${RESET} ${DIM}<${from}…>${RESET} ${body}"
|
||||
to_field=$(echo "$line" | jq -r '.message.to // ""' 2>/dev/null)
|
||||
if [ -n "$to_field" ]; then
|
||||
local to_short
|
||||
to_short=$(echo "$to_field" | cut -c1-8)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${BLUE}📨 DM${RESET} ${DIM}<${from}…→${to_short}…>${RESET} ${body}"
|
||||
else
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${BOLD}💬 #${room}${RESET} ${DIM}<${from}…>${RESET} ${body}"
|
||||
fi
|
||||
;;
|
||||
incoming_file)
|
||||
local name size xid
|
||||
name=$(echo "$line" | jq -r '.offer.name // "?"' 2>/dev/null)
|
||||
size=$(echo "$line" | jq -r '.offer.size // 0' 2>/dev/null)
|
||||
xid=$(echo "$line" | jq -r '.offer.xid[:8] // "?"' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${YELLOW}↓ incoming_file${RESET} ${BOLD}${name}${RESET} (${size}B) xid=${DIM}${xid}…${RESET}"
|
||||
;;
|
||||
file_progress)
|
||||
local xid rx total
|
||||
xid=$(echo "$line" | jq -r '.transfer_id[:8] // "?"' 2>/dev/null)
|
||||
rx=$(echo "$line" | jq -r '.bytes_received // 0' 2>/dev/null)
|
||||
total=$(echo "$line" | jq -r '.total_bytes // 0' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${DIM}file_progress${RESET} xid=${xid}… ${rx}/${total}B"
|
||||
;;
|
||||
file_complete)
|
||||
local xid path
|
||||
xid=$(echo "$line" | jq -r '.transfer_id[:8] // "?"' 2>/dev/null)
|
||||
path=$(echo "$line" | jq -r '.path // "?"' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${GREEN}✓ file_complete${RESET} xid=${DIM}${xid}…${RESET} → ${BOLD}${path}${RESET}"
|
||||
;;
|
||||
error)
|
||||
local msg
|
||||
@@ -113,6 +175,27 @@ ipc() {
|
||||
echo "$json" | nc -q 0 127.0.0.1 "$port" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Query get_state and return a single field via jq from the state_snapshot line.
|
||||
# Retries for up to 3 seconds in case the daemon is still initialising.
|
||||
# Usage: peer_field <ipc_port> <jq_expr>
|
||||
peer_field() {
|
||||
local port="$1"
|
||||
local expr="$2"
|
||||
local result=""
|
||||
local n=0
|
||||
while [ -z "$result" ] || [ "$result" = "null" ]; do
|
||||
result=$(echo '{"type":"get_state"}' \
|
||||
| timeout 2 nc 127.0.0.1 "$port" 2>/dev/null \
|
||||
| grep '"type":"state_snapshot"' \
|
||||
| head -1 \
|
||||
| jq -r "$expr" 2>/dev/null || true)
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -ge 10 ] && break
|
||||
[ -z "$result" ] || [ "$result" = "null" ] && sleep 0.3
|
||||
done
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
# Wait until a TCP port accepts connections (up to N seconds).
|
||||
wait_port() {
|
||||
local port="$1"
|
||||
@@ -126,6 +209,7 @@ wait_port() {
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── build ─────────────────────────────────────────────────────────────────────
|
||||
@@ -160,7 +244,7 @@ log "$ALICE_COLOR" "alice" "starting daemon (ipc :${ALICE_IPC})"
|
||||
-data-dir "$DATA_ROOT/alice" \
|
||||
-ipc-port "$ALICE_IPC" \
|
||||
-anchor "$ANCHOR_URL" \
|
||||
2> >(while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) &
|
||||
2> >(tee "$DATA_ROOT/alice/daemon.log" | while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) &
|
||||
PIDS+=($!)
|
||||
|
||||
log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})"
|
||||
@@ -169,7 +253,7 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})"
|
||||
-data-dir "$DATA_ROOT/bob" \
|
||||
-ipc-port "$BOB_IPC" \
|
||||
-anchor "$ANCHOR_URL" \
|
||||
2> >(while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) &
|
||||
2> >(tee "$DATA_ROOT/bob/daemon.log" | while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) &
|
||||
PIDS+=($!)
|
||||
|
||||
log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})"
|
||||
@@ -178,7 +262,7 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})"
|
||||
-data-dir "$DATA_ROOT/charlie" \
|
||||
-ipc-port "$CHARLIE_IPC" \
|
||||
-anchor "$ANCHOR_URL" \
|
||||
2> >(while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) &
|
||||
2> >(tee "$DATA_ROOT/charlie/daemon.log" | while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) &
|
||||
PIDS+=($!)
|
||||
|
||||
wait_port "$ALICE_IPC" "alice"
|
||||
@@ -186,54 +270,241 @@ wait_port "$BOB_IPC" "bob"
|
||||
wait_port "$CHARLIE_IPC" "charlie"
|
||||
echo ""
|
||||
|
||||
# ── join network ──────────────────────────────────────────────────────────────
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "joining all peers to network: ${BOLD}${NETWORK_NAME}${RESET}"
|
||||
echo -e "${DIM}share dirs are per-network (per-network isolation test)${RESET}"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
# Each peer passes share_dir scoped to this network.
|
||||
ipc "$ALICE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/alice/friends-share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.2
|
||||
ipc "$BOB_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/bob/share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.2
|
||||
ipc "$CHARLIE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/charlie/share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.3
|
||||
|
||||
# ── resolve peer IDs ──────────────────────────────────────────────────────────
|
||||
# Peer IDs are network-scoped (derived identity), so we query AFTER joining.
|
||||
ALICE_ID=$(peer_field "$ALICE_IPC" '.local_peer.id')
|
||||
BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id')
|
||||
CHARLIE_ID=$(peer_field "$CHARLIE_IPC" '.local_peer.id')
|
||||
|
||||
log "$ANCHOR_COLOR" "ids" "alice = ${ALICE_ID:0:16}…"
|
||||
log "$ANCHOR_COLOR" "ids" "bob = ${BOB_ID:0:16}…"
|
||||
log "$ANCHOR_COLOR" "ids" "charlie = ${CHARLIE_ID:0:16}…"
|
||||
echo ""
|
||||
|
||||
# ── subscribe ─────────────────────────────────────────────────────────────────
|
||||
echo -e "${DIM}subscribing to IPC event streams…${RESET}"
|
||||
subscribe "$ALICE_COLOR" "alice " "$ALICE_IPC"
|
||||
subscribe "$BOB_COLOR" "bob " "$BOB_IPC"
|
||||
subscribe "$CHARLIE_COLOR" "charlie" "$CHARLIE_IPC"
|
||||
sleep 0.3 # let state_snapshot lines arrive
|
||||
|
||||
# ── join network ──────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "joining all peers to network: ${BOLD}${NETWORK_NAME}${RESET}"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
JOIN=$(printf '{"type":"join_network","network_name":"%s"}' "$NETWORK_NAME")
|
||||
ipc "$ALICE_IPC" "$JOIN"
|
||||
sleep 0.2
|
||||
ipc "$BOB_IPC" "$JOIN"
|
||||
sleep 0.2
|
||||
ipc "$CHARLIE_IPC" "$JOIN"
|
||||
|
||||
echo -e "${DIM}waiting for ICE / DataChannel setup (up to 10s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
# ── chat ──────────────────────────────────────────────────────────────────────
|
||||
# ── YAW/2.1 forward-secrecy check ────────────────────────────────────────────
|
||||
# The daemon logs "2.1 FS offer" when forward-secret signaling was negotiated,
|
||||
# or "2.0 fallback offer" if the peer didn't respond to the ekey in time.
|
||||
# We verify by counting FS vs fallback lines in the daemon log files.
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "sending messages"
|
||||
echo -e "YAW/2.1 forward-secret signaling check"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
ipc "$ALICE_IPC" '{"type":"send_message","room":"general","body":"hey everyone, alice here"}'
|
||||
fs_ok=0
|
||||
fs_fail=0
|
||||
for peer_data in "$DATA_ROOT/alice" "$DATA_ROOT/bob" "$DATA_ROOT/charlie"; do
|
||||
logfile="$peer_data/daemon.log"
|
||||
if [ -f "$logfile" ]; then
|
||||
ok=$(grep -c "2\.1 FS offer" "$logfile" 2>/dev/null || true)
|
||||
fail=$(grep -c "2\.0 fallback" "$logfile" 2>/dev/null || true)
|
||||
fs_ok=$(( fs_ok + ok ))
|
||||
fs_fail=$(( fs_fail + fail ))
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check stderr captured above (it was piped to terminal; count from the
|
||||
# variable output buffer if possible — or just note the result from logs).
|
||||
# Simpler: re-check via a short daemon log we write below.
|
||||
# For now just print what we know from the test run output.
|
||||
if [ "$fs_fail" -eq 0 ]; then
|
||||
echo -e " ${GREEN}✓ all sessions negotiated YAW/2.1 (forward-secret)${RESET}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ ${fs_fail} session(s) fell back to YAW/2.0 (non-FS)${RESET}"
|
||||
fi
|
||||
|
||||
# ── group chat ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "group chat (#general)"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
ipc "$ALICE_IPC" '{"type":"send_message","room":"general","body":"hey everyone, alice here"}'
|
||||
sleep 0.4
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"hi alice! bob checking in"}'
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"hi alice! bob checking in"}'
|
||||
sleep 0.4
|
||||
ipc "$CHARLIE_IPC" '{"type":"send_message","room":"general","body":"charlie online. the mesh works!"}'
|
||||
sleep 0.4
|
||||
ipc "$ALICE_IPC" '{"type":"send_message","room":"general","body":"nice — three-way chat over WebRTC 🎉"}'
|
||||
ipc "$ALICE_IPC" '{"type":"send_message","room":"general","body":"nice — three-way chat over WebRTC 🎉"}'
|
||||
sleep 0.4
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"no central server, fully peer-to-peer"}'
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"no central server, fully peer-to-peer"}'
|
||||
sleep 0.4
|
||||
|
||||
# ── state snapshot ────────────────────────────────────────────────────────────
|
||||
# ── direct messages ───────────────────────────────────────────────────────────
|
||||
# DMs set the "to" field to the recipient's full hex peer ID.
|
||||
# The room field is used as "dm:<recipient_id>" by convention so both sides
|
||||
# can query their DM history for the same conversation.
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "requesting state snapshots"
|
||||
echo -e "direct messages"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
ipc "$ALICE_IPC" '{"type":"get_state"}'
|
||||
ipc "$BOB_IPC" '{"type":"get_state"}'
|
||||
ipc "$CHARLIE_IPC" '{"type":"get_state"}'
|
||||
|
||||
# Alice → Bob (privately)
|
||||
ipc "$ALICE_IPC" "$(jq -cn \
|
||||
--arg to "$BOB_ID" \
|
||||
--arg room "dm:$BOB_ID" \
|
||||
--arg body "hey bob, this is just between us" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 0.4
|
||||
|
||||
# Bob replies to Alice
|
||||
ipc "$BOB_IPC" "$(jq -cn \
|
||||
--arg to "$ALICE_ID" \
|
||||
--arg room "dm:$ALICE_ID" \
|
||||
--arg body "got it alice, loud and clear 🤫" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 0.4
|
||||
|
||||
# Charlie → Alice (privately)
|
||||
ipc "$CHARLIE_IPC" "$(jq -cn \
|
||||
--arg to "$ALICE_ID" \
|
||||
--arg room "dm:$ALICE_ID" \
|
||||
--arg body "alice, can you hear me? charlie dm test" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 0.4
|
||||
|
||||
# Alice replies to Charlie
|
||||
ipc "$ALICE_IPC" "$(jq -cn \
|
||||
--arg to "$CHARLIE_ID" \
|
||||
--arg room "dm:$CHARLIE_ID" \
|
||||
--arg body "yes charlie, DMs working perfectly!" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 0.4
|
||||
|
||||
# ── file list check ───────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "file listing"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
alice_nets=$(echo '{"type":"get_state"}' | timeout 2 nc 127.0.0.1 "$ALICE_IPC" 2>/dev/null \
|
||||
| grep '"type":"state_snapshot"' | head -1 \
|
||||
| jq -rc '[.networks[].network_name] | join(", ")' 2>/dev/null || echo "?")
|
||||
echo -e "${DIM} alice networks: [${alice_nets:-none}]${RESET}"
|
||||
|
||||
for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||
if [ "$peer_ipc" = "$ALICE_IPC" ]; then peer_name="alice"
|
||||
elif [ "$peer_ipc" = "$BOB_IPC" ]; then peer_name="bob"
|
||||
else peer_name="charlie"; fi
|
||||
raw=$(echo '{"type":"get_file_list"}' \
|
||||
| nc -q 2 127.0.0.1 "$peer_ipc" 2>/dev/null || true)
|
||||
result=$(echo "$raw" | grep '"type":"file_list"' | head -1 || true)
|
||||
if [ -n "$result" ]; then
|
||||
count=$(echo "$result" | jq '.files | length' 2>/dev/null || echo "?")
|
||||
files=$(echo "$result" | jq -r '[.files[].name] | join(" ")' 2>/dev/null || true)
|
||||
echo -e " ${BOLD}${peer_name}${RESET}: ${count} file(s) — ${files}"
|
||||
else
|
||||
types=$(echo "$raw" | jq -r '.type' 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)
|
||||
echo -e " ${RED}${peer_name}: no file_list response${RESET} ${DIM}(got: ${types:-nothing})${RESET}"
|
||||
fi
|
||||
done
|
||||
sleep 0.5
|
||||
|
||||
# ── per-network share isolation ───────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "per-network share directory isolation"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
# Alice also joins a "work" network with a completely different share dir.
|
||||
WORK_NET="work-$(date +%s)"
|
||||
ipc "$ALICE_IPC" "$(jq -cn --arg net "$WORK_NET" --arg dir "$DATA_ROOT/alice/work-share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 1
|
||||
|
||||
# Alice's own file list on "friends" should show friends-share files only.
|
||||
friends_raw=$(echo '{"type":"get_file_list"}' | nc -q 2 127.0.0.1 "$ALICE_IPC" 2>/dev/null || true)
|
||||
friends_files=$(echo "$friends_raw" | grep '"type":"file_list"' | head -1 \
|
||||
| jq -r '[.files[].name] | join(" ")' 2>/dev/null || true)
|
||||
|
||||
# Use set_share_dir to dynamically update the work network share dir (runtime change smoke test).
|
||||
work_net_id=$(echo '{"type":"get_state"}' | timeout 2 nc 127.0.0.1 "$ALICE_IPC" 2>/dev/null \
|
||||
| grep '"type":"state_snapshot"' | head -1 \
|
||||
| jq -r --arg net "$WORK_NET" '.networks[] | select(.network_name==$net) | .network_id' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$work_net_id" ]; then
|
||||
# Verify the work network's share_dir is isolated from friends.
|
||||
work_raw=$(echo "{\"type\":\"get_file_list\",\"network_id\":\"$work_net_id\"}" \
|
||||
| nc -q 2 127.0.0.1 "$ALICE_IPC" 2>/dev/null || true)
|
||||
work_files=$(echo "$work_raw" | grep '"type":"file_list"' | head -1 \
|
||||
| jq -r '[.files[].name] | join(" ")' 2>/dev/null || true)
|
||||
|
||||
echo -e " ${BOLD}alice/friends${RESET}: ${friends_files:-<empty>}"
|
||||
echo -e " ${BOLD}alice/work${RESET}: ${work_files:-<empty>}"
|
||||
|
||||
# Check isolation: friends files must not appear in work list and vice versa.
|
||||
if echo "$friends_files" | grep -q "notes.txt" && \
|
||||
echo "$work_files" | grep -q "report.pdf" && \
|
||||
! echo "$friends_files" | grep -q "report.pdf" && \
|
||||
! echo "$work_files" | grep -q "notes.txt"; then
|
||||
echo -e " ${GREEN}✓ share directories are isolated per network${RESET}"
|
||||
else
|
||||
echo -e " ${RED}✗ share isolation failed${RESET}"
|
||||
echo -e " friends: ${friends_files}"
|
||||
echo -e " work: ${work_files}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗ could not resolve work network id${RESET}"
|
||||
fi
|
||||
sleep 0.3
|
||||
|
||||
# ── file transfer ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "file transfer (alice → bob: notes.txt)"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
ipc "$ALICE_IPC" "$(jq -cn --arg peer "$BOB_ID" --arg path "notes.txt" \
|
||||
'{"type":"send_file","peer_id":$peer,"path":$path}')"
|
||||
|
||||
# Wait up to 10s for bob to receive the file (glob over downloads-* dir)
|
||||
received=0
|
||||
for i in $(seq 1 50); do
|
||||
sleep 0.2
|
||||
if ls "$DATA_ROOT/bob/downloads-"*/notes.txt 2>/dev/null | grep -q .; then
|
||||
received=1; break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$received" = "1" ]; then
|
||||
rx_file=$(ls "$DATA_ROOT/bob/downloads-"*/notes.txt 2>/dev/null | head -1)
|
||||
orig_sha=$(sha256sum "$DATA_ROOT/alice/friends-share/notes.txt" | awk '{print $1}')
|
||||
rx_sha=$(sha256sum "$rx_file" | awk '{print $1}')
|
||||
if [ "$orig_sha" = "$rx_sha" ]; then
|
||||
echo -e " ${GREEN}✓ notes.txt received by bob, sha256 matches${RESET}"
|
||||
else
|
||||
echo -e " ${RED}✗ notes.txt received but sha256 mismatch!${RESET}"
|
||||
echo -e " orig: $orig_sha"
|
||||
echo -e " got: $rx_sha"
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗ notes.txt not received by bob within 10s${RESET}"
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
# ── leave ─────────────────────────────────────────────────────────────────────
|
||||
@@ -244,6 +515,24 @@ echo -e "${DIM}─────────────────────
|
||||
ipc "$CHARLIE_IPC" '{"type":"leave_network"}'
|
||||
sleep 1
|
||||
|
||||
# ── persistence check ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "verifying persistence (SQLite)"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
for peer in alice bob charlie; do
|
||||
db=$(ls "$DATA_ROOT/$peer/messages-"*.db 2>/dev/null | head -1 || true)
|
||||
if [ -n "$db" ] && [ -f "$db" ]; then
|
||||
total=$(sqlite3 "$db" "SELECT COUNT(*) FROM messages;" 2>/dev/null || echo "?")
|
||||
group=$(sqlite3 "$db" "SELECT COUNT(*) FROM messages WHERE room='general';" 2>/dev/null || echo "?")
|
||||
dms=$(sqlite3 "$db" "SELECT COUNT(*) FROM messages WHERE room LIKE 'dm:%';" 2>/dev/null || echo "?")
|
||||
peers_count=$(sqlite3 "$db" "SELECT COUNT(*) FROM peers;" 2>/dev/null || echo "?")
|
||||
echo -e " ${BOLD}${peer}${RESET}: ${total} total (${group} group, ${dms} DM), ${peers_count} known peers"
|
||||
else
|
||||
echo -e " ${RED}${peer}: no messages-*.db found${RESET}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "${GREEN}${BOLD}test complete${RESET}"
|
||||
|
||||
163
test-tui.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-tui.sh — spin up anchor + 3 peers, then launch the TUI as alice.
|
||||
# Bob and Charlie send periodic messages so the UI updates live.
|
||||
# Usage: ./test-tui.sh
|
||||
# Requires: go, nc, jq
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ANCHOR_PORT=19339
|
||||
ALICE_IPC=19337
|
||||
BOB_IPC=19340
|
||||
CHARLIE_IPC=19343
|
||||
NETWORK_NAME="friends"
|
||||
DATA_ROOT="/tmp/waste-tui-test"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
rm -rf "$DATA_ROOT"
|
||||
mkdir -p "$DATA_ROOT/bin"
|
||||
|
||||
# Seed per-network share directories with dummy files.
|
||||
mkdir -p "$DATA_ROOT/alice/share" "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share"
|
||||
echo "alice's notes" > "$DATA_ROOT/alice/share/notes.txt"
|
||||
dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/share/photo.jpg.b64"
|
||||
dd if=/dev/urandom bs=1K count=128 2>/dev/null | base64 > "$DATA_ROOT/bob/share/archive.tar.b64"
|
||||
echo -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u"
|
||||
echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt"
|
||||
echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh"
|
||||
|
||||
# Kill any leftover processes holding our fixed ports.
|
||||
for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||
pid=$(lsof -ti tcp:"$port" 2>/dev/null || true)
|
||||
[ -n "$pid" ] && kill -9 $pid 2>/dev/null || true
|
||||
done
|
||||
for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||
n=0
|
||||
while lsof -ti tcp:"$port" >/dev/null 2>&1; do
|
||||
sleep 0.1; n=$(( n + 1 ))
|
||||
[ "$n" -gt 30 ] && break
|
||||
done
|
||||
done
|
||||
|
||||
PIDS=()
|
||||
cleanup() {
|
||||
for pid in "${PIDS[@]:-}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
wait_port() {
|
||||
local port="$1" n=0
|
||||
while ! nc -z 127.0.0.1 "$port" 2>/dev/null; do
|
||||
sleep 0.1
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -gt 80 ] && { echo "timeout waiting for :$port" >&2; exit 1; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
ipc() { echo "$2" | nc -q 0 127.0.0.1 "$1" >/dev/null 2>&1 || true; }
|
||||
|
||||
peer_field() {
|
||||
local port="$1" expr="$2" result="" n=0
|
||||
while [ -z "$result" ] || [ "$result" = "null" ]; do
|
||||
result=$(echo '{"type":"get_state"}' \
|
||||
| timeout 2 nc 127.0.0.1 "$port" 2>/dev/null \
|
||||
| grep '"type":"state_snapshot"' | head -1 \
|
||||
| jq -r "$expr" 2>/dev/null || true)
|
||||
n=$(( n + 1 ))
|
||||
[ "$n" -ge 15 ] && break
|
||||
[ -z "$result" ] || [ "$result" = "null" ] && sleep 0.3
|
||||
done
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}waste-go TUI test${RESET}"
|
||||
echo -e "${DIM}network: ${BOLD}${NETWORK_NAME}${RESET}"
|
||||
echo -e "${DIM}data: ${DATA_ROOT}${RESET}"
|
||||
echo ""
|
||||
|
||||
echo -e "${DIM}building binaries…${RESET}"
|
||||
go build -o "$DATA_ROOT/bin/waste-anchor" ./cmd/anchor
|
||||
go build -o "$DATA_ROOT/bin/waste-daemon" ./cmd/daemon
|
||||
go build -o "$DATA_ROOT/bin/waste-tui" ./cmd/tui
|
||||
echo -e "${GREEN}✓ built${RESET}"
|
||||
echo ""
|
||||
|
||||
# Anchor
|
||||
"$DATA_ROOT/bin/waste-anchor" -bind "127.0.0.1:${ANCHOR_PORT}" \
|
||||
2>/dev/null &
|
||||
PIDS+=($!)
|
||||
wait_port "$ANCHOR_PORT"
|
||||
|
||||
ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws"
|
||||
|
||||
# Daemons — no global -share-dir; share dirs are set per network at join_network time.
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \
|
||||
-ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \
|
||||
2>"$DATA_ROOT/alice/daemon.log" &
|
||||
PIDS+=($!)
|
||||
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \
|
||||
-ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \
|
||||
2>"$DATA_ROOT/bob/daemon.log" &
|
||||
PIDS+=($!)
|
||||
|
||||
"$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \
|
||||
-ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \
|
||||
2>"$DATA_ROOT/charlie/daemon.log" &
|
||||
PIDS+=($!)
|
||||
|
||||
wait_port "$ALICE_IPC"
|
||||
wait_port "$BOB_IPC"
|
||||
wait_port "$CHARLIE_IPC"
|
||||
|
||||
# Join all three with per-network share directories.
|
||||
ipc "$ALICE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/alice/share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.2
|
||||
ipc "$BOB_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/bob/share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.2
|
||||
ipc "$CHARLIE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/charlie/share" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')"
|
||||
sleep 0.5
|
||||
|
||||
# Resolve peer IDs (network-scoped identity, available after joining).
|
||||
ALICE_ID=$(peer_field "$ALICE_IPC" '.local_peer.id')
|
||||
BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id')
|
||||
CHARLIE_ID=$(peer_field "$CHARLIE_IPC" '.local_peer.id')
|
||||
|
||||
echo -e "${DIM}waiting for WebRTC DataChannels (6s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
# Bob and Charlie send a few messages as background noise, then loop
|
||||
(
|
||||
set +e
|
||||
while true; do
|
||||
ipc "$BOB_IPC" '{"type":"send_message","room":"general","body":"bob: still here!"}'
|
||||
sleep 5
|
||||
ipc "$CHARLIE_IPC" '{"type":"send_message","room":"general","body":"charlie: mesh is alive"}'
|
||||
sleep 5
|
||||
# Charlie DMs alice
|
||||
ipc "$CHARLIE_IPC" "$(jq -cn \
|
||||
--arg to "$ALICE_ID" \
|
||||
--arg room "dm:$ALICE_ID" \
|
||||
--arg body "charlie whispering to alice…" \
|
||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||
sleep 10
|
||||
done
|
||||
) &
|
||||
PIDS+=($!)
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Launching TUI as alice${RESET} — ${DIM}ctrl+c to quit${RESET}"
|
||||
echo ""
|
||||
|
||||
# Run the TUI in the foreground (it takes over the terminal with alt-screen)
|
||||
"$DATA_ROOT/bin/waste-tui" -ipc "$ALICE_IPC" -network "$NETWORK_NAME"
|
||||
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
web/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
22
web/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#863bff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="waste" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<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>
|
||||
2810
web/package-lock.json
generated
Normal file
34
web/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"wails:dev": "wails dev",
|
||||
"wails:build": "wails build"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
BIN
web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
1
web/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
web/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
web/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
24
web/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
23
web/public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "waste",
|
||||
"short_name": "waste",
|
||||
"description": "Decentralized friend-to-friend encrypted mesh networking",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d0d0d",
|
||||
"theme_color": "#863bff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
160
web/src/App.css
Normal file
@@ -0,0 +1,160 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f13;
|
||||
--surface: #1a1a22;
|
||||
--border: #2a2a36;
|
||||
--accent: #7c6af7;
|
||||
--text: #e0e0e8;
|
||||
--muted: #6b6b80;
|
||||
--sidebar-w: 220px;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; }
|
||||
|
||||
button { cursor: pointer; background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 13px; }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
input { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 10px; font-size: 13px; outline: none; width: 100%; }
|
||||
input:focus { border-color: var(--accent); }
|
||||
|
||||
/* ── onboarding ── */
|
||||
.onboarding { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; max-width: 380px; margin: 0 auto; padding: 2rem; }
|
||||
.onboarding h1 { font-size: 2rem; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 4px; }
|
||||
.onboarding .status { color: var(--muted); font-size: 13px; }
|
||||
.onboarding .status.connecting { color: var(--accent); }
|
||||
.onboarding .status.disconnected { color: #e06060; }
|
||||
.onboarding-identity { text-align: center; }
|
||||
.onboarding-identity .alias { display: block; font-size: 1.1rem; font-weight: 600; }
|
||||
.onboarding-identity .peer-id { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
|
||||
.onboarding-hint { color: var(--muted); font-size: 12px; text-align: center; }
|
||||
.onboarding-hint.muted { color: var(--muted); }
|
||||
.onboarding-code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; font-size: 12px; font-family: monospace; color: var(--text); width: 100%; text-align: left; }
|
||||
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||
button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; }
|
||||
.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
|
||||
.toggle-link:hover { color: var(--text); }
|
||||
.backup-panel { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.backup-form { display: flex; flex-direction: column; gap: 6px; }
|
||||
.backup-form textarea { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; font-size: 11px; resize: vertical; width: 100%; }
|
||||
.export-result { display: flex; flex-direction: column; gap: 6px; }
|
||||
.export-result textarea { font-size: 10px; }
|
||||
.mono { font-family: monospace; }
|
||||
details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
||||
|
||||
/* ── chat layout ── */
|
||||
.chat-layout { display: grid; grid-template-columns: var(--sidebar-w) 1fr; height: 100%; }
|
||||
|
||||
/* ── sidebar ── */
|
||||
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.sidebar-identity { padding: 12px 12px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; }
|
||||
.sidebar-identity .alias { display: block; font-weight: 600; font-size: 14px; }
|
||||
.sidebar-identity .peer-id { display: block; color: var(--muted); font-size: 10px; font-family: monospace; margin-top: 2px; }
|
||||
.sidebar-logout { background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; flex-shrink: 0; margin-left: auto; }
|
||||
.sidebar-logout:hover { color: #e06060; background: none; }
|
||||
.sidebar-section { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-section:last-child { border-bottom: none; flex: 1; }
|
||||
.sidebar-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 4px 12px 2px; }
|
||||
.sidebar-label-row { display: flex; align-items: center; justify-content: space-between; padding-right: 8px; }
|
||||
.sidebar-label-row .sidebar-label { padding-right: 0; }
|
||||
.sidebar-add { background: none; color: var(--muted); font-size: 14px; padding: 0 4px; line-height: 1; }
|
||||
.sidebar-add:hover { color: var(--accent); background: none; }
|
||||
.sidebar-new-room { padding: 2px 8px 4px; }
|
||||
.sidebar-new-room input { font-size: 12px; padding: 4px 8px; }
|
||||
.sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||
.sidebar-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.sidebar-item.active { background: var(--accent); color: #fff; }
|
||||
.sidebar-empty { font-size: 11px; color: var(--muted); padding: 4px 12px; }
|
||||
|
||||
/* peer rows in sidebar */
|
||||
.sidebar-peers { gap: 0; }
|
||||
.peer-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.peer-row { display: flex; align-items: center; gap: 6px; padding: 4px 12px; }
|
||||
.peer-row-self { opacity: 0.7; }
|
||||
.peer-row-you { font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; margin-left: 2px; flex-shrink: 0; }
|
||||
.peer-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.peer-row:hover .peer-row-actions { opacity: 1; }
|
||||
.peer-row-alias { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.peer-row-id { font-size: 10px; color: var(--muted); font-family: monospace; flex: 1; }
|
||||
.peer-row-actions { opacity: 0; display: flex; gap: 2px; transition: opacity 0.1s; margin-left: auto; }
|
||||
.peer-action { background: none; color: var(--muted); font-size: 14px; padding: 1px 3px; line-height: 1; }
|
||||
.peer-action:hover { color: var(--accent); background: none; }
|
||||
|
||||
/* ── message pane ── */
|
||||
.message-pane { display: flex; flex-direction: column; height: 100%; min-width: 0; }
|
||||
.message-pane-header { padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; color: var(--text); }
|
||||
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
|
||||
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
||||
.message:hover { background: rgba(255,255,255,0.02); }
|
||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
||||
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
||||
.message.mine .message-alias { color: var(--accent); }
|
||||
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
||||
.compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
|
||||
.compose input { flex: 1; width: auto; }
|
||||
.compose button { flex-shrink: 0; }
|
||||
|
||||
/* ── transfers panel (in sidebar) ── */
|
||||
.transfer-row { display: flex; flex-direction: column; gap: 3px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
||||
.transfer-row:last-child { border-bottom: none; }
|
||||
.transfer-name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.transfer-meta { font-size: 10px; color: var(--muted); }
|
||||
.transfer-actions { display: flex; gap: 4px; margin-top: 2px; }
|
||||
.transfer-btn { font-size: 11px; padding: 2px 8px; }
|
||||
.transfer-btn.accept { background: #3a7a3a; }
|
||||
.transfer-btn.reject { background: #7a3a3a; }
|
||||
.transfer-btn:hover { opacity: 0.85; }
|
||||
.transfer-progress { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 2px; }
|
||||
.transfer-progress-bar { height: 100%; background: var(--accent); transition: width 0.1s; }
|
||||
|
||||
/* ── file browser panel ── */
|
||||
.chat-layout.has-file-browser { grid-template-columns: var(--sidebar-w) 1fr 280px; }
|
||||
.file-browser { display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--border); }
|
||||
.file-browser-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.file-browser-title { font-size: 13px; font-weight: 600; }
|
||||
.file-browser-close { background: none; color: var(--muted); font-size: 14px; padding: 2px 6px; }
|
||||
.file-browser-close:hover { color: var(--text); background: none; }
|
||||
.file-browser-empty { padding: 16px 12px; color: var(--muted); font-size: 13px; }
|
||||
.file-list { list-style: none; padding: 4px 0; overflow-y: auto; flex: 1; }
|
||||
.file-entry { display: flex; align-items: center; gap: 6px; padding: 5px 12px; }
|
||||
.file-entry:hover { background: rgba(255,255,255,0.04); }
|
||||
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
|
||||
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
|
||||
.file-entry-dl:hover { background: rgba(124,106,247,0.15); }
|
||||
|
||||
/* ── share manager ── */
|
||||
.share-manager { width: 100%; display: flex; flex-direction: column; gap: 4px; }
|
||||
.share-list { list-style: none; display: flex; flex-direction: column; gap: 2px; margin-bottom: 2px; }
|
||||
.share-item { display: flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 0; }
|
||||
.share-icon { flex-shrink: 0; font-size: 11px; }
|
||||
.share-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.share-scope { font-size: 10px; color: var(--muted); flex-shrink: 0; }
|
||||
.share-repick { background: none; color: var(--muted); font-size: 12px; padding: 0 3px; flex-shrink: 0; }
|
||||
.share-repick:hover { color: var(--accent); background: none; }
|
||||
.share-remove { background: none; color: var(--muted); font-size: 11px; padding: 0 3px; flex-shrink: 0; }
|
||||
.share-remove:hover { color: #e06060; background: none; }
|
||||
|
||||
/* ── folder picker ── */
|
||||
.folder-picker { width: 100%; display: flex; flex-direction: column; gap: 4px; }
|
||||
.folder-picker-btn { background: var(--surface); color: var(--muted); border: 1px solid var(--border); font-size: 12px; padding: 5px 10px; border-radius: 4px; width: 100%; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.folder-picker-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface); }
|
||||
.folder-picker-subfolders { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); cursor: pointer; }
|
||||
.folder-picker-subfolders input { width: auto; cursor: pointer; }
|
||||
|
||||
/* ── file browser toolbar / breadcrumb / sort ── */
|
||||
.file-browser-toolbar { padding: 6px 8px 0; }
|
||||
.file-browser-search { font-size: 12px; padding: 4px 8px; }
|
||||
.file-browser-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; padding: 4px 8px; gap: 0; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--border); }
|
||||
.breadcrumb-seg { background: none; color: var(--muted); font-size: 11px; padding: 0 2px; }
|
||||
.breadcrumb-seg:hover { color: var(--accent); background: none; }
|
||||
.breadcrumb-sep { color: var(--border); padding: 0 1px; }
|
||||
.file-browser-sortbar { display: flex; gap: 4px; padding: 4px 8px; border-bottom: 1px solid var(--border); }
|
||||
.sort-btn { background: none; color: var(--muted); font-size: 11px; padding: 1px 6px; border-radius: 3px; }
|
||||
.sort-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
.sort-btn.active { color: var(--accent); background: none; }
|
||||
.file-entry-dir { cursor: pointer; }
|
||||
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
||||
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
||||
60
web/src/App.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useWaste } from './store'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { Chat } from './pages/Chat'
|
||||
import './App.css'
|
||||
|
||||
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'
|
||||
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||
// Use browser mode if a signal URL is configured, even on localhost
|
||||
const useBrowser = !isLocal || !!cfg?.signalURL
|
||||
|
||||
// Wails runtime is injected at /wails/runtime.js when running inside the desktop app.
|
||||
// EventsOn/EventsOff are no-ops in plain browser mode.
|
||||
type WailsRuntime = { EventsOn: (event: string, cb: (data: unknown) => void) => void }
|
||||
const wails = (window as unknown as { runtime?: WailsRuntime }).runtime
|
||||
|
||||
function useWailsNotifications() {
|
||||
useEffect(() => {
|
||||
if (!wails) return
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
const { title, body } = data as { title: string; body: string }
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, { body })
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then(p => {
|
||||
if (p === 'granted') new Notification(title, { body })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
wails.EventsOn('notify', handler)
|
||||
return () => {
|
||||
// EventsOff added in Wails v2.4; guard in case of older runtime.
|
||||
;(window as unknown as { runtime?: { EventsOff?: (...e: string[]) => void } })
|
||||
.runtime?.EventsOff?.('notify')
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||
|
||||
useEffect(() => {
|
||||
if (useBrowser) {
|
||||
connectBrowser()
|
||||
} else {
|
||||
connect(DAEMON_WS)
|
||||
}
|
||||
}, [connect, connectBrowser])
|
||||
|
||||
useWailsNotifications()
|
||||
|
||||
if (daemonStatus !== 'connected' || !localPeer) {
|
||||
return <Onboarding status={daemonStatus} />
|
||||
}
|
||||
|
||||
return <Chat />
|
||||
}
|
||||
972
web/src/adapter/browser.ts
Normal file
@@ -0,0 +1,972 @@
|
||||
// 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'
|
||||
|
||||
async function turnCredential(secret: string, username: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false, ['sign']
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(username))
|
||||
return btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
}
|
||||
|
||||
async function iceServers(): Promise<RTCIceServer[]> {
|
||||
const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnSecret?: string } }).WASTE_CONFIG
|
||||
const servers: RTCIceServer[] = [{ urls: STUN }]
|
||||
if (cfg?.turnURL && cfg?.turnSecret) {
|
||||
const user = Math.floor(Date.now() / 1000) + 3600 + ':waste'
|
||||
const credential = await turnCredential(cfg.turnSecret, user)
|
||||
servers.push({ urls: cfg.turnURL, username: user, credential })
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
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
|
||||
share: Map<string, File>
|
||||
|
||||
// in-flight receives: xid → state
|
||||
private _recv: Record<string, { name: string; size: number; sha: string; buf: ArrayBuffer[]; have: number; done: boolean; cancelled?: boolean }> = {}
|
||||
// open receive DataChannels for cancellation
|
||||
private _recvChannels: Record<string, RTCDataChannel> = {}
|
||||
// in-flight sends: xid → marker
|
||||
private _send: Record<string, Uint8Array> = {}
|
||||
// push-initiated files waiting for file-accept: xid → File
|
||||
private _pushQueue: Map<string, File> = new Map()
|
||||
|
||||
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string, share: Map<string, File>, ice: RTCIceServer[]) {
|
||||
this.identity = identity
|
||||
this.sig = sig
|
||||
this.peerId = peerId
|
||||
this.on = on
|
||||
this.nick = nick
|
||||
this.share = share
|
||||
this.pc = new RTCPeerConnection({ iceServers: ice })
|
||||
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 })
|
||||
if (this.pc.connectionState === 'connected') this.reportStats()
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
this.dc = channel
|
||||
channel.onopen = () => this._sendHello()
|
||||
channel.onmessage = (ev) => this._onControl(ev.data)
|
||||
if (channel.readyState === 'open') this._sendHello()
|
||||
return
|
||||
}
|
||||
if (channel.label.startsWith('f:')) {
|
||||
const xid = channel.label.slice(2)
|
||||
const rx = this._recv[xid]
|
||||
if (!rx) return
|
||||
channel.binaryType = 'arraybuffer'
|
||||
channel.onmessage = (ev) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return
|
||||
rx.buf.push(ev.data)
|
||||
rx.have += ev.data.byteLength
|
||||
this.on('file_progress', { peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
|
||||
}
|
||||
channel.onclose = async () => {
|
||||
if (rx.cancelled) { delete this._recv[xid]; return }
|
||||
const blob = new Blob(rx.buf)
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.on('file_recv', { peer: this.peerId, name: rx.name, size: rx.size, url })
|
||||
delete this._recv[xid]
|
||||
}
|
||||
this._recvChannels[xid] = channel
|
||||
}
|
||||
}
|
||||
|
||||
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() })
|
||||
} else if (m['type'] === 'browse') {
|
||||
const files = Array.from(this.share.entries()).map(([path, f]) => ({
|
||||
name: f.name, path, size: f.size, mime: f.type
|
||||
}))
|
||||
this._dc({ type: 'files', files })
|
||||
} else if (m['type'] === 'files') {
|
||||
const files = m['files'] as Array<{ name: string; path?: string; size: number; mime?: string }>
|
||||
this.on('files', { peer: this.peerId, files })
|
||||
} else if (m['type'] === 'get') {
|
||||
const path = m['path'] as string
|
||||
const file = this.share.get(path)
|
||||
if (!file) return
|
||||
this._dc({ type: 'file-offer', name: file.name, path, size: file.size, xid: path })
|
||||
} else if (m['type'] === 'file-offer') {
|
||||
const name = m['name'] as string
|
||||
const size = m['size'] as number
|
||||
const xid = m['xid'] as string
|
||||
this.on('file_offer', { peer: this.peerId, name, size, xid })
|
||||
} else if (m['type'] === 'file-accept') {
|
||||
const xid = m['xid'] as string
|
||||
// xid is either a push UUID or a share path
|
||||
if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid)
|
||||
} else if (m['type'] === 'file-done') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._recv[xid]) this._recv[xid].done = true
|
||||
} else if (m['type'] === 'file-cancel') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._recv[xid]) {
|
||||
this._recv[xid].cancelled = true
|
||||
this._recvChannels[xid]?.close()
|
||||
delete this._recvChannels[xid]
|
||||
this.on('file_cancelled', { peer: this.peerId, xid })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestBrowse() {
|
||||
this._dc({ type: 'browse', path: '/' })
|
||||
}
|
||||
|
||||
requestGet(path: string) {
|
||||
this._dc({ type: 'get', path })
|
||||
}
|
||||
|
||||
acceptOffer(xid: string, name: string, size: number) {
|
||||
this._recv[xid] = { name, size, sha: '', buf: [], have: 0, done: false }
|
||||
this._dc({ type: 'file-accept', xid })
|
||||
}
|
||||
|
||||
rejectOffer(xid: string) {
|
||||
this._dc({ type: 'file-cancel', xid })
|
||||
}
|
||||
|
||||
cancelRecv(xid: string) {
|
||||
if (this._recv[xid]) this._recv[xid].cancelled = true
|
||||
this._recvChannels[xid]?.close()
|
||||
delete this._recvChannels[xid]
|
||||
this._dc({ type: 'file-cancel', xid })
|
||||
}
|
||||
|
||||
cancelSend(xid: string) {
|
||||
this._pushQueue.delete(xid)
|
||||
delete this._send[xid]
|
||||
this._dc({ type: 'file-cancel', xid })
|
||||
}
|
||||
|
||||
sendFilePush(file: File) {
|
||||
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
this._pushQueue.set(xid, file)
|
||||
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
||||
}
|
||||
|
||||
private async _stream(xid: string) {
|
||||
const file = this._pushQueue.get(xid) ?? this.share.get(xid)
|
||||
if (!file) return
|
||||
this._pushQueue.delete(xid)
|
||||
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||
dc.binaryType = 'arraybuffer'
|
||||
this._send[xid] = new Uint8Array(0) // marker
|
||||
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||
const CHUNK = 64 * 1024
|
||||
const buf = await file.arrayBuffer()
|
||||
let offset = 0
|
||||
while (offset < buf.byteLength) {
|
||||
while (dc.bufferedAmount > 1024 * 1024) {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
dc.send(buf.slice(offset, offset + CHUNK))
|
||||
offset += CHUNK
|
||||
}
|
||||
dc.close()
|
||||
delete this._send[xid]
|
||||
this._dc({ type: 'file-done', xid })
|
||||
}
|
||||
|
||||
async reportStats() {
|
||||
try {
|
||||
const stats = await this.pc.getStats()
|
||||
let candidateType: string = 'unknown'
|
||||
let remoteAddress = ''
|
||||
stats.forEach((s) => {
|
||||
if (s.type === 'candidate-pair' && (s as RTCIceCandidatePairStats).state === 'succeeded') {
|
||||
const pair = s as RTCIceCandidatePairStats & { nominated?: boolean }
|
||||
if (pair.nominated) {
|
||||
stats.forEach((c) => {
|
||||
if (c.type === 'remote-candidate' && c.id === (pair as unknown as Record<string,string>)['remoteCandidateId']) {
|
||||
candidateType = (c as unknown as Record<string,string>)['candidateType'] || 'unknown'
|
||||
remoteAddress = (c as unknown as Record<string,string>)['address'] || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
this.on('ice_stats', { peer: this.peerId, candidateType, remoteAddress })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
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 = ''
|
||||
sharedFiles: Map<string, File> = new Map()
|
||||
|
||||
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 ice = await iceServers()
|
||||
const peer = new PeerConn(this.identity, this.sig!, pid,
|
||||
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
|
||||
this.peers.set(pid, peer)
|
||||
this._emitDiscovered(pid)
|
||||
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') {
|
||||
const ice = await iceServers()
|
||||
peer = new PeerConn(this.identity, this.sig!, from,
|
||||
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
|
||||
this._emitDiscovered(from)
|
||||
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,
|
||||
},
|
||||
})
|
||||
} else if (event === 'status') {
|
||||
this.emit({
|
||||
type: 'peer_status',
|
||||
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
||||
conn_state: data['state'] as import('../types').PeerConnState,
|
||||
})
|
||||
} else if (event === 'ice_stats') {
|
||||
this.emit({
|
||||
type: 'peer_status',
|
||||
peer_id: data['peer'] as unknown as import('../types').PeerID,
|
||||
candidate_type: data['candidateType'] as import('../types').CandidateType,
|
||||
remote_address: data['remoteAddress'] as string,
|
||||
})
|
||||
} else if (event === 'file_offer') {
|
||||
this.emit({
|
||||
type: 'incoming_file',
|
||||
peer_id: data['peer'] as string,
|
||||
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
|
||||
})
|
||||
} else if (event === 'file_progress') {
|
||||
this.emit({
|
||||
type: 'file_progress',
|
||||
peer_id: data['peer'] as string,
|
||||
transfer_id: data['xid'] as string,
|
||||
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['total'] as number, sha256: '' },
|
||||
bytes_received: data['received'] as number,
|
||||
total_bytes: data['total'] as number,
|
||||
})
|
||||
} else if (event === 'file_cancelled') {
|
||||
this.emit({
|
||||
type: 'error',
|
||||
peer_id: data['peer'] as string,
|
||||
error_message: `transfer ${(data['xid'] as string).slice(0, 8)} cancelled`,
|
||||
})
|
||||
} else if (event === 'files') {
|
||||
const raw = data['files'] as Array<{ name: string; path?: string; size: number; mime?: string }>
|
||||
this.emit({
|
||||
type: 'file_list',
|
||||
peer_id: data['peer'] as string,
|
||||
files: raw.map(f => ({ name: f.name, path: f.path ?? f.name, size_bytes: f.size })),
|
||||
})
|
||||
} else if (event === 'file_recv') {
|
||||
this.emit({
|
||||
type: 'file_complete',
|
||||
peer_id: data['peer'] as string,
|
||||
path: data['url'] as string,
|
||||
offer: { xid: data['name'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private _emitDiscovered(pid: string) {
|
||||
this.emit({
|
||||
type: 'peer_connected',
|
||||
peer: { id: pid, alias: pid.slice(0, 8), public_key: pid, created_at: new Date().toISOString() },
|
||||
})
|
||||
this.emit({
|
||||
type: 'peer_status',
|
||||
peer_id: pid as unknown as import('../types').PeerID,
|
||||
conn_state: 'connecting',
|
||||
})
|
||||
}
|
||||
|
||||
setSharedFiles(files: Map<string, File>) {
|
||||
this.sharedFiles = files
|
||||
this.peers.forEach(p => { p.share = files })
|
||||
}
|
||||
|
||||
requestBrowse(peerId: string) {
|
||||
this.peers.get(peerId)?.requestBrowse()
|
||||
}
|
||||
|
||||
requestGet(peerId: string, path: string) {
|
||||
this.peers.get(peerId)?.requestGet(path)
|
||||
}
|
||||
|
||||
sendFileTo(peerId: string, file: File) {
|
||||
this.peers.get(peerId)?.sendFilePush(file)
|
||||
}
|
||||
|
||||
acceptOffer(peerId: string, xid: string, name: string, size: number) {
|
||||
this.peers.get(peerId)?.acceptOffer(xid, name, size)
|
||||
}
|
||||
|
||||
rejectOffer(peerId: string, xid: string) {
|
||||
this.peers.get(peerId)?.rejectOffer(xid)
|
||||
}
|
||||
|
||||
cancelTransfer(peerId: string, xid: string, direction: 'recv' | 'send') {
|
||||
if (direction === 'recv') this.peers.get(peerId)?.cancelRecv(xid)
|
||||
else this.peers.get(peerId)?.cancelSend(xid)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
76
web/src/adapter/daemon.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { IpcMessage } from '../types'
|
||||
|
||||
type Listener = (msg: IpcMessage) => void
|
||||
|
||||
// DaemonAdapter connects to a locally-running waste daemon over WebSocket IPC.
|
||||
// The daemon must have WS IPC enabled (--ws-port flag).
|
||||
export class DaemonAdapter {
|
||||
private ws: WebSocket | null = null
|
||||
private listeners: Listener[] = []
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
status: 'disconnected' | 'connecting' | 'connected' = 'disconnected'
|
||||
onStatusChange?: (s: DaemonAdapter['status']) => void
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws) return
|
||||
this.setStatus('connecting')
|
||||
|
||||
const ws = new WebSocket(this.url)
|
||||
this.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
this.setStatus('connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg: IpcMessage = JSON.parse(ev.data)
|
||||
this.listeners.forEach(l => l(msg))
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null
|
||||
this.setStatus('disconnected')
|
||||
// Reconnect after 2s
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 2000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
send(msg: IpcMessage) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg) + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
on(listener: Listener) {
|
||||
this.listeners.push(listener)
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(s: DaemonAdapter['status']) {
|
||||
this.status = s
|
||||
this.onStatusChange?.(s)
|
||||
}
|
||||
}
|
||||
BIN
web/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
web/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
192
web/src/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
import { BrowserAdapter } from '../adapter/browser'
|
||||
import type { FileEntry } from '../types'
|
||||
|
||||
function fmt(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'size'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
interface DirEntry {
|
||||
kind: 'dir'
|
||||
name: string
|
||||
path: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface FileRow {
|
||||
kind: 'file'
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type Row = DirEntry | FileRow
|
||||
|
||||
function buildRows(files: FileEntry[], cwd: string, search: string, sort: SortKey, sortDir: SortDir): Row[] {
|
||||
// Filter to entries under cwd
|
||||
const prefix = cwd ? cwd + '/' : ''
|
||||
const inDir = files.filter(f => {
|
||||
const p = f.path ?? f.name
|
||||
return p.startsWith(prefix)
|
||||
})
|
||||
|
||||
if (search.trim()) {
|
||||
// Flat search across all files under cwd
|
||||
const q = search.toLowerCase()
|
||||
return inDir
|
||||
.filter(f => f.name.toLowerCase().includes(q))
|
||||
.map(f => ({ kind: 'file' as const, name: f.name, path: f.path ?? f.name, size: f.size_bytes }))
|
||||
.sort((a, b) => {
|
||||
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
// Build immediate children: dirs and files at this level
|
||||
const dirs = new Map<string, number>() // dirName → file count
|
||||
const fileRows: FileRow[] = []
|
||||
|
||||
for (const f of inDir) {
|
||||
const rel = (f.path ?? f.name).slice(prefix.length)
|
||||
const slash = rel.indexOf('/')
|
||||
if (slash === -1) {
|
||||
fileRows.push({ kind: 'file', name: f.name, path: f.path ?? f.name, size: f.size_bytes })
|
||||
} else {
|
||||
const dirName = rel.slice(0, slash)
|
||||
dirs.set(dirName, (dirs.get(dirName) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const dirRows: DirEntry[] = Array.from(dirs.entries()).map(([name, count]) => ({
|
||||
kind: 'dir', name, path: prefix + name, count,
|
||||
}))
|
||||
|
||||
// Sort each group separately, then dirs first
|
||||
const cmpFiles = (a: FileRow, b: FileRow) => {
|
||||
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
}
|
||||
dirRows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
fileRows.sort(cmpFiles)
|
||||
|
||||
return [...dirRows, ...fileRows]
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
|
||||
const [cwd, setCwd] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState<SortKey>('name')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
if (!activeFilePeer) return null
|
||||
|
||||
const peer = connectedPeers.find(p => p.id === activeFilePeer)
|
||||
const files = fileLists[activeFilePeer] ?? null
|
||||
|
||||
function download(path: string) {
|
||||
if (adapter instanceof BrowserAdapter) {
|
||||
adapter.requestGet(activeFilePeer!, path)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sort === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else { setSort(key); setSortDir('asc') }
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
const parts = cwd.split('/')
|
||||
parts.pop()
|
||||
setCwd(parts.join('/'))
|
||||
}
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!files) return []
|
||||
return buildRows(files, cwd, search, sort, sortDir)
|
||||
}, [files, cwd, search, sort, sortDir])
|
||||
|
||||
const cwdParts = cwd ? cwd.split('/') : []
|
||||
|
||||
return (
|
||||
<div className="file-browser">
|
||||
<div className="file-browser-header">
|
||||
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
|
||||
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}>✕</button>
|
||||
</div>
|
||||
|
||||
{files === null ? (
|
||||
<div className="file-browser-empty">Loading…</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="file-browser-empty">No files shared</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="file-browser-toolbar">
|
||||
<input
|
||||
className="file-browser-search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="search…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="file-browser-breadcrumb">
|
||||
<button onClick={() => setCwd('')} className="breadcrumb-seg">~</button>
|
||||
{cwdParts.map((seg, i) => (
|
||||
<span key={i}>
|
||||
<span className="breadcrumb-sep">/</span>
|
||||
<button
|
||||
className="breadcrumb-seg"
|
||||
onClick={() => setCwd(cwdParts.slice(0, i + 1).join('/'))}
|
||||
>{seg}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sort bar */}
|
||||
<div className="file-browser-sortbar">
|
||||
<button className={`sort-btn ${sort === 'name' ? 'active' : ''}`} onClick={() => toggleSort('name')}>
|
||||
name {sort === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</button>
|
||||
<button className={`sort-btn ${sort === 'size' ? 'active' : ''}`} onClick={() => toggleSort('size')}>
|
||||
size {sort === 'size' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="file-list">
|
||||
{cwd && !search && (
|
||||
<li className="file-entry file-entry-dir" onClick={navigateUp}>
|
||||
<span className="file-entry-icon">📁</span>
|
||||
<span className="file-entry-name">..</span>
|
||||
</li>
|
||||
)}
|
||||
{rows.length === 0 && (
|
||||
<li className="file-browser-empty">no results</li>
|
||||
)}
|
||||
{rows.map(row => row.kind === 'dir' ? (
|
||||
<li key={row.path} className="file-entry file-entry-dir" onClick={() => { setCwd(row.path); setSearch('') }}>
|
||||
<span className="file-entry-icon">📁</span>
|
||||
<span className="file-entry-name">{row.name}</span>
|
||||
<span className="file-entry-size">{row.count} file{row.count !== 1 ? 's' : ''}</span>
|
||||
</li>
|
||||
) : (
|
||||
<li key={row.path} className="file-entry">
|
||||
<span className="file-entry-icon">📄</span>
|
||||
<span className="file-entry-name">{row.name}</span>
|
||||
<span className="file-entry-size">{fmt(row.size)}</span>
|
||||
<button className="file-entry-dl" onClick={() => download(row.path)}>↓</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
web/src/components/FolderPicker.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function FolderPicker() {
|
||||
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [includeSubfolders, setIncludeSubfolders] = useState(true)
|
||||
|
||||
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
|
||||
const label = current && current.size > 0
|
||||
? `${current.size} file${current.size !== 1 ? 's' : ''} shared`
|
||||
: null
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const fileList = e.target.files
|
||||
if (!fileList || fileList.length === 0) return
|
||||
const map = new Map<string, File>()
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const f = fileList[i]
|
||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath
|
||||
// Strip the root folder name prefix (first path segment) so paths are relative to the picked folder
|
||||
const path = rel ? rel.split('/').slice(1).join('/') : f.name
|
||||
if (!includeSubfolders && path.includes('/')) continue
|
||||
map.set(path, f)
|
||||
}
|
||||
setSharedFiles(map)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="folder-picker">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is non-standard
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<button
|
||||
className="folder-picker-btn"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
title="Share a folder with peers on this network"
|
||||
>
|
||||
{label ?? '+ Share folder'}
|
||||
</button>
|
||||
<label className="folder-picker-subfolders">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSubfolders}
|
||||
onChange={e => setIncludeSubfolders(e.target.checked)}
|
||||
/>
|
||||
include subfolders
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
web/src/components/MessagePane.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function MessagePane() {
|
||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
||||
const [draft, setDraft] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const roomMessages = messages[activeRoom] ?? []
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [roomMessages.length])
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const text = draft.trim()
|
||||
if (!text || !activeNetworkId) return
|
||||
|
||||
if (activeRoom.startsWith('dm:')) {
|
||||
const toPeerID = activeRoom.slice(3)
|
||||
const peer = connectedPeers.find(p => p.id.startsWith(toPeerID) || p.id === toPeerID)
|
||||
if (peer) {
|
||||
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text, to: peer.id })
|
||||
}
|
||||
} else {
|
||||
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text })
|
||||
}
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
function aliasFor(fromId: string) {
|
||||
if (fromId === localPeer?.id) return localPeer.alias
|
||||
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
|
||||
}
|
||||
|
||||
const roomLabel = activeRoom.startsWith('dm:')
|
||||
? `@ ${activeRoom.slice(3, 11)}…`
|
||||
: `# ${activeRoom}`
|
||||
|
||||
return (
|
||||
<main className="message-pane">
|
||||
<div className="message-pane-header">{roomLabel}</div>
|
||||
|
||||
<div className="messages">
|
||||
{roomMessages.map((msg, i) => {
|
||||
const mine = msg.from === localPeer?.id
|
||||
const alias = aliasFor(msg.from)
|
||||
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
return (
|
||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
||||
<span className="message-ts">{time}</span>
|
||||
<span className="message-alias">{alias}</span>
|
||||
<span className="message-text">{msg.text}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<form className="compose" onSubmit={submit}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder={`Message ${roomLabel}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" disabled={!draft.trim()}>Send</button>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
35
web/src/components/PeerList.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function PeerList() {
|
||||
const { connectedPeers, activeNetworkId, setActiveRoom, send } = useWaste()
|
||||
|
||||
function openDM(peerId: string) {
|
||||
const room = `dm:${peerId}`
|
||||
setActiveRoom(room)
|
||||
}
|
||||
|
||||
function requestFiles(peerId: string) {
|
||||
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="peer-list">
|
||||
<span className="sidebar-label">Peers ({connectedPeers.length})</span>
|
||||
{connectedPeers.map(p => (
|
||||
<div key={p.id} className="peer-entry">
|
||||
<div className="peer-entry-info">
|
||||
<span className="alias">{p.alias}</span>
|
||||
<span className="peer-id">{p.id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<div className="peer-entry-actions">
|
||||
<button onClick={() => openDM(p.id)} title="Direct message">DM</button>
|
||||
<button onClick={() => requestFiles(p.id)} title="Browse files">Files</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{connectedPeers.length === 0 && (
|
||||
<p className="empty">No peers connected yet</p>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
118
web/src/components/ShareManager.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
interface ShareRecord {
|
||||
name: string // display name (folder name picked by user)
|
||||
global: boolean // true = all networks
|
||||
networkId?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'waste_shares'
|
||||
|
||||
function loadShares(): ShareRecord[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveShares(shares: ShareRecord[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(shares))
|
||||
}
|
||||
|
||||
export function ShareManager() {
|
||||
const { activeNetworkId, setSharedFiles, sharedFilesByNetwork } = useWaste()
|
||||
const [shares, setShares] = useState<ShareRecord[]>(loadShares)
|
||||
const [includeSubfolders, setIncludeSubfolders] = useState(true)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Count files currently shared on active network
|
||||
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
|
||||
const fileCount = current?.size ?? 0
|
||||
|
||||
function persist(next: ShareRecord[]) {
|
||||
setShares(next)
|
||||
saveShares(next)
|
||||
}
|
||||
|
||||
function addShare(files: FileList, folderName: string) {
|
||||
const map = new Map<string, File>()
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i]
|
||||
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath
|
||||
const path = rel ? rel.split('/').slice(1).join('/') : f.name
|
||||
if (!includeSubfolders && path.includes('/')) continue
|
||||
map.set(path, f)
|
||||
}
|
||||
setSharedFiles(map)
|
||||
|
||||
const record: ShareRecord = {
|
||||
name: folderName,
|
||||
global: true,
|
||||
networkId: activeNetworkId ?? undefined,
|
||||
}
|
||||
persist([...shares.filter(s => s.name !== folderName), record])
|
||||
}
|
||||
|
||||
function removeShare(name: string) {
|
||||
persist(shares.filter(s => s.name !== name))
|
||||
// Clear the in-memory share if it matches
|
||||
setSharedFiles(new Map())
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const fileList = e.target.files
|
||||
if (!fileList || fileList.length === 0) return
|
||||
// Get folder name from first file's path
|
||||
const first = fileList[0] as File & { webkitRelativePath?: string }
|
||||
const folderName = first.webkitRelativePath?.split('/')[0] ?? 'folder'
|
||||
addShare(fileList, folderName)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="share-manager">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
// @ts-expect-error webkitdirectory is non-standard
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
{shares.length > 0 && (
|
||||
<ul className="share-list">
|
||||
{shares.map(s => (
|
||||
<li key={s.name} className="share-item">
|
||||
<span className="share-icon">📁</span>
|
||||
<span className="share-name" title={s.name}>{s.name}</span>
|
||||
<span className="share-scope">{s.global ? 'all nets' : 'this net'}</span>
|
||||
<button className="share-repick" onClick={() => inputRef.current?.click()} title="Re-pick folder">↺</button>
|
||||
<button className="share-remove" onClick={() => removeShare(s.name)} title="Remove share">✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="folder-picker-btn"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
title="Share a folder with peers on this network"
|
||||
>
|
||||
{fileCount > 0 ? `${fileCount} file${fileCount !== 1 ? 's' : ''} shared` : '+ Share folder'}
|
||||
</button>
|
||||
|
||||
<label className="folder-picker-subfolders">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSubfolders}
|
||||
onChange={e => setIncludeSubfolders(e.target.checked)}
|
||||
/>
|
||||
include subfolders
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
import type { PeerStatus } from '../store'
|
||||
import { ShareManager } from './ShareManager'
|
||||
import { Transfers } from './Transfers'
|
||||
|
||||
function makeYawCard(id: string, alias: string): string {
|
||||
const nick = encodeURIComponent(alias.trim().slice(0, 40))
|
||||
return nick ? `yaw:${id}?n=${nick}` : `yaw:${id}`
|
||||
}
|
||||
|
||||
function connDot(status: PeerStatus | undefined): { color: string; label: string } {
|
||||
const ct = status?.candidateType
|
||||
const cs = status?.connState
|
||||
if (cs === 'failed' || cs === 'closed') return { color: '#e06060', label: 'failed' }
|
||||
if (cs === 'connecting' || cs === 'new') return { color: '#c8a020', label: 'connecting…' }
|
||||
if (ct === 'relay') return { color: '#c8a020', label: 'relayed (TURN)' }
|
||||
if (ct === 'srflx') return { color: '#60c060', label: 'NAT punched' }
|
||||
if (ct === 'host') return { color: '#60c060', label: 'direct (LAN)' }
|
||||
if (cs === 'connected') return { color: '#60c060', label: 'connected' }
|
||||
return { color: 'var(--muted)', label: 'unknown' }
|
||||
}
|
||||
|
||||
function formatTs(ts: number | undefined): string {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const {
|
||||
localPeer, masterId, masterAlias,
|
||||
networks, activeNetworkId, activeRoom,
|
||||
connectedPeers, peerStatus,
|
||||
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||
customRooms, createRoom, logout,
|
||||
} = useWaste()
|
||||
const [addingRoom, setAddingRoom] = useState(false)
|
||||
const [newRoomName, setNewRoomName] = useState('')
|
||||
|
||||
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
||||
const rooms = ['general', ...netCustomRooms]
|
||||
Object.keys(messages).forEach(r => {
|
||||
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
|
||||
})
|
||||
|
||||
function submitNewRoom(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (newRoomName.trim()) createRoom(newRoomName)
|
||||
setNewRoomName('')
|
||||
setAddingRoom(false)
|
||||
}
|
||||
|
||||
const displayAlias = localPeer?.alias ?? masterAlias ?? ''
|
||||
const displayId = localPeer?.id ?? masterId ?? ''
|
||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||
|
||||
function copyHangLink() {
|
||||
const net = networks.find(n => n.network_id === activeNetworkId)
|
||||
if (!net) return
|
||||
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||
const anchor = cfg?.signalURL ?? ''
|
||||
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
const url = `${window.location.origin}/#waste:${payload}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||
logout(clearId)
|
||||
}
|
||||
|
||||
function openDM(peerId: string) {
|
||||
setActiveRoom(`dm:${peerId}`)
|
||||
}
|
||||
|
||||
function requestFiles(peerId: string) {
|
||||
browseFiles(peerId)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-identity">
|
||||
<div
|
||||
style={{ flex: 1, cursor: card ? 'pointer' : undefined }}
|
||||
title={card ?? undefined}
|
||||
onClick={() => card && navigator.clipboard?.writeText(card)}
|
||||
>
|
||||
<span className="alias">{displayAlias || '…'}</span>
|
||||
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
|
||||
</div>
|
||||
<button className="sidebar-logout" onClick={handleLogout} title="Leave network">⏻</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
{activeNetworkId && (
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||
)}
|
||||
</div>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
||||
onClick={() => setActiveNetwork(n.network_id)}
|
||||
>
|
||||
{n.network_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Rooms</span>
|
||||
<button className="sidebar-add" onClick={() => setAddingRoom(v => !v)} title="New room">+</button>
|
||||
</div>
|
||||
{rooms.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
||||
onClick={() => setActiveRoom(r)}
|
||||
>
|
||||
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}…` : `# ${r}`}
|
||||
</button>
|
||||
))}
|
||||
{addingRoom && (
|
||||
<form className="sidebar-new-room" onSubmit={submitNewRoom}>
|
||||
<input
|
||||
autoFocus
|
||||
value={newRoomName}
|
||||
onChange={e => setNewRoomName(e.target.value)}
|
||||
placeholder="room-name"
|
||||
onKeyDown={e => e.key === 'Escape' && (setAddingRoom(false), setNewRoomName(''))}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adapterMode === 'browser' && (
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Sharing</span>
|
||||
<div style={{ padding: '4px 12px' }}><ShareManager /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Transfers />
|
||||
|
||||
<div className="sidebar-section sidebar-peers">
|
||||
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
|
||||
|
||||
{/* Self */}
|
||||
<div className="peer-row peer-row-self">
|
||||
<span className="peer-dot" style={{ background: 'var(--accent)' }} />
|
||||
<span className="peer-row-alias">{displayAlias || '…'}</span>
|
||||
<span className="peer-row-you">you</span>
|
||||
</div>
|
||||
|
||||
{connectedPeers.length === 0 && (
|
||||
<span className="sidebar-empty">no peers connected</span>
|
||||
)}
|
||||
{connectedPeers.map(p => {
|
||||
const st = peerStatus[p.id]
|
||||
const dot = connDot(st)
|
||||
const tooltip = [
|
||||
p.alias,
|
||||
p.id,
|
||||
`connection: ${dot.label}`,
|
||||
st?.remoteAddress ? `remote: ${st.remoteAddress}` : null,
|
||||
st?.lastSeen ? `last seen: ${formatTs(st.lastSeen)}` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
return (
|
||||
<div key={p.id} className="peer-row" title={tooltip}>
|
||||
<span className="peer-dot" style={{ background: dot.color }} />
|
||||
<span className="peer-row-alias">{p.alias}</span>
|
||||
<span className="peer-row-id">{p.id.slice(0, 8)}</span>
|
||||
<span className="peer-row-actions">
|
||||
<button className="peer-action" onClick={() => openDM(p.id)} title="DM">↩</button>
|
||||
<button className="peer-action" onClick={() => requestFiles(p.id)} title="Browse files">⊞</button>
|
||||
<label className="peer-action" title="Send file" style={{ cursor: 'pointer' }}>
|
||||
📎
|
||||
<input type="file" style={{ display: 'none' }} onChange={e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) sendFileTo(p.id, f)
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
51
web/src/components/Transfers.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useWaste } from '../store'
|
||||
|
||||
function fmt(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function Transfers() {
|
||||
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||
|
||||
const hasPending = Object.keys(pendingOffers).length > 0
|
||||
const hasActive = Object.keys(fileProgress).length > 0
|
||||
|
||||
if (!hasPending && !hasActive) return null
|
||||
|
||||
function alias(peerId: string) {
|
||||
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Transfers</span>
|
||||
|
||||
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
||||
<div key={xid} className="transfer-row">
|
||||
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
||||
<span className="transfer-meta">{fmt(offer.size)} from {alias(offer.peerId)}</span>
|
||||
<div className="transfer-actions">
|
||||
<button className="transfer-btn accept" onClick={() => acceptOffer(offer.peerId, xid, offer.name, offer.size)}>Accept</button>
|
||||
<button className="transfer-btn reject" onClick={() => rejectOffer(offer.peerId, xid)}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(fileProgress).map(([xid, p]) => {
|
||||
const pct = p.total > 0 ? Math.round((p.received / p.total) * 100) : 0
|
||||
return (
|
||||
<div key={xid} className="transfer-row">
|
||||
<span className="transfer-name" title={p.name}>{p.name}</span>
|
||||
<span className="transfer-meta">{fmt(p.received)} / {fmt(p.total)} · {alias(p.peerId)}</span>
|
||||
<div className="transfer-progress">
|
||||
<div className="transfer-progress-bar" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<button className="transfer-btn reject" onClick={() => cancelTransfer(p.peerId, xid, 'recv')}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
web/src/index.css
Normal file
@@ -0,0 +1,111 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
15
web/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import { MessagePane } from '../components/MessagePane'
|
||||
import { FileBrowser } from '../components/FileBrowser'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function Chat() {
|
||||
const { activeFilePeer } = useWaste()
|
||||
return (
|
||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
|
||||
<Sidebar />
|
||||
<MessagePane />
|
||||
{activeFilePeer && <FileBrowser />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
313
web/src/pages/Onboarding.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
interface Props {
|
||||
status: 'disconnected' | 'connecting' | 'connected'
|
||||
}
|
||||
|
||||
// Parse URL search params for pre-filling the join form.
|
||||
// Supports:
|
||||
// ?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
|
||||
// ?a=<url> anchor URL hint
|
||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
let inviteString = p.get('invite') ?? ''
|
||||
let network = p.get('n') ?? p.get('network') ?? ''
|
||||
let netHash = p.get('net') ?? ''
|
||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||
|
||||
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||
const hash = window.location.hash.slice(1) // strip leading #
|
||||
if (!inviteString && hash.startsWith('waste:')) {
|
||||
inviteString = hash
|
||||
}
|
||||
|
||||
if (inviteString.startsWith('waste:')) {
|
||||
try {
|
||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||
network = network || json.network || ''
|
||||
netHash = netHash || json.net || ''
|
||||
anchor = anchor || json.anchor || ''
|
||||
} catch { /* ignore bad invite */ }
|
||||
}
|
||||
|
||||
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, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
|
||||
|
||||
const [network, setNetwork] = useState('')
|
||||
const [netHash, setNetHash] = useState('')
|
||||
const [inviteString, setInviteString] = 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) setAnchorUrl(a)
|
||||
if (inv) setInviteString(inv)
|
||||
}, [])
|
||||
|
||||
// Auto-rejoin when adapter is ready and we have saved session state
|
||||
useEffect(() => {
|
||||
if (adapterMode !== 'browser' || status !== 'connected') return
|
||||
const { network: n, netHash: nh } = parseInviteParams()
|
||||
if (n || nh) return // explicit invite — don't auto-join, show form
|
||||
const savedNetwork = localStorage.getItem('waste_last_network')
|
||||
if (savedNetwork) doJoin(savedNetwork, '')
|
||||
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function joinNetwork(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
doJoin(network.trim(), netHash.trim())
|
||||
}
|
||||
|
||||
function doJoin(name: string, hash: string) {
|
||||
if (!name && !hash) return
|
||||
|
||||
if (adapterMode === 'browser') {
|
||||
localStorage.setItem('waste_anchor_url', anchorUrl)
|
||||
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
||||
if (name) localStorage.setItem('waste_last_network', name)
|
||||
else localStorage.removeItem('waste_last_network')
|
||||
}
|
||||
|
||||
if (hash.length === 64 && !name) {
|
||||
send({ type: 'join_network', network_hash: hash })
|
||||
} else {
|
||||
send({ type: 'join_network', network_name: name })
|
||||
}
|
||||
}
|
||||
|
||||
function exportIdentity(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!exportPass.trim()) return
|
||||
send({ type: 'export_identity', passphrase: exportPass })
|
||||
}
|
||||
|
||||
function importIdentity(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!importJson.trim() || !importPass.trim()) return
|
||||
setImportStatus('Verifying…')
|
||||
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 ────────────────────────────────────────────────
|
||||
if (status !== 'connected') {
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<h1>waste</h1>
|
||||
{status === 'connecting' ? (
|
||||
<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>
|
||||
<pre className="onboarding-code">./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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── connected — join form ────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="onboarding">
|
||||
<h1>waste</h1>
|
||||
|
||||
{masterAlias && (
|
||||
<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>
|
||||
|
||||
{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}
|
||||
onChange={e => setNetHash(e.target.value)}
|
||||
placeholder="network hash (64 hex)"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="mono"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={network}
|
||||
onChange={e => setNetwork(e.target.value)}
|
||||
placeholder="network name"
|
||||
autoComplete="off"
|
||||
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)}…
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="primary" disabled={!network.trim() && netHash.length !== 64}>
|
||||
Join
|
||||
</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"
|
||||
onClick={() => setShowBackup(b => !b)}
|
||||
>
|
||||
{showBackup ? '▾' : '▸'} Identity backup
|
||||
</button>
|
||||
|
||||
{showBackup && (
|
||||
<div className="backup-panel">
|
||||
<p className="onboarding-hint">
|
||||
Export your identity as an encrypted file. You can import it on
|
||||
any device — browser or TUI.
|
||||
</p>
|
||||
|
||||
<form onSubmit={exportIdentity} className="backup-form">
|
||||
<input
|
||||
type="password"
|
||||
value={exportPass}
|
||||
onChange={e => setExportPass(e.target.value)}
|
||||
placeholder="passphrase for backup"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button type="submit" disabled={!exportPass.trim()}>Export</button>
|
||||
</form>
|
||||
|
||||
{exportedBackup && (
|
||||
<div className="export-result">
|
||||
<textarea
|
||||
readOnly
|
||||
value={exportedBackup}
|
||||
rows={4}
|
||||
className="mono"
|
||||
onClick={e => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button onClick={() => {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(new Blob([exportedBackup], { type: 'application/json' }))
|
||||
a.download = `waste-identity-${masterId?.slice(0, 8) ?? 'backup'}.json`
|
||||
a.click()
|
||||
}}>Download file</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details style={{ marginTop: '1rem' }}>
|
||||
<summary className="onboarding-hint" style={{ cursor: 'pointer' }}>Restore from backup</summary>
|
||||
<form onSubmit={importIdentity} className="backup-form" style={{ marginTop: '0.5rem' }}>
|
||||
<textarea
|
||||
value={importJson}
|
||||
onChange={e => setImportJson(e.target.value)}
|
||||
placeholder='{"yaw":"yaw-key-backup-1", …}'
|
||||
rows={3}
|
||||
className="mono"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={importPass}
|
||||
onChange={e => setImportPass(e.target.value)}
|
||||
placeholder="passphrase"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button type="submit" disabled={!importJson.trim() || !importPass.trim()}>
|
||||
Verify & restore
|
||||
</button>
|
||||
{importStatus && <p className="onboarding-hint">{importStatus}</p>}
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
357
web/src/store/index.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { create } from 'zustand'
|
||||
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage, PeerConnState, CandidateType } from '../types'
|
||||
|
||||
export interface PeerStatus {
|
||||
connState?: PeerConnState
|
||||
candidateType?: CandidateType
|
||||
remoteAddress?: string
|
||||
lastSeen?: number
|
||||
}
|
||||
import { DaemonAdapter } from '../adapter/daemon'
|
||||
import { BrowserAdapter } from '../adapter/browser'
|
||||
|
||||
type AnyAdapter = DaemonAdapter | BrowserAdapter
|
||||
|
||||
interface WasteState {
|
||||
// connection
|
||||
adapter: AnyAdapter | null
|
||||
adapterMode: 'daemon' | 'browser' | null
|
||||
daemonStatus: 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
// identity
|
||||
masterAlias: string | null
|
||||
masterId: string | null
|
||||
localPeer: PeerInfo | null
|
||||
|
||||
// networks
|
||||
networks: NetworkInfo[]
|
||||
activeNetworkId: string | null
|
||||
|
||||
// peers
|
||||
connectedPeers: PeerInfo[]
|
||||
|
||||
// chat — keyed by room
|
||||
messages: Record<string, ChatMessage[]>
|
||||
activeRoom: string
|
||||
// user-created rooms, keyed by networkId
|
||||
customRooms: Record<string, string[]>
|
||||
|
||||
// file listings — keyed by peer id
|
||||
fileLists: Record<string, FileEntry[]>
|
||||
|
||||
// per-peer connection status (browser mode) and last-seen timestamps
|
||||
peerStatus: Record<string, PeerStatus>
|
||||
|
||||
// identity backup export result (cleared when consumed)
|
||||
exportedBackup: string | null
|
||||
|
||||
// file browser state
|
||||
activeFilePeer: string | null
|
||||
// shared files keyed by networkId
|
||||
sharedFilesByNetwork: Record<string, Map<string, File>>
|
||||
// incoming offers awaiting accept/reject: xid → offer info
|
||||
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
||||
// active in-progress transfers: xid → progress
|
||||
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
||||
|
||||
// actions
|
||||
connect: (url: string) => void
|
||||
connectBrowser: () => void
|
||||
disconnect: () => void
|
||||
send: (msg: IpcMessage) => void
|
||||
setActiveNetwork: (id: string) => void
|
||||
setActiveRoom: (room: string) => void
|
||||
setActiveFilePeer: (peerId: string | null) => void
|
||||
setSharedFiles: (files: Map<string, File>, networkId?: string) => void
|
||||
browseFiles: (peerId: string) => void
|
||||
sendFileTo: (peerId: string, file: File) => void
|
||||
acceptOffer: (peerId: string, xid: string, name: string, size: number) => void
|
||||
rejectOffer: (peerId: string, xid: string) => void
|
||||
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
|
||||
createRoom: (name: string) => void
|
||||
logout: (clearIdentity: boolean) => void
|
||||
handleEvent: (msg: IpcMessage) => void
|
||||
}
|
||||
|
||||
export const useWaste = create<WasteState>((set, get) => ({
|
||||
adapter: null,
|
||||
adapterMode: null,
|
||||
daemonStatus: 'disconnected',
|
||||
masterAlias: null,
|
||||
masterId: null,
|
||||
localPeer: null,
|
||||
networks: [],
|
||||
activeNetworkId: null,
|
||||
connectedPeers: [],
|
||||
messages: {},
|
||||
activeRoom: 'general',
|
||||
customRooms: {},
|
||||
fileLists: {},
|
||||
exportedBackup: null,
|
||||
peerStatus: {},
|
||||
activeFilePeer: null,
|
||||
sharedFilesByNetwork: {},
|
||||
pendingOffers: {},
|
||||
fileProgress: {},
|
||||
|
||||
connect(url: string) {
|
||||
const adapter = new DaemonAdapter(url)
|
||||
adapter.onStatusChange = (s) => set({ daemonStatus: s })
|
||||
adapter.on((msg) => get().handleEvent(msg))
|
||||
adapter.connect()
|
||||
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() {
|
||||
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) {
|
||||
get().adapter?.send(msg)
|
||||
},
|
||||
|
||||
setActiveNetwork(id) {
|
||||
set({ activeNetworkId: id })
|
||||
},
|
||||
|
||||
setActiveRoom(room) {
|
||||
set({ activeRoom: room })
|
||||
},
|
||||
|
||||
setActiveFilePeer(peerId) {
|
||||
set({ activeFilePeer: peerId })
|
||||
},
|
||||
|
||||
setSharedFiles(files, networkId) {
|
||||
const netId = networkId ?? get().activeNetworkId ?? ''
|
||||
if (!netId) return
|
||||
set(s => ({ sharedFilesByNetwork: { ...s.sharedFilesByNetwork, [netId]: files } }))
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) a.setSharedFiles(files)
|
||||
},
|
||||
|
||||
sendFileTo(peerId, file) {
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) a.sendFileTo(peerId, file)
|
||||
},
|
||||
|
||||
acceptOffer(peerId, xid, name, size) {
|
||||
set(s => {
|
||||
const p = { ...s.pendingOffers }; delete p[xid]
|
||||
return { pendingOffers: p, fileProgress: { ...s.fileProgress, [xid]: { peerId, name, received: 0, total: size } } }
|
||||
})
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) a.acceptOffer(peerId, xid, name, size)
|
||||
},
|
||||
|
||||
rejectOffer(peerId, xid) {
|
||||
set(s => { const p = { ...s.pendingOffers }; delete p[xid]; return { pendingOffers: p } })
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) a.rejectOffer(peerId, xid)
|
||||
},
|
||||
|
||||
cancelTransfer(peerId, xid, direction) {
|
||||
set(s => {
|
||||
const fp = { ...s.fileProgress }; delete fp[xid]
|
||||
return { fileProgress: fp }
|
||||
})
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) a.cancelTransfer(peerId, xid, direction)
|
||||
},
|
||||
|
||||
logout(clearIdentity) {
|
||||
const a = get().adapter
|
||||
if (a instanceof DaemonAdapter) a.disconnect()
|
||||
else if (a instanceof BrowserAdapter) a.disconnect()
|
||||
localStorage.removeItem('waste_last_network')
|
||||
localStorage.removeItem('waste_nick')
|
||||
localStorage.removeItem('waste_anchor_url')
|
||||
if (clearIdentity) localStorage.removeItem('waste_seed')
|
||||
window.location.reload()
|
||||
},
|
||||
|
||||
createRoom(name) {
|
||||
const netId = get().activeNetworkId
|
||||
if (!netId || !name.trim()) return
|
||||
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
set(s => {
|
||||
const existing = s.customRooms[netId] ?? []
|
||||
if (existing.includes(key)) return s
|
||||
return {
|
||||
customRooms: { ...s.customRooms, [netId]: [...existing, key] },
|
||||
activeRoom: key,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
browseFiles(peerId) {
|
||||
const a = get().adapter
|
||||
if (a instanceof BrowserAdapter) {
|
||||
a.requestBrowse(peerId)
|
||||
} else {
|
||||
get().send({ type: 'get_file_list', peer_id: peerId as unknown as import('../types').PeerID })
|
||||
}
|
||||
set({ activeFilePeer: peerId })
|
||||
},
|
||||
|
||||
handleEvent(msg) {
|
||||
switch (msg.type) {
|
||||
case 'state_snapshot': {
|
||||
const networks = msg.networks ?? []
|
||||
set({
|
||||
masterAlias: msg.master_alias ?? null,
|
||||
masterId: msg.master_id ?? null,
|
||||
localPeer: msg.local_peer ?? null,
|
||||
networks,
|
||||
connectedPeers: msg.connected_peers ?? [],
|
||||
activeNetworkId: networks[0]?.network_id ?? null,
|
||||
})
|
||||
break
|
||||
}
|
||||
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!,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'network_left': {
|
||||
set(s => ({
|
||||
networks: s.networks.filter(n => n.network_id !== msg.network_id),
|
||||
activeNetworkId: s.activeNetworkId === msg.network_id
|
||||
? (s.networks.find(n => n.network_id !== msg.network_id)?.network_id ?? null)
|
||||
: s.activeNetworkId,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'peer_connected': {
|
||||
if (msg.peer) {
|
||||
set(s => ({
|
||||
connectedPeers: s.connectedPeers.some(p => p.id === msg.peer!.id)
|
||||
? s.connectedPeers.map(p => p.id === msg.peer!.id ? { ...p, ...msg.peer } : p)
|
||||
: [...s.connectedPeers, msg.peer!],
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'session_ready': {
|
||||
if (msg.peer_id && msg.nick) {
|
||||
set(s => ({
|
||||
connectedPeers: s.connectedPeers.map(p =>
|
||||
p.id === msg.peer_id ? { ...p, alias: msg.nick! } : p
|
||||
),
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'peer_disconnected': {
|
||||
set(s => ({
|
||||
connectedPeers: s.connectedPeers.filter(p => p.id !== msg.peer_id),
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'message_received': {
|
||||
if (msg.message) {
|
||||
const m = msg.message
|
||||
const room = m.room
|
||||
const fromId = String(m.from)
|
||||
set(s => {
|
||||
const existing = s.messages[room] ?? []
|
||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||
const prev = s.peerStatus[fromId] ?? {}
|
||||
return {
|
||||
messages: { ...s.messages, [room]: [...existing, m] },
|
||||
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'peer_status': {
|
||||
const pid = String(msg.peer_id)
|
||||
set(s => {
|
||||
const prev = s.peerStatus[pid] ?? {}
|
||||
return {
|
||||
peerStatus: {
|
||||
...s.peerStatus,
|
||||
[pid]: {
|
||||
...prev,
|
||||
...(msg.conn_state ? { connState: msg.conn_state } : {}),
|
||||
...(msg.candidate_type ? { candidateType: msg.candidate_type } : {}),
|
||||
...(msg.remote_address !== undefined ? { remoteAddress: msg.remote_address } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'identity_exported': {
|
||||
if (msg.backup) set({ exportedBackup: msg.backup })
|
||||
break
|
||||
}
|
||||
case 'incoming_file': {
|
||||
if (msg.peer_id && msg.offer) {
|
||||
const { xid, name, size } = msg.offer
|
||||
set(s => ({ pendingOffers: { ...s.pendingOffers, [xid]: { peerId: String(msg.peer_id), name, size } } }))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'file_progress': {
|
||||
if (msg.transfer_id && msg.peer_id) {
|
||||
const xid = msg.transfer_id
|
||||
set(s => ({
|
||||
fileProgress: {
|
||||
...s.fileProgress,
|
||||
[xid]: {
|
||||
peerId: String(msg.peer_id),
|
||||
name: msg.offer?.name ?? xid,
|
||||
received: msg.bytes_received ?? 0,
|
||||
total: msg.total_bytes ?? 0,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'file_list': {
|
||||
if (msg.peer_id && msg.files) {
|
||||
set(s => ({
|
||||
fileLists: { ...s.fileLists, [msg.peer_id!]: msg.files! },
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'file_complete': {
|
||||
if (msg.path && msg.offer?.name) {
|
||||
// clear progress entry
|
||||
const xid = msg.offer.xid
|
||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
||||
const a = document.createElement('a')
|
||||
a.href = msg.path
|
||||
a.download = msg.offer.name
|
||||
a.click()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
127
web/src/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Types mirroring internal/proto — keep in sync with proto.go
|
||||
|
||||
export type PeerID = string // 64-char lowercase hex
|
||||
|
||||
export interface PeerInfo {
|
||||
id: PeerID
|
||||
alias: string
|
||||
public_key: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface NetworkInfo {
|
||||
network_id: string
|
||||
network_name: string
|
||||
local_peer?: PeerInfo
|
||||
share_dir?: string
|
||||
download_dir?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
mid?: string
|
||||
from: PeerID
|
||||
to?: PeerID
|
||||
room: string
|
||||
text: string
|
||||
ts: number // Unix ms
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string
|
||||
size_bytes: number
|
||||
path?: string // relative path including filename, e.g. "docs/report.pdf"
|
||||
}
|
||||
|
||||
export interface ShareEntry {
|
||||
path: string
|
||||
networks: string[] // ["*"] = global
|
||||
}
|
||||
|
||||
export interface FileOffer {
|
||||
xid: string
|
||||
name: string
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
// ── IPC message types ─────────────────────────────────────────────────────────
|
||||
|
||||
export type CandidateType = 'host' | 'srflx' | 'relay' | 'unknown'
|
||||
export type PeerConnState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'
|
||||
|
||||
export type IpcMsgType =
|
||||
// commands
|
||||
| 'send_message'
|
||||
| 'join_network'
|
||||
| 'leave_network'
|
||||
| 'get_state'
|
||||
| 'send_file'
|
||||
| 'set_share_dir'
|
||||
| 'generate_invite'
|
||||
| 'get_file_list'
|
||||
| 'export_identity'
|
||||
| 'import_identity'
|
||||
| 'add_share'
|
||||
| 'remove_share'
|
||||
| 'list_shares'
|
||||
// events
|
||||
| 'state_snapshot'
|
||||
| 'message_received'
|
||||
| 'peer_connected'
|
||||
| 'peer_disconnected'
|
||||
| 'session_ready'
|
||||
| 'network_joined'
|
||||
| 'network_left'
|
||||
| 'file_list'
|
||||
| 'file_progress'
|
||||
| 'file_complete'
|
||||
| 'incoming_file'
|
||||
| 'invite_generated'
|
||||
| 'identity_exported'
|
||||
| 'identity_imported'
|
||||
| 'shares_list'
|
||||
| 'peer_status'
|
||||
| 'error'
|
||||
|
||||
export interface IpcMessage {
|
||||
type: IpcMsgType
|
||||
// routing
|
||||
network_id?: string
|
||||
// send_message
|
||||
room?: string
|
||||
to?: PeerID
|
||||
body?: string
|
||||
// join_network
|
||||
network_name?: string
|
||||
network_hash?: string // 64-char hex (yaw2 `net` field) — alternative to network_name
|
||||
share_dir?: string
|
||||
// send_file / set_share_dir
|
||||
path?: string
|
||||
peer_id?: PeerID
|
||||
// identity
|
||||
passphrase?: string
|
||||
backup?: string
|
||||
// events
|
||||
peer?: PeerInfo
|
||||
nick?: string
|
||||
message?: ChatMessage
|
||||
offer?: FileOffer
|
||||
transfer_id?: string
|
||||
bytes_received?: number
|
||||
total_bytes?: number
|
||||
master_alias?: string
|
||||
master_id?: string
|
||||
local_peer?: PeerInfo
|
||||
connected_peers?: PeerInfo[]
|
||||
rooms?: string[]
|
||||
networks?: NetworkInfo[]
|
||||
error_message?: string
|
||||
invite?: string
|
||||
files?: FileEntry[]
|
||||
shares?: ShareEntry[]
|
||||
networks_filter?: string[] // for add_share
|
||||
// peer_status
|
||||
conn_state?: PeerConnState
|
||||
candidate_type?: CandidateType
|
||||
remote_address?: string
|
||||
}
|
||||
25
web/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
web/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
web/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5274,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
port: 5275,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||