Compare commits
85 Commits
1a5f416ee4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8aeb45480 | ||
|
|
586d39e3b8 | ||
|
|
b649f4d012 | ||
|
|
697a7e614d | ||
|
|
d07342e97e | ||
|
|
f425e0bb8e | ||
|
|
bf4009558d | ||
|
|
d09aa2b219 | ||
|
|
437deca6a0 | ||
|
|
851cfdc7e9 | ||
|
|
4a7a95fe9d | ||
|
|
32a6f46481 | ||
|
|
1d9c9d1524 | ||
|
|
f5fb0862ff | ||
|
|
dab5387cbd | ||
|
|
1d9827beb0 | ||
|
|
fcbd84f873 | ||
|
|
cef9374416 | ||
|
|
9ad3c96d43 | ||
|
|
48400440dd | ||
|
|
0e812a2479 | ||
|
|
f319721e01 | ||
|
|
9de625d617 | ||
|
|
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 |
9
.claude/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go build *)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
82
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
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
|
||||
cp build/bin/waste ../../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, '-') }}
|
||||
16
.gitignore
vendored
@@ -1,9 +1,13 @@
|
||||
/bin/
|
||||
setup-anchor.sh
|
||||
launch-tui.sh
|
||||
*.exe
|
||||
# compiled binary names that land in the repo root
|
||||
# compiled binaries that land in the repo root
|
||||
/anchor
|
||||
/waste-anchor
|
||||
/waste-daemon
|
||||
/waste
|
||||
/app
|
||||
/relay
|
||||
*.identity.json
|
||||
/tmp/
|
||||
@@ -11,3 +15,13 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
/tui
|
||||
launch-web.sh
|
||||
build-web.sh
|
||||
build-daemon.sh
|
||||
deploy-web.sh
|
||||
deploy-daemon.sh
|
||||
serve-web.sh
|
||||
push.sh
|
||||
web/public/config.js
|
||||
cmd/app/frontend/dist/*
|
||||
!cmd/app/frontend/dist/.gitkeep
|
||||
|
||||
487
EXTENSIONS.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 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 fetches short-lived
|
||||
credentials from the anchor's `GET /turn-credentials` endpoint (derived from
|
||||
`WASTE_CONFIG.signalURL`, or overridden via `WASTE_CONFIG.turnCredentialsURL`).
|
||||
The anchor computes the credential using HMAC-SHA1 of the username (coturn
|
||||
`use-auth-secret` scheme) — the shared secret itself is never sent to the
|
||||
browser. Daemon mode does the equivalent computation locally, since the
|
||||
daemon already holds `-turn-secret` server-side.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## EXT-008 — Message Reactions
|
||||
|
||||
### Wire message (`PeerMessage`)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "reaction",
|
||||
"reaction_mid": "<32-hex mid of the target message>",
|
||||
"reaction_emoji": "👍"
|
||||
}
|
||||
```
|
||||
|
||||
Sent on the normal mesh DataChannel (same as `chat`). No signing beyond
|
||||
the existing channel-level encryption.
|
||||
|
||||
### Semantics
|
||||
|
||||
- A reaction is idempotent: the same `(mid, emoji, from_peer)` triple is
|
||||
stored with `INSERT OR IGNORE` — receiving a duplicate is a no-op.
|
||||
- There is no "un-react" wire message. Toggling off a reaction in the UI
|
||||
is a local-only operation in the current implementation.
|
||||
- `reaction_mid` must reference a message that exists in the local store;
|
||||
unknown mids are silently ignored.
|
||||
|
||||
### Storage
|
||||
|
||||
SQLite table added as a migration:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
mid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
from_peer TEXT NOT NULL,
|
||||
reacted_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (mid, emoji, from_peer)
|
||||
)
|
||||
```
|
||||
|
||||
### IPC
|
||||
|
||||
**Command** — send a reaction (daemon and browser mode):
|
||||
```json
|
||||
{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
|
||||
```
|
||||
|
||||
**Event** — reaction received or replayed from history:
|
||||
```json
|
||||
{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
|
||||
```
|
||||
|
||||
Stored reactions are replayed as `reaction` IPC events when history is
|
||||
loaded (`sendStoredHistory`), so the UI always sees reactions alongside
|
||||
their messages.
|
||||
|
||||
### History replay
|
||||
|
||||
When `sendStoredHistory` sends a `history_chunk`, it also queries
|
||||
`ReactionsForRoom` and emits one `reaction` event per stored reaction so
|
||||
clients receive the full reaction state on reconnect.
|
||||
|
||||
### Browser mode
|
||||
|
||||
`browser.ts` mirrors the daemon behaviour independently:
|
||||
|
||||
- `PeerConn.sendReaction(mid, emoji)` broadcasts `{ type: "reaction", reaction_mid, reaction_emoji }` over the DataChannel.
|
||||
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
|
||||
- `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event.
|
||||
- `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers.
|
||||
|
||||
---
|
||||
|
||||
## EXT-009 — Scoped Presence Query
|
||||
|
||||
**Status:** proposed
|
||||
**Affects:** anchor WS protocol only (new request/response pair); no DataChannel or peer-to-peer wire changes
|
||||
|
||||
### Motivation
|
||||
|
||||
YAW/2 §5.2 presence (`peer-join` / `peer-leave`) is push-only and scoped to peers
|
||||
who are simultaneously joined to the same network — you only learn someone is
|
||||
online by already being in the room with them. That's fine for chat, but it's
|
||||
the wrong shape for flit's "known devices" list: each paired device is its own
|
||||
isolated network (`net = NetHash(PairRoomName(idA, idB))`), and the app doesn't
|
||||
hold a persistent anchor connection per pairing while idle. Today the only way
|
||||
to find out if a known device is reachable is to attempt a full connect.
|
||||
|
||||
This extension adds a lightweight, stateless query: "is peer X currently
|
||||
online in network Y?" — answerable from the anchor's existing in-memory
|
||||
`clients` registry with an O(1) lookup, no new server-side state.
|
||||
|
||||
**Deliberately not** a global "is this pubkey online anywhere" query. The
|
||||
anchor's `clients` map is keyed globally by peer id, so an unscoped query
|
||||
would let anyone who knows a pubkey probe its online status across every
|
||||
network on the anchor, with no relationship required — a cross-identity
|
||||
presence oracle. Scoping the query to `(net, id)` keeps the access model
|
||||
identical to today's: knowing a network's hash is already the bar for
|
||||
joining it and observing presence the slow way (§5.1 `joined`, §5.2
|
||||
`peer-join`); this extension only removes the need to actually join and wait.
|
||||
|
||||
### Wire messages
|
||||
|
||||
Sent over the anchor WS connection. Does **not** require a prior `join` —
|
||||
the query is anchor-local and stateless, so a client may ask before joining,
|
||||
after leaving, or without ever joining any network on this connection.
|
||||
|
||||
#### `presence_query`
|
||||
|
||||
```json
|
||||
{ "type": "presence_query", "net": "<64-hex net hash>", "id": "<64-hex peer id>" }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|--------------|
|
||||
| `net` | string | Network hash, computed the same way as `join` (§5.1). |
|
||||
| `id` | string | Peer id being queried. |
|
||||
|
||||
#### `presence`
|
||||
|
||||
```json
|
||||
{ "type": "presence", "net": "<64-hex net hash>", "id": "<64-hex peer id>", "online": true }
|
||||
```
|
||||
|
||||
`online` is `true` iff a client is currently registered with that exact
|
||||
`(net, id)` pair. Unknown `net`/`id` combinations return `online: false`,
|
||||
identical in shape to a peer who simply isn't connected — the anchor does
|
||||
not distinguish "never seen this net" from "peer not currently in it."
|
||||
|
||||
### Anchor implementation notes
|
||||
|
||||
No new registry. `a.clients` is already keyed globally by peer id
|
||||
(`cmd/anchor/main.go`); the query handler does:
|
||||
|
||||
```go
|
||||
a.mu.RLock()
|
||||
c, ok := a.clients[id]
|
||||
online := ok && c.net == net
|
||||
a.mu.RUnlock()
|
||||
```
|
||||
|
||||
Same lock, same map, no writes. YAW/2-only anchors that don't implement this
|
||||
extension simply never emit a `presence` reply — per §8, unknown request
|
||||
types are ignored by clients, so callers should treat "no reply within a
|
||||
short timeout" the same as `online: false` rather than blocking indefinitely.
|
||||
|
||||
### Security considerations
|
||||
|
||||
- **No new information beyond what §5.2 already permits** — the requester
|
||||
must already know both `net` and `id` to ask, exactly the knowledge
|
||||
required to join that network and observe presence natively. This
|
||||
extension is a latency/statefulness shortcut, not a new capability.
|
||||
- **Does not enable identity-wide presence enumeration.** Because the query
|
||||
is scoped to a specific `net`, checking whether pubkey X is online
|
||||
requires already knowing at least one network X is a member of. An anchor
|
||||
operator or a client cannot use this to ask "where is X online" across
|
||||
all networks it serves.
|
||||
- **Rate limiting recommended** at the anchor (e.g. per-connection token
|
||||
bucket) to prevent a connection from being used to hammer many `(net,
|
||||
id)` guesses cheaply. This mirrors the existing `history_request`
|
||||
rate-limit precedent (EXT-007, one request per peer/room per 60s) even
|
||||
though the abuse shape here is different — presence queries are free of
|
||||
disk I/O but still worth bounding.
|
||||
- **No change to what the anchor can already infer.** An anchor could
|
||||
already tell that two ids are members of the same net by virtue of
|
||||
relaying between them; this extension doesn't let the anchor learn new
|
||||
relationships, only lets *clients* ask a question the anchor already had
|
||||
the answer to.
|
||||
|
||||
### Client usage (flit)
|
||||
|
||||
Each known device already has a deterministic `net` (`PairRoomName(idA,
|
||||
idB)`, hashed). A "known devices" list can send one `presence_query` per
|
||||
entry — no persistent connection required per pairing — and render
|
||||
online/offline before the user taps Connect. A short client-side cache
|
||||
(a few seconds) is recommended to avoid re-querying on every render.
|
||||
189
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,86 +23,151 @@ 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 fetches a time-limited credential from the anchor's `GET /turn-credentials` endpoint (HMAC-SHA1, compatible with coturn `use-auth-secret`) rather than holding the shared secret client-side. 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
|
||||
> **Security fix:** earlier this previously embedded `turnSecret` directly in `WASTE_CONFIG`, which let anyone reading the PWA's JS mint unlimited long-lived TURN credentials. The secret now lives only on the anchor (`-turn-secret` flag); the anchor mints short-lived credentials per-request instead.
|
||||
|
||||
### 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
|
||||
**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.
|
||||
|
||||
### Multi-Network Support
|
||||
### 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.
|
||||
|
||||
A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person.
|
||||
### Multi-Network Support ✅ (shipped)
|
||||
One daemon, multiple simultaneously-joined networks. Per-network HKDF-derived identities prevent cross-network correlation. Network-scoped SQLite stores.
|
||||
|
||||
**Privacy constraint:** if the same Ed25519 keypair is used across networks, any peer who is a member of both networks can trivially correlate you. The anchor also sees the same public key across networks.
|
||||
### 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.
|
||||
|
||||
**Solution — per-network derived identities:**
|
||||
- One master Ed25519 seed in `identity.json`
|
||||
- Per-network keypair = `HKDF(masterSeed, "yaw2-net", networkHash)`
|
||||
- Same master + same network name = same derived keypair (stable identity within a network)
|
||||
- Different networks = different peer IDs; correlation is impossible without knowing both network names
|
||||
- The anchor sees only the derived public key
|
||||
### 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).
|
||||
|
||||
**Daemon changes:**
|
||||
- Replace the single `networkCancel` with a `map[networkID]*networkCtx`
|
||||
- Each context holds its own: derived identity, mesh, anchor connection, store (`messages-<netHash>.db`)
|
||||
- `join_network` returns a `network_id` token used to scope subsequent commands
|
||||
### 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.
|
||||
|
||||
**IPC changes (breaking):**
|
||||
- All commands and events gain a `network_id` field
|
||||
- `get_state` returns an array of all joined networks
|
||||
- `join_network` responds with `network_joined` carrying the derived peer ID for that network
|
||||
---
|
||||
|
||||
**TUI changes:**
|
||||
- Top-level network switcher (e.g. `[work] [friends]`)
|
||||
- Rooms and peers are scoped per network underneath
|
||||
## Remaining Work
|
||||
|
||||
### 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.
|
||||
### 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
|
||||
|
||||
On daemon start, the download directory is scanned for `.tmp.meta` sidecars and a `resumable_transfers` IPC event is emitted so the UI can show pending transfers with a progress bar.
|
||||
|
||||
### 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`. Built in `cmd/app/` via `./build-app.sh`. System tray (Linux/Windows) and OS notifications are implemented. macOS menu-bar tray requires Cocoa main-thread integration — currently a stub.
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
| ✅ shipped | File transfer resume UX — resumable transfers surfaced in Transfers panel on reconnect |
|
||||
| ✅ shipped | P2P message history gossip (EXT-007) — new peers receive recent history from first connected peer |
|
||||
| ✅ shipped | Date-aware timestamps in TUI and web UI |
|
||||
| ✅ shipped | Historical peer alias resolution in web UI |
|
||||
| ✅ shipped | Message reactions (emoji picker, full-stack: wire protocol, SQLite, IPC, web UI, TUI) |
|
||||
| ✅ shipped | Link rendering + image preview in web UI messages |
|
||||
| ✅ shipped | Responsive mobile layout (slide-over sidebar, hamburger button) |
|
||||
| ✅ shipped | TUI multi-network (join/switch networks at runtime, `ctrl+n`, `/join`, `/net`) |
|
||||
| ✅ shipped | TUI message reactions (`/react <emoji>` or `/react <n> <emoji>`) |
|
||||
| 🔜 planned | Push notifications (PWA Web Push + service worker) |
|
||||
|
||||
### Message Reactions ✅ (shipped)
|
||||
Full-stack emoji reactions. The web UI shows a `+` button on hover that opens a six-emoji picker (👍 ❤️ 😂 😮 😢 🙏). Reactions render as chips below each message; clicking an existing chip toggles your own reaction. Reactions are stored in SQLite (`reactions` table), gossiped over the mesh as `reaction` wire messages (EXT-008), and replayed via IPC on reconnect.
|
||||
|
||||
Browser mode: `browser.ts` independently mirrors the daemon — reactions flow over the DataChannel and are broadcast to all connected peers.
|
||||
|
||||
TUI: messages show `[n]` line numbers. Use `/react <emoji>` (reacts to last message) or `/react <n> <emoji>` (reacts to message `n`). Reactions render inline below the target message.
|
||||
|
||||
### Link Rendering + Image Preview ✅ (shipped)
|
||||
URLs in messages are auto-linked. URLs ending in a recognized image extension (`.jpg`, `.png`, `.gif`, `.webp`, `.svg`) render an inline `<img>` preview (max 320×200px). `blob:` and `data:image` URLs are also treated as images.
|
||||
|
||||
### Responsive Mobile Layout ✅ (shipped)
|
||||
At viewport width ≤ 600px the sidebar becomes a fixed-position slide-over drawer, hidden off-screen by default (`transform: translateX(-100%)`). A `☰` hamburger button in the message pane header toggles it open. Clicking any room or network in the sidebar closes it automatically. The layout is dimmed while the sidebar is open via a `::before` overlay.
|
||||
|
||||
### Push Notifications (planned)
|
||||
The web UI is already a PWA (installable, has manifest). The missing half is a service worker + Web Push subscription:
|
||||
|
||||
1. **Service worker** — intercepts `push` events and shows OS notifications via `showNotification()`.
|
||||
2. **VAPID key pair** — generated once by the daemon (`-vapid-key` flag); the public key is served to the browser so it can subscribe.
|
||||
3. **Subscription persistence** — the browser's `PushSubscription` JSON is sent to the daemon over IPC (`register_push` command). The daemon stores it per-network-per-peer.
|
||||
4. **Daemon relay** — when a `message_received` event fires with no active IPC WebSocket connection, the daemon POSTs a Web Push notification to the stored subscription endpoint.
|
||||
|
||||
This keeps the architecture clean: the daemon already runs in the background; it becomes the notification relay. No third-party push server is required for self-hosted setups (coturn already in use for TURN; a lightweight Web Push POST is similar).
|
||||
|
||||
---
|
||||
|
||||
@@ -111,5 +176,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
|
||||
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 explewd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
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.
|
||||
109
QUICKSTART.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 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 + TUI or web UI locally (power users / dev)
|
||||
|
||||
Example scripts are provided for the common local workflows. Copy them and fill in your anchor URL:
|
||||
|
||||
```bash
|
||||
# TUI (terminal UI):
|
||||
cp launch-tui.sh.example launch-tui.sh
|
||||
$EDITOR launch-tui.sh # set ANCHOR=wss://your-anchor/ws
|
||||
./launch-tui.sh
|
||||
|
||||
# Web UI in daemon mode (Vite dev server + daemon):
|
||||
cp launch-web.sh.example launch-web.sh
|
||||
$EDITOR launch-web.sh # set ANCHOR=wss://your-anchor/ws
|
||||
./launch-web.sh
|
||||
```
|
||||
|
||||
The real script files are gitignored so your local edits (anchor URL, alias, network) are never accidentally committed.
|
||||
|
||||
---
|
||||
|
||||
## Option 4 — Run the daemon manually (headless)
|
||||
|
||||
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_DOMAIN/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 -turn-secret YOUR_COTURN_SECRET
|
||||
```
|
||||
|
||||
Put it behind nginx with `/ws` and `/turn-credentials` proxied to the anchor, and the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.
|
||||
638
README.md
@@ -9,7 +9,7 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
|
||||
waste-go/
|
||||
├── cmd/
|
||||
│ ├── daemon/ The peer process — run one on each friend's machine
|
||||
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS
|
||||
│ ├── anchor/ WebSocket signaling server — run this on your VPS
|
||||
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
|
||||
└── internal/
|
||||
├── proto/ All wire types (shared by daemon and anchor)
|
||||
@@ -19,204 +19,510 @@ waste-go/
|
||||
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
- Go 1.24+ → https://go.dev/dl/
|
||||
- VS Code with the Go extension (`golang.go`)
|
||||
## Prebuilt binaries
|
||||
|
||||
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them.
|
||||
Every tagged release publishes cross-compiled binaries — no Go toolchain required. Grab them from the repo's **Releases** page:
|
||||
|
||||
## Getting started
|
||||
```
|
||||
https://repo.explewd.com/explewd/waste-go/releases
|
||||
```
|
||||
|
||||
| File | What it is | Run it on |
|
||||
|---|---|---|
|
||||
| `waste-daemon-<os>-<arch>` | The peer process — your identity, mesh connections, file shares | Each friend's own machine |
|
||||
| `waste-anchor-<os>-<arch>` | The signaling relay (no message content ever passes through it) | One server you control (VPS) |
|
||||
|
||||
`<os>` is `linux`, `darwin` (macOS), or `windows`; `<arch>` is `amd64` or `arm64` (Windows builds are amd64 only). Windows binaries have a `.exe` suffix.
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
# Fetch dependencies
|
||||
go mod tidy
|
||||
curl -LO https://repo.explewd.com/explewd/waste-go/releases/download/<tag>/waste-daemon-linux-amd64
|
||||
chmod +x waste-daemon-linux-amd64
|
||||
./waste-daemon-linux-amd64 -alias yourname -data-dir ~/.waste --join 'waste:eyJ...'
|
||||
```
|
||||
|
||||
# Build everything (confirms it compiles)
|
||||
go build ./...
|
||||
> **macOS Gatekeeper:** unsigned binaries downloaded from a browser get a quarantine flag and macOS will refuse to run them ("cannot be opened because the developer cannot be verified"). Clear it once after downloading: `xattr -d com.apple.quarantine waste-daemon-darwin-arm64` (or right-click → Open the first time, which prompts for an override).
|
||||
|
||||
# Terminal 1 — anchor (required for peers to find each other)
|
||||
**Windows:** download the `.exe`, then run it from PowerShell or `cmd.exe`:
|
||||
|
||||
```powershell
|
||||
.\waste-daemon-windows-amd64.exe -alias yourname -data-dir C:\waste --join "waste:eyJ..."
|
||||
```
|
||||
|
||||
SmartScreen may warn about an unrecognized publisher on first run — these binaries aren't code-signed. Click "More info" → "Run anyway".
|
||||
|
||||
Once the daemon is running, drive it with the [TUI](#terminal-ui) (`./cmd/tui` built locally, or the web UI in [daemon mode](#daemon-mode-for-users-running-the-daemon-locally)) — the daemon itself has no UI of its own, it just exposes the local IPC API on port 17337.
|
||||
|
||||
The `waste-anchor` binary is for whoever is hosting a network — see [Hosting on a VPS](#hosting-on-a-vps) below for the full anchor + web UI setup. For a quick local anchor (e.g. testing on a LAN), just run:
|
||||
|
||||
```bash
|
||||
./waste-anchor-linux-amd64 -bind 0.0.0.0:8080
|
||||
```
|
||||
|
||||
> **No desktop app build in the current release.** A native Wails desktop app (`cmd/app/`) exists in the source tree, but the CI step that packages it for releases was broken (wrong output path) until this fix — earlier tagged releases only have daemon/anchor binaries. The next tag will include `waste-linux-amd64`. Until then, build it yourself with `./build-app.sh` (see [Desktop app (Wails)](#desktop-app-wails) below).
|
||||
|
||||
---
|
||||
|
||||
## Hosting on a VPS
|
||||
|
||||
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.
|
||||
|
||||
### 1. Build and run the anchor
|
||||
|
||||
```bash
|
||||
# On your local machine — cross-compile for Linux
|
||||
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
|
||||
|
||||
# Copy to VPS
|
||||
scp bin/waste-anchor user@your-vps:~/waste-anchor
|
||||
```
|
||||
|
||||
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 these locations:
|
||||
|
||||
**Location 1 — WebSocket signaling (`/ws`)**
|
||||
- Location: `/ws`
|
||||
- Forward hostname/IP: `127.0.0.1`
|
||||
- Forward port: `8080`
|
||||
- Enable: WebSockets Support
|
||||
|
||||
**Location 1b — TURN credentials (`/turn-credentials`, only if using TURN — see [step 4](#4-turn-relay-optional-fixes-mobile--cgnat))**
|
||||
- Location: `/turn-credentials`
|
||||
- Forward hostname/IP: `127.0.0.1`
|
||||
- Forward port: `8080`
|
||||
- Plain HTTP, no WebSockets toggle needed
|
||||
|
||||
**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)
|
||||
- `/turn-credentials` → anchor process (plain HTTP; only needed if using TURN)
|
||||
- `/*` → 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
|
||||
```
|
||||
|
||||
**Start the anchor with the same secret**, so it can mint short-lived credentials on your behalf:
|
||||
|
||||
```bash
|
||||
./waste-anchor -bind 0.0.0.0:8080 -turn-secret YOUR_SECRET_HERE
|
||||
```
|
||||
|
||||
This enables `GET /turn-credentials` on the anchor, which returns a fresh `{username, credential}` pair (1-hour TTL) computed from the shared secret — the secret itself never leaves the server.
|
||||
|
||||
**Update `config.js`** to tell browsers about the TURN server (no secret here — only the public relay address):
|
||||
|
||||
```js
|
||||
window.WASTE_CONFIG = {
|
||||
signalURL: 'wss://your-domain.com/ws',
|
||||
turnURL: 'turn:your-domain.com:3478',
|
||||
}
|
||||
```
|
||||
|
||||
> **Security note:** earlier versions of this doc had you put `turnSecret` directly in `config.js`. Don't — anyone reading the PWA's JS bundle could read it and mint unlimited, long-lived TURN credentials, turning your relay into an open proxy for anyone. The browser now calls the anchor's `/turn-credentials` endpoint instead and only ever sees a credential that expires in an hour. If you have an old `config.js` with `turnSecret` set, remove it and rotate the coturn secret (`static-auth-secret` in `turnserver.conf` and the anchor's `-turn-secret` flag) since the old one was exposed.
|
||||
|
||||
The browser adapter calls `signalURL` with `/ws` swapped for `/turn-credentials` to find the anchor's endpoint by default; set `turnCredentialsURL` explicitly in `WASTE_CONFIG` if the anchor is reachable at a different path. If `turnURL` is set but the credentials endpoint is unreachable, the browser falls back to STUN-only.
|
||||
|
||||
You'll also need nginx to route the new path to the anchor, alongside `/ws` (see [step 3](#3-nginx-proxy-manager-setup)):
|
||||
|
||||
- `/turn-credentials` → anchor process (plain HTTP, no WebSocket upgrade needed)
|
||||
|
||||
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. This is unaffected by the above — the daemon computes credentials itself server-side and never exposes the secret, same as the anchor now does 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
|
||||
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
|
||||
|
||||
# Terminal 3 — peer B (or use --join with an invite from peer A)
|
||||
# Terminal 3 — peer B
|
||||
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
|
||||
```
|
||||
|
||||
Both peers join the same named network via IPC:
|
||||
Join both to a network:
|
||||
|
||||
```bash
|
||||
# Join peer A to a network called "friends"
|
||||
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
|
||||
|
||||
# Join peer B to the same network
|
||||
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
|
||||
|
||||
# Subscribe to peer A's events (in a separate terminal)
|
||||
nc 127.0.0.1 17337 &
|
||||
|
||||
# Send a message from B
|
||||
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
|
||||
```
|
||||
|
||||
**On Windows** — use PowerShell's built-in TCP client instead of `nc`:
|
||||
|
||||
```powershell
|
||||
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
|
||||
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
|
||||
|
||||
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
|
||||
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
|
||||
|
||||
# In a separate terminal — subscribe to peer A's events
|
||||
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
|
||||
$reader = [System.IO.StreamReader]::new($r.GetStream())
|
||||
while ($true) { $reader.ReadLine() }
|
||||
```
|
||||
|
||||
## Deploying the anchor on your Hetzner VPS
|
||||
### Web UI (daemon mode)
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
|
||||
scp bin/waste-anchor user@your-vps:~/
|
||||
# Requires a running daemon on port 17337
|
||||
./launch-web.sh
|
||||
|
||||
# On the VPS (also run coturn in STUN-only mode on port 3478)
|
||||
./waste-anchor -bind 0.0.0.0:17339
|
||||
# Or with a custom alias and network:
|
||||
ALIAS=alice NETWORK=friends ./launch-web.sh
|
||||
```
|
||||
|
||||
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC
|
||||
with ICE (STUN-assisted hole punching) through the anchor for signaling.
|
||||
|
||||
## IPC protocol (plain JSON over TCP)
|
||||
|
||||
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
|
||||
**Commands you send:**
|
||||
```jsonc
|
||||
{"type":"join_network","network_name":"friends"}
|
||||
{"type":"leave_network"}
|
||||
{"type":"send_message","room":"general","body":"hi"}
|
||||
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
|
||||
{"type":"get_state"}
|
||||
{"type":"generate_invite"}
|
||||
```
|
||||
|
||||
**Events the daemon pushes:**
|
||||
```jsonc
|
||||
// Sent immediately on connect and in response to get_state
|
||||
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
|
||||
|
||||
// Peer lifecycle
|
||||
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
|
||||
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
||||
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
||||
|
||||
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
|
||||
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","to":"<64-hex>","room":"general","body":"hi","sent_at":"..."}}
|
||||
|
||||
// Invite generation response
|
||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||
|
||||
// Error
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
## Crypto choices
|
||||
|
||||
| Purpose | Algorithm | Notes |
|
||||
|---|---|---|
|
||||
| Identity | Ed25519 | Fast, small keys, standard |
|
||||
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
|
||||
| Signaling encryption | 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 |
|
||||
|
||||
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
|
||||
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
|
||||
> on disk are unaffected — only the over-the-wire representation changed from base64url.
|
||||
|
||||
## Onboarding a new peer
|
||||
|
||||
Alice is already on the network and wants to add Bob.
|
||||
|
||||
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
|
||||
```bash
|
||||
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
|
||||
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
|
||||
```
|
||||
|
||||
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
|
||||
```bash
|
||||
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
|
||||
```
|
||||
|
||||
**Bob opens the TUI** — `--join` also accepts the invite to skip the `-network` flag:
|
||||
```bash
|
||||
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
|
||||
```
|
||||
|
||||
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
|
||||
|
||||
## Terminal UI
|
||||
|
||||
Start the daemon first (see Getting started above), then:
|
||||
|
||||
```bash
|
||||
go run ./cmd/tui -network friends
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `-network` | *(required unless -join)* | Network name to join on startup |
|
||||
| `-join` | — | `waste:` invite string — sets the network name automatically |
|
||||
| `-ipc` | `17337` | Daemon IPC port |
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
|
||||
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
|
||||
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
|
||||
│ ││ 15:05 charlie the mesh works ││ ● charlie │
|
||||
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
|
||||
╭─────────────────────────────────────────────────────────────╮
|
||||
│ Type a message… │
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
|
||||
|
||||
## Testing
|
||||
|
||||
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
|
||||
### Automated test
|
||||
|
||||
```bash
|
||||
./test-network.sh
|
||||
```
|
||||
|
||||
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run:
|
||||
Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding a new peer
|
||||
|
||||
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
|
||||
|
||||
```bash
|
||||
sqlite3 /tmp/waste-test/alice/messages.db
|
||||
.headers on
|
||||
SELECT room, from_peer, body, sent_at FROM messages;
|
||||
SELECT peer_id, alias, last_seen FROM peers;
|
||||
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
|
||||
# → {"type":"invite_generated","invite":"waste:eyJ..."}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
Bob joins using the invite:
|
||||
|
||||
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation
|
||||
- [x] **Proto additions** — `mid` dedup field, signaling types, anchor wire types, `hello` message
|
||||
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server replacing TCP relay
|
||||
- [x] **WebRTC peer connections** — pion/webrtc DataChannels; ICE hole-punching via STUN
|
||||
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing
|
||||
- [x] **IPC updates** — `join_network`/`leave_network`; `session_ready` event; DMs via `to` field
|
||||
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache
|
||||
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs
|
||||
- [ ] **File transfer** — chunked binary DataChannel (`f:<xid>`)
|
||||
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
|
||||
```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 · `Ctrl+N` — cycle networks · `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)
|
||||
- `/join <name>` — join a new network at runtime (the `-network` flag is optional; start idle and `/join` to connect)
|
||||
- `/net <n|name>` — switch active network by index or name
|
||||
- `/react <emoji>` — react to the last message (use actual emoji characters, e.g. `/react 👍`)
|
||||
- `/react <n> <emoji>` — react to message `[n]` (line numbers shown next to each message)
|
||||
|
||||
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":"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":"send_reaction","network_id":"...","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
|
||||
{"type":"export_identity","passphrase":"..."}
|
||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||
```
|
||||
|
||||
**Events:**
|
||||
```jsonc
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","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":"reaction","network_id":"...","peer_id":"<64-hex>","reaction_mid":"<32-hex>","reaction_emoji":"👍"}
|
||||
{"type":"identity_exported","backup":"..."}
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Crypto
|
||||
|
||||
| Purpose | Algorithm |
|
||||
|---|---|
|
||||
| Identity | Ed25519 |
|
||||
| Signaling (2.0) | XSalsa20-Poly1305, X25519 keys derived from Ed25519 |
|
||||
| Signaling (2.1) | XSalsa20-Poly1305, ephemeral X25519 per session (forward secrecy) |
|
||||
| Transport | WebRTC DataChannels (DTLS+SCTP via pion/webrtc) |
|
||||
| File integrity | SHA-256 |
|
||||
|
||||
### Forward-secret signaling (YAW/2.1)
|
||||
|
||||
Each peer generates a fresh X25519 keypair per session and broadcasts the public half in a signed `ekey` message before sending an offer. The `esk` is zeroed when the session ends. A 2.0 peer ignores `ekey` and the offerer falls back to static-key sealing after 2 s — so 2.1↔2.0 sessions work, just without forward secrecy.
|
||||
|
||||
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
|
||||
@@ -6,12 +6,17 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -23,16 +28,40 @@ import (
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
|
||||
turnSecret := flag.String("turn-secret", "", "coturn use-auth-secret shared secret; enables GET /turn-credentials")
|
||||
flag.Parse()
|
||||
|
||||
a := newAnchor()
|
||||
http.HandleFunc("/ws", a.handleWS)
|
||||
if *turnSecret != "" {
|
||||
http.HandleFunc("/turn-credentials", turnCredentialsHandler(*turnSecret))
|
||||
log.Printf("anchor: /turn-credentials enabled")
|
||||
}
|
||||
log.Printf("anchor: listening on %s", *bind)
|
||||
if err := http.ListenAndServe(*bind, nil); err != nil {
|
||||
log.Fatalf("anchor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// turnCredentialsHandler mints short-lived coturn use-auth-secret credentials
|
||||
// server-side, so the shared secret never reaches the browser. Mirrors the
|
||||
// scheme in internal/netmgr.Manager.turnICEServers (daemon mode).
|
||||
func turnCredentialsHandler(secret string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(expiry))
|
||||
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Username string `json:"username"`
|
||||
Credential string `json:"credential"`
|
||||
TTL int `json:"ttl"`
|
||||
}{Username: expiry, Credential: credential, TTL: 3600})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Anchor ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type client struct {
|
||||
@@ -40,8 +69,30 @@ type client struct {
|
||||
net string // hashed network name, set after join
|
||||
send chan proto.AnchorMessage
|
||||
conn *websocket.Conn
|
||||
|
||||
// EXT-009 presence_query rate limiting. Only ever touched from this
|
||||
// connection's own read-loop goroutine, so no lock needed.
|
||||
presenceQueryCount int
|
||||
presenceWindowStart time.Time
|
||||
}
|
||||
|
||||
// allowPresenceQuery implements a simple per-connection token bucket for
|
||||
// EXT-009 presence_query: presenceQueryLimit requests per presenceQueryWindow.
|
||||
func (c *client) allowPresenceQuery() bool {
|
||||
now := time.Now()
|
||||
if now.Sub(c.presenceWindowStart) > presenceQueryWindow {
|
||||
c.presenceWindowStart = now
|
||||
c.presenceQueryCount = 0
|
||||
}
|
||||
c.presenceQueryCount++
|
||||
return c.presenceQueryCount <= presenceQueryLimit
|
||||
}
|
||||
|
||||
const (
|
||||
presenceQueryLimit = 20
|
||||
presenceQueryWindow = 10 * time.Second
|
||||
)
|
||||
|
||||
type anchor struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*client // keyed by hex peer id
|
||||
@@ -80,6 +131,16 @@ func (a *anchor) unregister(c *client) {
|
||||
log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))])
|
||||
}
|
||||
|
||||
// isOnline reports whether a client is currently registered with the exact
|
||||
// (net, id) pair — EXT-009. O(1): reuses the existing global clients map,
|
||||
// no new state.
|
||||
func (a *anchor) isOnline(net, id string) bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
c, ok := a.clients[id]
|
||||
return ok && c.net == net
|
||||
}
|
||||
|
||||
// networkPeerIDs returns the hex ids of all peers in the same network as netHash.
|
||||
func (a *anchor) networkPeerIDs(netHash, excludeID string) []string {
|
||||
a.mu.RLock()
|
||||
@@ -126,8 +187,8 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
// Send a challenge nonce immediately.
|
||||
nonce := make([]byte, 16)
|
||||
// Send a challenge nonce immediately. §5.1 requires 32 bytes.
|
||||
nonce := make([]byte, 32)
|
||||
rand.Read(nonce)
|
||||
nonceHex := hex.EncodeToString(nonce)
|
||||
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
|
||||
@@ -170,9 +231,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
|
||||
@@ -197,6 +257,24 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers))
|
||||
|
||||
case proto.AnchorPresenceQuery:
|
||||
// EXT-009. Stateless and anchor-local: does not require the
|
||||
// querying connection to have joined any network. Scoped to
|
||||
// (net, id) — never a global "is this pubkey online anywhere"
|
||||
// lookup, to avoid a cross-network presence oracle.
|
||||
if msg.Net == "" || msg.ID == "" {
|
||||
continue
|
||||
}
|
||||
if !c.allowPresenceQuery() {
|
||||
continue
|
||||
}
|
||||
online := a.isOnline(msg.Net, msg.ID)
|
||||
resp := proto.AnchorMessage{Type: proto.AnchorPresence, Net: msg.Net, ID: msg.ID, Online: &online}
|
||||
select {
|
||||
case c.send <- resp:
|
||||
default:
|
||||
}
|
||||
|
||||
case proto.AnchorTo:
|
||||
if c.id == "" {
|
||||
continue // not joined yet
|
||||
|
||||
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,29 +3,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/store"
|
||||
"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")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
// --join overrides/sets the anchor URL and triggers an auto-join.
|
||||
var autoJoinNetwork string
|
||||
if *joinInvite != "" {
|
||||
@@ -38,41 +72,31 @@ func main() {
|
||||
log.Printf("daemon: invite decoded — anchor=%s network=%s", inv.Anchor, inv.Network)
|
||||
}
|
||||
|
||||
dir := expandHome(*dataDir)
|
||||
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)
|
||||
mgr := netmgr.New(netmgr.Config{
|
||||
MasterIdentity: id,
|
||||
StoreDir: dir,
|
||||
AnchorURL: *anchorURL,
|
||||
ShareDir: expandHome(*shareDir),
|
||||
DownloadDir: expandHome(*downloadDir),
|
||||
TurnURL: *turnURL,
|
||||
TurnSecret: *turnSecret,
|
||||
})
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "messages.db"))
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
m := mesh.New(id, st)
|
||||
|
||||
// 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
|
||||
}
|
||||
log.Printf("daemon: joining network %q via %s", networkName, *anchorURL)
|
||||
anchor.Run(ctx, *anchorURL, networkName, id, m)
|
||||
log.Printf("daemon: left network %q", networkName)
|
||||
}
|
||||
|
||||
// Auto-join from --join flag before starting IPC (non-blocking).
|
||||
if autoJoinNetwork != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_ = cancel // lifecycle managed by the joinFn / ipc.Run leave
|
||||
go joinFn(ctx, autoJoinNetwork)
|
||||
if _, err := mgr.Join(autoJoinNetwork, ""); err != nil {
|
||||
log.Fatalf("auto-join: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ipc.Run(m, *ipcPort, *anchorURL, joinFn); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -85,4 +109,3 @@ func expandHome(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
711
cmd/tui/main.go
@@ -1,6 +1,7 @@
|
||||
// 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).
|
||||
// It connects to a running daemon's IPC port and renders a three-pane layout:
|
||||
// rooms/networks (left), messages with line numbers (centre), peers (right).
|
||||
// Multiple networks are supported at runtime via /join; switch with ctrl+n.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,21 +27,25 @@ import (
|
||||
// ── 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)
|
||||
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"))
|
||||
styleNet = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
||||
styleNetActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("51"))
|
||||
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"))
|
||||
styleLineNum = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
styleReaction = lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
|
||||
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)
|
||||
const sideW = 22
|
||||
|
||||
// ── tea messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +57,6 @@ 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
|
||||
}
|
||||
@@ -84,7 +87,42 @@ func (lr *lineReader) next() tea.Cmd {
|
||||
|
||||
// ── model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// netData holds per-network state.
|
||||
type netData struct {
|
||||
id string
|
||||
name string
|
||||
localID proto.PeerID
|
||||
localAlias string
|
||||
rooms []string
|
||||
activeRoom int
|
||||
peers map[proto.PeerID]string
|
||||
peerOrder []proto.PeerID
|
||||
knownPeers map[proto.PeerID]string // historical peers (from store)
|
||||
}
|
||||
|
||||
func newNetData(id, name string) *netData {
|
||||
return &netData{
|
||||
id: id,
|
||||
name: name,
|
||||
rooms: []string{"general"},
|
||||
peers: make(map[proto.PeerID]string),
|
||||
knownPeers: make(map[proto.PeerID]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *netData) addRoom(room string) bool {
|
||||
for _, r := range n.rooms {
|
||||
if r == room {
|
||||
return false
|
||||
}
|
||||
}
|
||||
n.rooms = append(n.rooms, room)
|
||||
return true
|
||||
}
|
||||
|
||||
// entry is a single chat message in the viewport.
|
||||
type entry struct {
|
||||
mid string
|
||||
from string
|
||||
body string
|
||||
at time.Time
|
||||
@@ -92,8 +130,8 @@ type entry struct {
|
||||
}
|
||||
|
||||
type model struct {
|
||||
ipcPort int
|
||||
networkName string
|
||||
ipcPort int
|
||||
initialNetwork string // from -network flag; joined on first connect
|
||||
|
||||
width, height int
|
||||
|
||||
@@ -101,15 +139,15 @@ type model struct {
|
||||
enc *json.Encoder
|
||||
reader *lineReader
|
||||
|
||||
localID proto.PeerID
|
||||
localAlias string
|
||||
nets []*netData
|
||||
activeNet int
|
||||
|
||||
rooms []string // "general" always first; DM rooms appended
|
||||
activeRoom int
|
||||
messages map[string][]entry
|
||||
// keyed by "netId:room"
|
||||
messages map[string][]entry
|
||||
unread map[string]bool
|
||||
|
||||
peers map[proto.PeerID]string // connected peers: id → alias
|
||||
peerOrder []proto.PeerID
|
||||
// mid → emoji → []alias
|
||||
reactions map[string]map[string][]string
|
||||
|
||||
input textinput.Model
|
||||
viewport viewport.Model
|
||||
@@ -117,26 +155,89 @@ type model struct {
|
||||
|
||||
status string
|
||||
errMsg string
|
||||
invitePopup string // non-empty = show invite overlay
|
||||
invitePopup string
|
||||
}
|
||||
|
||||
func newModel(ipcPort int, network string) model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Type a message…"
|
||||
ti.Placeholder = "Type a message, or /join /net /room /react…"
|
||||
ti.Focus()
|
||||
ti.CharLimit = 2000
|
||||
|
||||
return model{
|
||||
ipcPort: ipcPort,
|
||||
networkName: network,
|
||||
rooms: []string{"general"},
|
||||
messages: make(map[string][]entry),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
ipcPort: ipcPort,
|
||||
initialNetwork: network,
|
||||
messages: make(map[string][]entry),
|
||||
unread: make(map[string]bool),
|
||||
reactions: make(map[string]map[string][]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
}
|
||||
}
|
||||
|
||||
// ── accessors ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) activeNetData() *netData {
|
||||
if m.activeNet < len(m.nets) {
|
||||
return m.nets[m.activeNet]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) activeNetworkID() string {
|
||||
if n := m.activeNetData(); n != nil {
|
||||
return n.id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) activeRoomName() string {
|
||||
n := m.activeNetData()
|
||||
if n == nil {
|
||||
return "general"
|
||||
}
|
||||
if n.activeRoom < len(n.rooms) {
|
||||
return n.rooms[n.activeRoom]
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
func (m model) msgKey() string {
|
||||
n := m.activeNetData()
|
||||
if n == nil {
|
||||
return ":general"
|
||||
}
|
||||
return n.id + ":" + n.rooms[n.activeRoom]
|
||||
}
|
||||
|
||||
func (m model) netByID(id string) *netData {
|
||||
for _, n := range m.nets {
|
||||
if n.id == id {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) aliasOf(netID string, id proto.PeerID) string {
|
||||
n := m.netByID(netID)
|
||||
if n == nil {
|
||||
return shortID(id)
|
||||
}
|
||||
if id == n.localID && n.localAlias != "" {
|
||||
return n.localAlias
|
||||
}
|
||||
if a, ok := n.peers[id]; ok && a != "" {
|
||||
return a
|
||||
}
|
||||
if a, ok := n.knownPeers[id]; ok && a != "" {
|
||||
return a
|
||||
}
|
||||
return shortID(id)
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(connectCmd(m.ipcPort), textinput.Blink)
|
||||
}
|
||||
@@ -160,7 +261,7 @@ func sendIPC(enc *json.Encoder, msg proto.IpcMessage) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// ── update ────────────────────────────────────────────────────────────────────
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
@@ -179,12 +280,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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(),
|
||||
)
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGetState}), m.reader.next())
|
||||
if m.initialNetwork != "" {
|
||||
m.status = "joining " + m.initialNetwork + "…"
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: m.initialNetwork}))
|
||||
} else {
|
||||
m.status = "connected — /join <network> to start"
|
||||
}
|
||||
|
||||
case ipcLineMsg:
|
||||
var evt proto.IpcMessage
|
||||
@@ -209,16 +311,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
case msg.String() == "ctrl+i":
|
||||
if m.enc != nil {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite}))
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
|
||||
Type: proto.CmdGenerateInvite,
|
||||
NetworkID: m.activeNetworkID(),
|
||||
}))
|
||||
}
|
||||
case msg.String() == "ctrl+n":
|
||||
if len(m.nets) > 1 {
|
||||
m.activeNet = (m.activeNet + 1) % len(m.nets)
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
case msg.Type == tea.KeyEnter:
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||
m = m.refreshViewport()
|
||||
if n := m.activeNetData(); n != nil {
|
||||
n.activeRoom = (n.activeRoom + 1) % len(n.rooms)
|
||||
delete(m.unread, m.msgKey())
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
case msg.Type == tea.KeyShiftTab:
|
||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||
m = m.refreshViewport()
|
||||
if n := m.activeNetData(); n != nil {
|
||||
n.activeRoom = (n.activeRoom - 1 + len(n.rooms)) % len(n.rooms)
|
||||
delete(m.unread, m.msgKey())
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
default:
|
||||
var tiCmd tea.Cmd
|
||||
m.input, tiCmd = m.input.Update(msg)
|
||||
@@ -226,7 +342,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
default:
|
||||
// Let viewport handle scroll events.
|
||||
if m.vpReady {
|
||||
var vpCmd tea.Cmd
|
||||
m.viewport, vpCmd = m.viewport.Update(msg)
|
||||
@@ -237,71 +352,219 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// ── applyEvent ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
switch evt.Type {
|
||||
|
||||
case proto.EvtStateSnapshot:
|
||||
// Populate nets from snapshot.
|
||||
for _, ni := range evt.Networks {
|
||||
n := m.netByID(ni.NetworkID)
|
||||
if n == nil {
|
||||
n = newNetData(ni.NetworkID, ni.NetworkName)
|
||||
m.nets = append(m.nets, n)
|
||||
}
|
||||
if ni.LocalPeer != nil {
|
||||
n.localID = ni.LocalPeer.ID
|
||||
n.localAlias = ni.LocalPeer.Alias
|
||||
}
|
||||
}
|
||||
// Backward-compat: connected_peers and rooms are from first network.
|
||||
if len(evt.Networks) > 0 && len(m.nets) > 0 {
|
||||
n := m.nets[0]
|
||||
for _, p := range evt.ConnectedPeers {
|
||||
if _, ok := n.peers[p.ID]; !ok {
|
||||
n.peerOrder = append(n.peerOrder, p.ID)
|
||||
}
|
||||
n.peers[p.ID] = p.Alias
|
||||
}
|
||||
for _, r := range evt.Rooms {
|
||||
n.addRoom(r)
|
||||
}
|
||||
for _, p := range evt.KnownPeers {
|
||||
n.knownPeers[p.ID] = p.Alias
|
||||
}
|
||||
}
|
||||
m = m.updateStatus()
|
||||
m = m.refreshViewport()
|
||||
|
||||
case proto.EvtNetworkJoined:
|
||||
n := m.netByID(evt.NetworkID)
|
||||
if n == nil {
|
||||
name := evt.NetworkName
|
||||
if name == "" {
|
||||
name = evt.NetworkID
|
||||
}
|
||||
n = newNetData(evt.NetworkID, name)
|
||||
m.nets = append(m.nets, n)
|
||||
m.activeNet = len(m.nets) - 1
|
||||
}
|
||||
if evt.LocalPeer != nil {
|
||||
m.localID = evt.LocalPeer.ID
|
||||
m.localAlias = evt.LocalPeer.Alias
|
||||
n.localID = evt.LocalPeer.ID
|
||||
n.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)
|
||||
m = m.updateStatus()
|
||||
m = m.refreshViewport()
|
||||
|
||||
case proto.EvtRoomCreated:
|
||||
if n := m.netByID(evt.NetworkID); n != nil {
|
||||
n.addRoom(evt.Room)
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtSessionReady:
|
||||
if evt.PeerID != nil {
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
if n := m.netByID(netID); n != nil && evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
if _, ok := n.peers[pid]; !ok {
|
||||
n.peerOrder = append(n.peerOrder, pid)
|
||||
}
|
||||
alias := evt.Nick
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
m.peers[pid] = alias
|
||||
n.peers[pid] = alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerConnected:
|
||||
if evt.Peer != nil {
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
if n := m.netByID(netID); n != nil && evt.Peer != nil {
|
||||
pid := evt.Peer.ID
|
||||
if _, ok := m.peers[pid]; !ok {
|
||||
m.peerOrder = append(m.peerOrder, pid)
|
||||
if _, ok := n.peers[pid]; !ok {
|
||||
n.peerOrder = append(n.peerOrder, pid)
|
||||
}
|
||||
m.peers[pid] = evt.Peer.Alias
|
||||
n.peers[pid] = evt.Peer.Alias
|
||||
}
|
||||
|
||||
case proto.EvtPeerDisconnected:
|
||||
if evt.PeerID != nil {
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
if n := m.netByID(netID); n != nil && evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
delete(m.peers, pid)
|
||||
m.peerOrder = filterIDs(m.peerOrder, pid)
|
||||
delete(n.peers, pid)
|
||||
n.peerOrder = filterIDs(n.peerOrder, pid)
|
||||
}
|
||||
|
||||
case proto.EvtInviteGenerated:
|
||||
m.invitePopup = evt.InviteString
|
||||
m.invitePopup = evt.InviteGenerated
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message != nil {
|
||||
msg := evt.Message
|
||||
e := entry{
|
||||
from: m.aliasOf(msg.From),
|
||||
body: msg.Body,
|
||||
at: msg.SentAt,
|
||||
fromMe: msg.From == m.localID,
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
n := m.netByID(netID)
|
||||
if n != nil {
|
||||
n.addRoom(msg.Room)
|
||||
}
|
||||
e := entry{
|
||||
mid: msg.Mid,
|
||||
from: m.aliasOf(netID, msg.From),
|
||||
body: msg.Text,
|
||||
at: time.UnixMilli(msg.Ts),
|
||||
fromMe: n != nil && msg.From == n.localID,
|
||||
}
|
||||
key := netID + ":" + msg.Room
|
||||
m.messages[key] = append(m.messages[key], e)
|
||||
if key != m.msgKey() {
|
||||
m.unread[key] = true
|
||||
}
|
||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||
m = m.addRoom(msg.Room)
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
|
||||
case proto.EvtHistoryLoaded:
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
n := m.netByID(netID)
|
||||
key := netID + ":" + evt.Room
|
||||
existing := m.messages[key]
|
||||
existingMids := make(map[string]bool, len(existing))
|
||||
for _, e := range existing {
|
||||
if e.mid != "" {
|
||||
existingMids[e.mid] = true
|
||||
}
|
||||
}
|
||||
var fresh []entry
|
||||
for _, msg := range evt.Messages {
|
||||
if msg.Mid != "" && existingMids[msg.Mid] {
|
||||
continue
|
||||
}
|
||||
fromMe := n != nil && msg.From == n.localID
|
||||
fresh = append(fresh, entry{
|
||||
mid: msg.Mid,
|
||||
from: m.aliasOf(netID, msg.From),
|
||||
body: msg.Text,
|
||||
at: time.UnixMilli(msg.Ts),
|
||||
fromMe: fromMe,
|
||||
})
|
||||
}
|
||||
if len(fresh) > 0 {
|
||||
// Prepend history, then existing live messages; sort by time.
|
||||
merged := append(fresh, existing...)
|
||||
// Simple insertion sort (lists are already mostly sorted).
|
||||
for i := 1; i < len(merged); i++ {
|
||||
for j := i; j > 0 && merged[j].at.Before(merged[j-1].at); j-- {
|
||||
merged[j], merged[j-1] = merged[j-1], merged[j]
|
||||
}
|
||||
}
|
||||
m.messages[key] = merged
|
||||
if n != nil {
|
||||
n.addRoom(evt.Room)
|
||||
}
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
|
||||
case proto.EvtReaction:
|
||||
mid := evt.ReactionMID
|
||||
emoji := evt.ReactionEmoji
|
||||
if mid == "" || emoji == "" || evt.PeerID == nil {
|
||||
break
|
||||
}
|
||||
netID := evt.NetworkID
|
||||
if netID == "" && len(m.nets) > 0 {
|
||||
netID = m.nets[0].id
|
||||
}
|
||||
alias := m.aliasOf(netID, *evt.PeerID)
|
||||
if m.reactions[mid] == nil {
|
||||
m.reactions[mid] = make(map[string][]string)
|
||||
}
|
||||
for _, a := range m.reactions[mid][emoji] {
|
||||
if a == alias {
|
||||
return m // already recorded
|
||||
}
|
||||
}
|
||||
m.reactions[mid][emoji] = append(m.reactions[mid][emoji], alias)
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) updateStatus() model {
|
||||
n := m.activeNetData()
|
||||
if n == nil {
|
||||
m.status = "connected — /join <network> to start"
|
||||
return m
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", n.localAlias, n.name)
|
||||
return m
|
||||
}
|
||||
|
||||
// ── doSend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
body := strings.TrimSpace(m.input.Value())
|
||||
if body == "" || m.enc == nil {
|
||||
@@ -309,8 +572,106 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
room := m.rooms[m.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||
// /join <network-name>
|
||||
if strings.HasPrefix(body, "/join ") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(body, "/join "))
|
||||
if name != "" {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdJoinNetwork, NetworkName: name}))
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// /net <number|name> — switch active network
|
||||
if strings.HasPrefix(body, "/net ") {
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(body, "/net "))
|
||||
if n, err := strconv.Atoi(arg); err == nil {
|
||||
idx := n - 1
|
||||
if idx >= 0 && idx < len(m.nets) {
|
||||
m.activeNet = idx
|
||||
m = m.updateStatus()
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
} else {
|
||||
for i, net := range m.nets {
|
||||
if strings.EqualFold(net.name, arg) {
|
||||
m.activeNet = i
|
||||
m = m.updateStatus()
|
||||
m = m.refreshViewport()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// /room <name>
|
||||
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,
|
||||
NetworkID: m.activeNetworkID(),
|
||||
Room: name,
|
||||
}))
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// /react [<n>] <emoji>
|
||||
if strings.HasPrefix(body, "/react ") {
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(body, "/react "))
|
||||
parts := strings.Fields(rest)
|
||||
var targetIdx int = -1 // -1 = last message
|
||||
var emoji string
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
emoji = parts[0]
|
||||
case 2:
|
||||
if n, err := strconv.Atoi(parts[0]); err == nil {
|
||||
targetIdx = n - 1
|
||||
} else {
|
||||
emoji = parts[0] // fallback: treat first token as emoji
|
||||
}
|
||||
if emoji == "" {
|
||||
emoji = parts[1]
|
||||
}
|
||||
}
|
||||
if emoji == "" {
|
||||
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
|
||||
return m, cmds
|
||||
}
|
||||
msgs := m.messages[m.msgKey()]
|
||||
var targetMid string
|
||||
if targetIdx == -1 && len(msgs) > 0 {
|
||||
targetMid = msgs[len(msgs)-1].mid
|
||||
} else if targetIdx >= 0 && targetIdx < len(msgs) {
|
||||
targetMid = msgs[targetIdx].mid
|
||||
}
|
||||
if targetMid == "" {
|
||||
m.errMsg = "usage: /react <emoji> or /react <n> <emoji> (e.g. /react 👍 or /react 3 ❤️)"
|
||||
return m, cmds
|
||||
}
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{
|
||||
Type: proto.CmdSendReaction,
|
||||
NetworkID: m.activeNetworkID(),
|
||||
ReactionMID: targetMid,
|
||||
ReactionEmoji: emoji,
|
||||
}))
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
// Regular message
|
||||
n := m.activeNetData()
|
||||
if n == nil {
|
||||
return m, cmds
|
||||
}
|
||||
room := n.rooms[n.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{
|
||||
Type: proto.CmdSendMessage,
|
||||
NetworkID: n.id,
|
||||
Room: room,
|
||||
Body: body,
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
recipID := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
ipcMsg.To = &recipID
|
||||
@@ -321,9 +682,7 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
|
||||
// ── 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
|
||||
@@ -331,10 +690,7 @@ func (m model) vpContentWidth() int {
|
||||
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
|
||||
@@ -357,42 +713,39 @@ func (m model) refreshViewport() model {
|
||||
if !m.vpReady {
|
||||
return m
|
||||
}
|
||||
room := m.activeRoomName()
|
||||
w := m.vpContentWidth()
|
||||
key := m.msgKey()
|
||||
var sb strings.Builder
|
||||
for _, e := range m.messages[room] {
|
||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
||||
for i, e := range m.messages[key] {
|
||||
lineNum := styleLineNum.Render(fmt.Sprintf("[%d]", i+1))
|
||||
ts := styleMsgTime.Render(formatMsgTime(e.at))
|
||||
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")
|
||||
sb.WriteString(fmt.Sprintf("%s %s %s %s\n", lineNum, ts, from, e.body))
|
||||
if e.mid != "" {
|
||||
if rxn := m.renderReactions(e.mid); rxn != "" {
|
||||
sb.WriteString(rxn + "\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]
|
||||
func (m model) renderReactions(mid string) string {
|
||||
byEmoji := m.reactions[mid]
|
||||
if len(byEmoji) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) addRoom(room string) model {
|
||||
for _, r := range m.rooms {
|
||||
if r == room {
|
||||
return m
|
||||
}
|
||||
var parts []string
|
||||
for emoji, froms := range byEmoji {
|
||||
parts = append(parts, fmt.Sprintf("%s %d", emoji, len(froms)))
|
||||
}
|
||||
m.rooms = append(m.rooms, room)
|
||||
return m
|
||||
return styleReaction.Render(" " + strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// ── view ──────────────────────────────────────────────────────────────────────
|
||||
@@ -402,41 +755,33 @@ func (m model) View() string {
|
||||
return "loading…\n"
|
||||
}
|
||||
|
||||
innerH := m.height - 3 - 1 // 3 = input box, 1 = status bar
|
||||
innerH := m.height - 3 - 1
|
||||
if innerH < 4 {
|
||||
innerH = 4
|
||||
}
|
||||
|
||||
// ── left: rooms ───────────────────────────────────────────────────────────
|
||||
leftBox := m.renderRooms(innerH)
|
||||
|
||||
// ── right: peers ──────────────────────────────────────────────────────────
|
||||
leftBox := m.renderLeft(innerH)
|
||||
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 · ctrl+i: invite · ctrl+c: quit"
|
||||
hint := " tab: rooms · ctrl+n: nets · /join /net /room /react · 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().
|
||||
@@ -457,20 +802,51 @@ func (m model) View() string {
|
||||
return view
|
||||
}
|
||||
|
||||
func (m model) renderRooms(boxH int) string {
|
||||
func (m model) renderLeft(boxH int) string {
|
||||
innerW := sideW - 2
|
||||
contentH := boxH - 2 // subtract top+bottom border
|
||||
contentH := boxH - 2
|
||||
sep := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
|
||||
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))
|
||||
|
||||
// Networks section
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Networks"))
|
||||
lines = append(lines, sep)
|
||||
if len(m.nets) == 0 {
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(" (none)"))
|
||||
}
|
||||
for i, n := range m.nets {
|
||||
label := n.name
|
||||
if i == m.activeNet {
|
||||
lines = append(lines, styleNetActive.Width(innerW).Render("▶ "+label))
|
||||
} else {
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
||||
lines = append(lines, styleNet.Width(innerW).Render(fmt.Sprintf(" [%d] %s", i+1, label)))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, sep)
|
||||
|
||||
// Rooms section
|
||||
lines = append(lines, styleHeader.Width(innerW).Render("Rooms"))
|
||||
lines = append(lines, sep)
|
||||
n := m.activeNetData()
|
||||
if n == nil {
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(" (no network)"))
|
||||
} else {
|
||||
for i, room := range n.rooms {
|
||||
label := roomLabel(room, n.peers)
|
||||
key := n.id + ":" + room
|
||||
if i == n.activeRoom {
|
||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||
} else {
|
||||
prefix := " "
|
||||
if m.unread[key] {
|
||||
prefix = "* "
|
||||
}
|
||||
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
}
|
||||
@@ -487,16 +863,18 @@ func (m model) renderPeers(boxH int) string {
|
||||
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)
|
||||
n := m.activeNetData()
|
||||
if n != nil {
|
||||
if n.localAlias != "" {
|
||||
lines = append(lines, styleSelf.Width(innerW).Render("◉ "+n.localAlias+" (me)"))
|
||||
}
|
||||
for _, pid := range n.peerOrder {
|
||||
alias := n.peers[pid]
|
||||
if alias == "" {
|
||||
alias = shortID(pid)
|
||||
}
|
||||
lines = append(lines, stylePeer.Width(innerW).Render("● "+alias))
|
||||
}
|
||||
lines = append(lines, stylePeer.Width(innerW).Render("● "+alias))
|
||||
}
|
||||
for len(lines) < contentH {
|
||||
lines = append(lines, strings.Repeat(" ", innerW))
|
||||
@@ -510,8 +888,18 @@ func (m model) renderPeers(boxH int) string {
|
||||
|
||||
func (m model) renderCentre(boxH int) string {
|
||||
innerW := m.vpContentWidth()
|
||||
room := m.activeRoomName()
|
||||
title := styleTitle.Width(innerW).Render(" " + roomTitle(room, m.peers))
|
||||
n := m.activeNetData()
|
||||
var roomName string
|
||||
if n != nil {
|
||||
roomName = n.rooms[n.activeRoom]
|
||||
} else {
|
||||
roomName = "general"
|
||||
}
|
||||
var peerMap map[proto.PeerID]string
|
||||
if n != nil {
|
||||
peerMap = n.peers
|
||||
}
|
||||
title := styleTitle.Width(innerW).Render(" " + roomTitle(roomName, peerMap))
|
||||
divider := lipgloss.NewStyle().Foreground(styleBorder).Render(strings.Repeat("─", innerW))
|
||||
|
||||
vpView := ""
|
||||
@@ -520,7 +908,6 @@ func (m model) renderCentre(boxH int) string {
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, title, divider, vpView)
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(innerW).Height(boxH - 2).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(styleBorder).
|
||||
@@ -529,19 +916,6 @@ func (m model) renderCentre(boxH int) string {
|
||||
|
||||
// ── 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 {
|
||||
@@ -556,8 +930,10 @@ func roomLabel(room string, peers map[proto.PeerID]string) string {
|
||||
}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
pid := proto.PeerID(strings.TrimPrefix(room, "dm:"))
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
if peers != nil {
|
||||
if a, ok := peers[pid]; ok && a != "" {
|
||||
return "@ " + a
|
||||
}
|
||||
}
|
||||
return "@ " + shortID(pid)
|
||||
}
|
||||
@@ -565,17 +941,7 @@ func roomLabel(room string, peers map[proto.PeerID]string) string {
|
||||
}
|
||||
|
||||
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
|
||||
return roomLabel(room, peers)
|
||||
}
|
||||
|
||||
func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
||||
@@ -588,6 +954,14 @@ func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
||||
return out
|
||||
}
|
||||
|
||||
func formatMsgTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
return t.Format("Jan 2 15:04")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -598,12 +972,11 @@ func min(a, b int) int {
|
||||
// ── 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")
|
||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||
network := flag.String("network", "", "network name to join on startup (optional)")
|
||||
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 {
|
||||
@@ -613,12 +986,6 @@ func main() {
|
||||
*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(),
|
||||
|
||||
32
deploy-web.sh.example
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-web.sh — build and push the web UI to the VPS.
|
||||
# Assumes SSH agent forwarding is set up.
|
||||
#
|
||||
# SETUP: copy this file to deploy-web.sh (gitignored) and set HOST below.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy-web.sh
|
||||
#
|
||||
# Optional env vars:
|
||||
# HOST SSH target (user@host) (required — edit below)
|
||||
# REMOTE_DIR path on VPS (default: ~/waste-www)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOST:-user@YOUR_VPS_IP}" # ← edit this
|
||||
REMOTE_DIR="${REMOTE_DIR:-~/waste-www}"
|
||||
|
||||
if [[ "$HOST" == *YOUR_VPS_IP* ]]; then
|
||||
echo "error: edit HOST in this script (or export HOST=user@your-vps before running)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ building web UI…"
|
||||
"$(dirname "$0")/build-web.sh"
|
||||
|
||||
echo "→ syncing to $HOST:$REMOTE_DIR"
|
||||
rsync -azv --delete \
|
||||
--exclude='config.js' \
|
||||
web/dist/ "$HOST:$REMOTE_DIR/"
|
||||
|
||||
echo "✓ done"
|
||||
39
go.mod
@@ -6,14 +6,16 @@ require (
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
golang.org/x/crypto v0.24.0
|
||||
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
|
||||
@@ -27,7 +29,26 @@ require (
|
||||
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
|
||||
@@ -35,6 +56,7 @@ require (
|
||||
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
|
||||
@@ -50,15 +72,24 @@ 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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // 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
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.16.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
|
||||
|
||||
85
go.sum
@@ -1,9 +1,13 @@
|
||||
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=
|
||||
@@ -31,21 +35,63 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
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=
|
||||
@@ -60,6 +106,8 @@ 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=
|
||||
@@ -102,22 +150,45 @@ 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=
|
||||
@@ -130,12 +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=
|
||||
@@ -144,17 +218,24 @@ 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=
|
||||
@@ -173,6 +254,7 @@ 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=
|
||||
@@ -180,6 +262,8 @@ 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=
|
||||
@@ -187,6 +271,7 @@ 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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
// Package invite encodes and decodes waste invite strings.
|
||||
// An invite carries the anchor URL and network name needed to join a network.
|
||||
//
|
||||
// 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"
|
||||
@@ -14,23 +26,69 @@ 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
|
||||
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)
|
||||
}
|
||||
|
||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
||||
// 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")
|
||||
}
|
||||
b, err := json.Marshal(Invite{Anchor: anchor, Network: network})
|
||||
if err != nil {
|
||||
return "", err
|
||||
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
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), 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.
|
||||
@@ -52,3 +110,21 @@ func Decode(s string) (Invite, error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,22 +14,41 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"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.
|
||||
// anchorURL is the configured anchor WebSocket URL (used for invite generation).
|
||||
// Network join/leave state is daemon-scoped (shared across all IPC clients).
|
||||
func Run(m *mesh.Mesh, port int, anchorURL string, 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 {
|
||||
@@ -35,63 +56,29 @@ func Run(m *mesh.Mesh, port int, anchorURL string, join JoinFunc) error {
|
||||
}
|
||||
log.Printf("ipc: listening on %s", addr)
|
||||
|
||||
// networkCancel / networkName are shared across all clients — any client
|
||||
// can join/leave, and the join persists after the commanding client disconnects.
|
||||
var (
|
||||
networkMu sync.Mutex
|
||||
networkCancel context.CancelFunc
|
||||
networkName string
|
||||
)
|
||||
|
||||
doJoin := func(name string) {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
networkName = name
|
||||
networkMu.Unlock()
|
||||
go join(ctx, name)
|
||||
}
|
||||
|
||||
doLeave := func() {
|
||||
networkMu.Lock()
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
networkName = ""
|
||||
}
|
||||
networkMu.Unlock()
|
||||
}
|
||||
|
||||
currentNetwork := func() string {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
return networkName
|
||||
}
|
||||
|
||||
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, anchorURL, currentNetwork, doJoin, doLeave)
|
||||
go handleClient(conn, mgr)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork func() string, 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')
|
||||
@@ -102,9 +89,7 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork
|
||||
}
|
||||
}()
|
||||
|
||||
// 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 {
|
||||
@@ -140,12 +125,10 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork
|
||||
}
|
||||
}
|
||||
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
// Send initial state snapshot.
|
||||
send(stateSnapshot(mgr))
|
||||
// Send stored history for each room so the UI is populated on connect.
|
||||
sendStoredHistory(mgr, send)
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
@@ -157,66 +140,305 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork
|
||||
|
||||
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(),
|
||||
case proto.CmdJoinNetwork:
|
||||
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
|
||||
}
|
||||
payload, err := json.Marshal(proto.PeerMessage{Type: proto.MsgChat, Chat: msg})
|
||||
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:
|
||||
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.CmdSendReaction:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("send_reaction: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.ReactionMID == "" || cmd.ReactionEmoji == "" {
|
||||
send(errMsg("send_reaction: reaction_mid and reaction_emoji are required"))
|
||||
continue
|
||||
}
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgReaction,
|
||||
ReactionMID: cmd.ReactionMID,
|
||||
ReactionEmoji: cmd.ReactionEmoji,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cmd.To != nil {
|
||||
// DM — send only to the named recipient.
|
||||
m.SendTo(*cmd.To, payload)
|
||||
} else {
|
||||
m.Broadcast(payload)
|
||||
}
|
||||
m.SaveMessage(msg)
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg})
|
||||
|
||||
case proto.CmdJoinNetwork:
|
||||
if cmd.NetworkName == "" {
|
||||
send(errMsg("join_network: network_name is required"))
|
||||
continue
|
||||
}
|
||||
doJoin(cmd.NetworkName)
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
doLeave()
|
||||
|
||||
case proto.CmdGetState:
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtStateSnapshot,
|
||||
LocalPeer: ptr(m.Identity.PeerInfo()),
|
||||
ConnectedPeers: m.ConnectedPeers(),
|
||||
Rooms: []string{"general"},
|
||||
n.Mesh.Broadcast(wire)
|
||||
n.Mesh.SaveReaction(cmd.ReactionMID, cmd.ReactionEmoji, string(n.Identity.PeerID()))
|
||||
n.Mesh.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
NetworkID: n.ID,
|
||||
PeerID: ptr(n.Identity.PeerID()),
|
||||
ReactionMID: cmd.ReactionMID,
|
||||
ReactionEmoji: cmd.ReactionEmoji,
|
||||
})
|
||||
|
||||
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:
|
||||
net := currentNetwork()
|
||||
if net == "" {
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("generate_invite: not currently joined to a network"))
|
||||
continue
|
||||
}
|
||||
if anchorURL == "" {
|
||||
if mgr.AnchorURL() == "" {
|
||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||
continue
|
||||
}
|
||||
inv, err := invite.Encode(anchorURL, net)
|
||||
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.EvtInviteGenerated, InviteString: inv})
|
||||
send(proto.IpcMessage{
|
||||
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)))
|
||||
@@ -225,13 +447,130 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Include all historically-known peers so the UI can resolve aliases in history.
|
||||
if known, err := all[0].Store.KnownPeers(); err == nil {
|
||||
connected := map[proto.PeerID]bool{}
|
||||
for _, p := range msg.ConnectedPeers {
|
||||
connected[p.ID] = true
|
||||
}
|
||||
for id, alias := range known {
|
||||
if connected[id] {
|
||||
continue // already in ConnectedPeers
|
||||
}
|
||||
msg.KnownPeers = append(msg.KnownPeers, proto.PeerInfo{
|
||||
ID: id,
|
||||
Alias: alias,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// sendStoredHistory pushes recent messages for all known rooms to a newly-connected IPC client.
|
||||
func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) {
|
||||
all := mgr.All()
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
n := all[0] // use first network; multi-network history follows same pattern
|
||||
if n.Store == nil {
|
||||
return
|
||||
}
|
||||
rooms := []string{"general"}
|
||||
if extra, err := n.Store.Rooms(); err == nil {
|
||||
rooms = append(rooms, extra...)
|
||||
}
|
||||
for _, room := range rooms {
|
||||
msgs, err := n.Store.RecentMessagesSince(room, 0, 200)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtHistoryLoaded,
|
||||
NetworkID: n.ID,
|
||||
Room: room,
|
||||
Messages: msgs,
|
||||
})
|
||||
// Send stored reactions for this room's messages.
|
||||
rxns, err := n.Store.ReactionsForRoom(room)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for mid, byEmoji := range rxns {
|
||||
for emoji, fromPeers := range byEmoji {
|
||||
for _, fromPeer := range fromPeers {
|
||||
pid := proto.PeerID(fromPeer)
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
NetworkID: n.ID,
|
||||
PeerID: &pid,
|
||||
ReactionMID: mid,
|
||||
ReactionEmoji: emoji,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -239,3 +578,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,30 +2,62 @@
|
||||
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
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
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
|
||||
|
||||
// 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
|
||||
subs []chan proto.IpcMessage
|
||||
@@ -35,32 +67,96 @@ type Mesh struct {
|
||||
// Pass a non-nil store to enable message and peer persistence.
|
||||
func New(id *crypto.Identity, st *store.Store) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
Store: st,
|
||||
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()
|
||||
|
||||
if m.Store != nil && conn.Info.Alias != "" {
|
||||
if err := m.Store.SavePeer(conn.Info.ID, conn.Info.Alias); err != nil {
|
||||
log.Printf("mesh: store peer %s: %v", conn.Info.ID.Short(), err)
|
||||
}
|
||||
}
|
||||
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerConnected,
|
||||
Peer: &conn.Info,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveReaction persists a reaction if a store is configured.
|
||||
// Duplicate (mid, emoji, fromPeer) triples are silently dropped.
|
||||
func (m *Mesh) SaveReaction(mid, emoji, fromPeer string) {
|
||||
if m.Store == nil {
|
||||
return
|
||||
}
|
||||
if err := m.Store.SaveReaction(mid, emoji, fromPeer); err != nil {
|
||||
log.Printf("mesh: store reaction %s/%s: %v", mid, emoji, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveMessage persists a chat message if a store is configured.
|
||||
// Duplicate mids are silently dropped.
|
||||
func (m *Mesh) SaveMessage(msg *proto.ChatMessage) {
|
||||
@@ -158,6 +254,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 {
|
||||
@@ -134,6 +195,10 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
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
|
||||
}
|
||||
|
||||
@@ -148,31 +213,170 @@ 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.SaveMessage(msg.Chat)
|
||||
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.MsgReaction:
|
||||
if msg.ReactionMID == "" || msg.ReactionEmoji == "" {
|
||||
return
|
||||
}
|
||||
m.SaveReaction(msg.ReactionMID, msg.ReactionEmoji, string(from))
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtReaction,
|
||||
PeerID: peerIDPtr(from),
|
||||
ReactionMID: msg.ReactionMID,
|
||||
ReactionEmoji: msg.ReactionEmoji,
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -196,4 +400,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 }
|
||||
|
||||
514
internal/mesh/transfer.go
Normal file
@@ -0,0 +1,514 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ScanResumable scans the download directory for .tmp.meta sidecars left by
|
||||
// interrupted transfers and emits a resumable_transfers IPC event listing them.
|
||||
// Called once after a network is joined so the UI can show pending transfers.
|
||||
func (m *Mesh) ScanResumable() {
|
||||
if m.DownloadDir == "" {
|
||||
return
|
||||
}
|
||||
metas, _ := filepath.Glob(filepath.Join(m.DownloadDir, "*.tmp.meta"))
|
||||
var files []proto.ResumableFile
|
||||
for _, mp := range metas {
|
||||
data, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var meta partialMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
continue
|
||||
}
|
||||
tp := strings.TrimSuffix(mp, ".meta")
|
||||
info, err := os.Stat(tp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, proto.ResumableFile{
|
||||
Name: meta.Name,
|
||||
SHA256: meta.SHA256,
|
||||
From: meta.From,
|
||||
Size: meta.Size,
|
||||
Offset: info.Size(),
|
||||
})
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return
|
||||
}
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtResumableTransfers,
|
||||
ResumableFiles: files,
|
||||
})
|
||||
log.Printf("transfer: %d resumable transfer(s) found in %s", len(files), m.DownloadDir)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
493
internal/netmgr/manager.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// 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)
|
||||
}()
|
||||
}
|
||||
|
||||
go m.ScanResumable()
|
||||
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
go m.ScanResumable()
|
||||
|
||||
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,108 @@ 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"
|
||||
MsgReaction MsgType = "reaction"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
|
||||
// reaction fields
|
||||
ReactionMID string `json:"reaction_mid,omitempty"`
|
||||
ReactionEmoji string `json:"reaction_emoji,omitempty"`
|
||||
}
|
||||
|
||||
// ChatMessage is a message to a room or a DM.
|
||||
// ResumableFile describes a partially-downloaded file found on daemon startup.
|
||||
type ResumableFile struct {
|
||||
Name string `json:"name"`
|
||||
SHA256 string `json:"sha256"`
|
||||
From string `json:"from"` // peer ID hex
|
||||
Size int64 `json:"size"`
|
||||
Offset int64 `json:"offset"` // bytes already received
|
||||
}
|
||||
|
||||
// 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 +154,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 +217,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) ────────────────────────────────────
|
||||
@@ -151,27 +240,30 @@ type SignalingPayload struct {
|
||||
type AnchorMsgType string
|
||||
|
||||
const (
|
||||
AnchorChallenge AnchorMsgType = "challenge"
|
||||
AnchorJoin AnchorMsgType = "join"
|
||||
AnchorJoined AnchorMsgType = "joined"
|
||||
AnchorPeerJoin AnchorMsgType = "peer-join"
|
||||
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
||||
AnchorTo AnchorMsgType = "to"
|
||||
AnchorFrom AnchorMsgType = "from"
|
||||
AnchorNoPeer AnchorMsgType = "no-peer"
|
||||
AnchorChallenge AnchorMsgType = "challenge"
|
||||
AnchorJoin AnchorMsgType = "join"
|
||||
AnchorJoined AnchorMsgType = "joined"
|
||||
AnchorPeerJoin AnchorMsgType = "peer-join"
|
||||
AnchorPeerLeave AnchorMsgType = "peer-leave"
|
||||
AnchorTo AnchorMsgType = "to"
|
||||
AnchorFrom AnchorMsgType = "from"
|
||||
AnchorNoPeer AnchorMsgType = "no-peer"
|
||||
AnchorPresenceQuery AnchorMsgType = "presence_query" // waste-go ext EXT-009
|
||||
AnchorPresence AnchorMsgType = "presence" // waste-go ext EXT-009
|
||||
)
|
||||
|
||||
// AnchorMessage covers all WebSocket frames to/from the anchor.
|
||||
type AnchorMessage struct {
|
||||
Type AnchorMsgType `json:"type"`
|
||||
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
||||
ID string `json:"id,omitempty"` // peer hex id
|
||||
Net string `json:"net,omitempty"` // hashed network name
|
||||
Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex
|
||||
Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network
|
||||
To string `json:"to,omitempty"` // target peer hex id
|
||||
From string `json:"from,omitempty"` // sender peer hex id
|
||||
Box string `json:"box,omitempty"` // base64 nacl/box sealed payload
|
||||
Type AnchorMsgType `json:"type"`
|
||||
Nonce string `json:"nonce,omitempty"` // challenge nonce, hex
|
||||
ID string `json:"id,omitempty"` // peer hex id
|
||||
Net string `json:"net,omitempty"` // hashed network name
|
||||
Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex
|
||||
Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network
|
||||
To string `json:"to,omitempty"` // target peer hex id
|
||||
From string `json:"from,omitempty"` // sender peer hex id
|
||||
Box string `json:"box,omitempty"` // base64 nacl/box sealed payload
|
||||
Online *bool `json:"online,omitempty"` // presence: true iff (net, id) is currently connected
|
||||
}
|
||||
|
||||
// ── IPC protocol (daemon ↔ local UI) ─────────────────────────────────────────
|
||||
@@ -181,52 +273,111 @@ type IpcMsgType string
|
||||
|
||||
const (
|
||||
// Commands (UI → daemon)
|
||||
CmdSendMessage IpcMsgType = "send_message"
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdSendMessage IpcMsgType = "send_message"
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
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
|
||||
CmdSendReaction IpcMsgType = "send_reaction" // fields: network_id, reaction_mid, reaction_emoji
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
EvtPeerConnected IpcMsgType = "peer_connected"
|
||||
EvtPeerDisconnected IpcMsgType = "peer_disconnected"
|
||||
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
|
||||
EvtIncomingFile IpcMsgType = "incoming_file"
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
EvtError IpcMsgType = "error"
|
||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||
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
|
||||
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
|
||||
EvtReaction IpcMsgType = "reaction" // fields: reaction_mid, reaction_emoji, peer_id
|
||||
)
|
||||
|
||||
// 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
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
PeerID *PeerID `json:"peer_id,omitempty"`
|
||||
Nick string `json:"nick,omitempty"`
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
Offer *FileOffer `json:"offer,omitempty"`
|
||||
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"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteString string `json:"invite,omitempty"`
|
||||
Peer *PeerInfo `json:"peer,omitempty"`
|
||||
PeerID *PeerID `json:"peer_id,omitempty"`
|
||||
Nick string `json:"nick,omitempty"`
|
||||
Message *ChatMessage `json:"message,omitempty"`
|
||||
Offer *FileOffer `json:"offer,omitempty"`
|
||||
TransferID string `json:"transfer_id,omitempty"`
|
||||
BytesReceived int64 `json:"bytes_received,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,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"`
|
||||
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
|
||||
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
|
||||
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
||||
ReactionMID string `json:"reaction_mid,omitempty"` // reaction
|
||||
ReactionEmoji string `json:"reaction_emoji,omitempty"` // reaction
|
||||
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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -28,8 +29,30 @@ CREATE TABLE IF NOT EXISTS peers (
|
||||
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.
|
||||
// CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are idempotent.
|
||||
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`,
|
||||
// Reactions: (mid, emoji, from_peer) triple is the unique dedup key.
|
||||
`CREATE TABLE IF NOT EXISTS reactions (
|
||||
mid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
from_peer TEXT NOT NULL,
|
||||
reacted_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (mid, emoji, from_peer)
|
||||
)`,
|
||||
}
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -46,6 +69,12 @@ func Open(path string) (*Store, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -57,10 +86,11 @@ func (s *Store) Close() error {
|
||||
// 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, room, from_peer, body, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
msg.Mid, msg.Room, string(msg.From), msg.Body, msg.SentAt.UTC(),
|
||||
`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
|
||||
}
|
||||
@@ -76,16 +106,48 @@ func (s *Store) SavePeer(peerID proto.PeerID, alias string) error {
|
||||
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) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT mid, from_peer, body, sent_at
|
||||
FROM messages
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT ?`,
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
|
||||
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||
// sinceMs == 0 returns the most recent messages regardless of timestamp.
|
||||
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
if sinceMs == 0 {
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
since := time.UnixMilli(sinceMs).UTC()
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ?
|
||||
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
|
||||
}
|
||||
@@ -96,12 +158,11 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
var m proto.ChatMessage
|
||||
var from string
|
||||
var sentAt time.Time
|
||||
if err := rows.Scan(&m.Mid, &from, &m.Body, &sentAt); err != nil {
|
||||
if err := rows.Scan(&m.Mid, &from, &m.Room, &m.Text, &sentAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.From = proto.PeerID(from)
|
||||
m.Room = room
|
||||
m.SentAt = sentAt
|
||||
m.Ts = sentAt.UnixMilli()
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
// Reverse so oldest-first.
|
||||
@@ -111,6 +172,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
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`)
|
||||
@@ -128,3 +216,47 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveReaction persists a reaction. Duplicate (mid, emoji, from_peer) triples are silently ignored.
|
||||
func (s *Store) SaveReaction(mid, emoji, fromPeer string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO reactions (mid, emoji, from_peer, reacted_at) VALUES (?, ?, ?, ?)`,
|
||||
mid, emoji, fromPeer, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReactionsForRoom returns all reactions for messages in a given room.
|
||||
// Result: mid → emoji → []fromPeer (ordered by reaction time).
|
||||
func (s *Store) ReactionsForRoom(room string) (map[string]map[string][]string, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.mid, r.emoji, r.from_peer
|
||||
FROM reactions r
|
||||
JOIN messages m ON m.mid = r.mid
|
||||
WHERE m.room = ?
|
||||
ORDER BY r.reacted_at
|
||||
`, room)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]map[string][]string)
|
||||
for rows.Next() {
|
||||
var mid, emoji, from string
|
||||
if err := rows.Scan(&mid, &emoji, &from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[mid] == nil {
|
||||
out[mid] = make(map[string][]string)
|
||||
}
|
||||
out[mid][emoji] = append(out[mid][emoji], from)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ func TestRoundTrip(t *testing.T) {
|
||||
defer st.Close()
|
||||
|
||||
msg := &proto.ChatMessage{
|
||||
Mid: "aabbccdd00112233",
|
||||
From: proto.PeerID("deadbeef"),
|
||||
Room: "general",
|
||||
Body: "hello world",
|
||||
SentAt: time.Now().UTC().Truncate(time.Second),
|
||||
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 {
|
||||
@@ -40,7 +40,7 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatalf("got %d messages, want 1", len(msgs))
|
||||
}
|
||||
got := msgs[0]
|
||||
if got.Mid != msg.Mid || got.Body != msg.Body || got.Room != msg.Room {
|
||||
if got.Mid != msg.Mid || got.Text != msg.Text || got.Room != msg.Room {
|
||||
t.Fatalf("message mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -79,11 +79,11 @@ func TestRecentMessagesOrdering(t *testing.T) {
|
||||
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",
|
||||
Body: string(rune('a' + i)),
|
||||
SentAt: base.Add(time.Duration(i) * time.Second),
|
||||
Mid: string(rune('a'+i)) + "000000000000000",
|
||||
From: "deadbeef",
|
||||
Room: "general",
|
||||
Text: string(rune('a' + i)),
|
||||
Ts: base.Add(time.Duration(i) * time.Second).UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestRecentMessagesOrdering(t *testing.T) {
|
||||
}
|
||||
// Must be oldest-first.
|
||||
for i := 1; i < len(msgs); i++ {
|
||||
if msgs[i].SentAt.Before(msgs[i-1].SentAt) {
|
||||
if msgs[i].Ts < msgs[i-1].Ts {
|
||||
t.Fatalf("messages not in ascending order at index %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
96
launch-tui.sh.example
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch-tui.sh — build and launch the TUI against a remote anchor.
|
||||
# Starts a local daemon then opens the Bubble Tea terminal UI.
|
||||
#
|
||||
# SETUP: copy this file to launch-tui.sh (gitignored) and set ANCHOR below.
|
||||
#
|
||||
# Usage:
|
||||
# ./launch-tui.sh
|
||||
# ALIAS=alice NETWORK=friends ./launch-tui.sh
|
||||
#
|
||||
# Optional env vars (all have defaults):
|
||||
# ANCHOR anchor WebSocket URL (required — edit below)
|
||||
# NETWORK network name to join (default: "friends")
|
||||
# ALIAS display name (default: $USER)
|
||||
# DATA_DIR identity + message store dir (default: ~/.waste-$ALIAS)
|
||||
# IPC_PORT local daemon IPC port (default: 17337)
|
||||
# SHARE_DIR directory to share with peers (optional)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ANCHOR="${ANCHOR:-wss://YOUR_ANCHOR_DOMAIN/ws}" # ← edit this
|
||||
|
||||
NETWORK="${NETWORK:-friends}"
|
||||
ALIAS="${ALIAS:-${USER:-anon}}"
|
||||
DATA_DIR="${DATA_DIR:-${HOME}/.waste-${ALIAS}}"
|
||||
|
||||
_DEFAULT_ALIAS="${USER:-anon}"
|
||||
if [ "${ALIAS}" = "${_DEFAULT_ALIAS}" ]; then
|
||||
IPC_PORT="${IPC_PORT:-17337}"
|
||||
else
|
||||
_HASH=$(printf '%d' "0x$(printf '%s' "$ALIAS" | md5sum | cut -c1-4)")
|
||||
IPC_PORT="${IPC_PORT:-$(( 17400 + _HASH % 1000 ))}"
|
||||
fi
|
||||
|
||||
SHARE_DIR="${SHARE_DIR:-}"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
if [[ "$ANCHOR" == *YOUR_ANCHOR_DOMAIN* ]]; then
|
||||
echo -e "${RED}error: edit ANCHOR in this script (or export ANCHOR=wss://... before running)${RESET}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}waste TUI${RESET}"
|
||||
echo -e "${DIM}anchor : ${BOLD}${ANCHOR}${RESET}"
|
||||
echo -e "${DIM}network : ${BOLD}${NETWORK}${RESET}"
|
||||
echo -e "${DIM}alias : ${BOLD}${ALIAS}${RESET}"
|
||||
echo -e "${DIM}data : ${DATA_DIR}${RESET}"
|
||||
[ -n "$SHARE_DIR" ] && echo -e "${DIM}share : ${SHARE_DIR}${RESET}"
|
||||
echo ""
|
||||
|
||||
echo -e "${DIM}building binaries…${RESET}"
|
||||
go build -o /tmp/waste-daemon-run ./cmd/daemon
|
||||
go build -o /tmp/waste-tui-run ./cmd/tui
|
||||
echo -e "${GREEN}✓ built${RESET}"
|
||||
echo ""
|
||||
|
||||
existing=$(lsof -ti tcp:"$IPC_PORT" 2>/dev/null || true)
|
||||
[ -n "$existing" ] && kill "$existing" 2>/dev/null && sleep 0.3 || true
|
||||
|
||||
echo -e "${DIM}starting daemon on :${IPC_PORT}…${RESET}"
|
||||
WS_PORT=$(( IPC_PORT + 1 ))
|
||||
/tmp/waste-daemon-run \
|
||||
-alias "$ALIAS" -data-dir "$DATA_DIR" \
|
||||
-ipc-port "$IPC_PORT" -ws-port "$WS_PORT" \
|
||||
-anchor "$ANCHOR" \
|
||||
2>/tmp/waste-daemon.log &
|
||||
DAEMON_PID=$!
|
||||
|
||||
n=0
|
||||
while ! nc -z 127.0.0.1 "$IPC_PORT" 2>/dev/null; do
|
||||
sleep 0.1; n=$(( n + 1 ))
|
||||
[ "$n" -gt 80 ] && echo -e "${RED}daemon failed — check /tmp/waste-daemon.log${RESET}" >&2 && exit 1
|
||||
done
|
||||
echo -e "${GREEN}✓ daemon started (pid ${DAEMON_PID})${RESET}"
|
||||
|
||||
sleep 0.3
|
||||
if [ -n "$SHARE_DIR" ]; then
|
||||
JOIN=$(jq -cn --arg net "$NETWORK" --arg dir "$SHARE_DIR" \
|
||||
'{"type":"join_network","network_name":$net,"share_dir":$dir}')
|
||||
else
|
||||
JOIN=$(jq -cn --arg net "$NETWORK" '{"type":"join_network","network_name":$net}')
|
||||
fi
|
||||
echo "$JOIN" | nc -q 0 127.0.0.1 "$IPC_PORT" >/dev/null 2>&1 || true
|
||||
echo -e "${DIM}joined network: ${BOLD}${NETWORK}${RESET}"
|
||||
|
||||
cleanup() { kill "$DAEMON_PID" 2>/dev/null || true; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}launching TUI${RESET} — ${DIM}ctrl+c to quit${RESET}"
|
||||
echo ""
|
||||
exec /tmp/waste-tui-run -ipc "$IPC_PORT" -network "$NETWORK"
|
||||
74
launch-web.sh.example
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch-web.sh — start the daemon and open the web UI Vite dev server.
|
||||
# For local development / daemon-mode browsing.
|
||||
#
|
||||
# SETUP: copy this file to launch-web.sh (gitignored) and set ANCHOR below.
|
||||
#
|
||||
# Usage:
|
||||
# ./launch-web.sh
|
||||
# ALIAS=alice NETWORK=friends ./launch-web.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ANCHOR="${ANCHOR:-wss://YOUR_ANCHOR_DOMAIN/ws}" # ← edit this
|
||||
|
||||
NETWORK="${NETWORK:-friends}"
|
||||
ALIAS="${ALIAS:-${USER:-anon}}"
|
||||
DATA_DIR="${DATA_DIR:-${HOME}/.waste-${ALIAS}}"
|
||||
IPC_PORT="${IPC_PORT:-17337}"
|
||||
WS_PORT=$(( IPC_PORT + 1 ))
|
||||
|
||||
if [[ "$ANCHOR" == *YOUR_ANCHOR_DOMAIN* ]]; then
|
||||
echo "error: edit ANCHOR in this script (or export ANCHOR=wss://... before running)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PIDS=()
|
||||
cleanup() {
|
||||
for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
for port in "$IPC_PORT" "$WS_PORT"; do
|
||||
existing=$(lsof -ti tcp:"$port" 2>/dev/null || true)
|
||||
[ -n "$existing" ] && kill "$existing" 2>/dev/null || true
|
||||
done
|
||||
for port in "$IPC_PORT" "$WS_PORT"; 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
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
echo "alias : $ALIAS"
|
||||
echo "network : $NETWORK"
|
||||
echo "anchor : $ANCHOR"
|
||||
echo "ws-port : $WS_PORT"
|
||||
echo ""
|
||||
|
||||
echo "building daemon…"
|
||||
go build -o /tmp/waste-daemon-web ./cmd/daemon
|
||||
|
||||
/tmp/waste-daemon-web \
|
||||
-alias "$ALIAS" -data-dir "$DATA_DIR" \
|
||||
-ipc-port "$IPC_PORT" -ws-port "$WS_PORT" \
|
||||
-anchor "$ANCHOR" \
|
||||
2>/tmp/waste-daemon-web.log &
|
||||
PIDS+=($!)
|
||||
|
||||
n=0
|
||||
while ! nc -z 127.0.0.1 "$IPC_PORT" 2>/dev/null; do
|
||||
sleep 0.1; n=$(( n + 1 ))
|
||||
[ "$n" -gt 80 ] && echo "daemon failed to start — check /tmp/waste-daemon-web.log" >&2 && exit 1
|
||||
done
|
||||
|
||||
sleep 0.2
|
||||
jq -cn --arg net "$NETWORK" '{"type":"join_network","network_name":$net}' \
|
||||
| nc -q0 127.0.0.1 "$IPC_PORT" >/dev/null 2>&1 || true
|
||||
echo "daemon ready — joined $NETWORK"
|
||||
echo ""
|
||||
|
||||
npm run dev --prefix "$(dirname "$0")/web"
|
||||
38
serve-web.sh.example
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# serve-web.sh — start (or restart) the static file server on the VPS.
|
||||
# Runs `npx serve` in the background, logs to ~/waste-www.log.
|
||||
#
|
||||
# SETUP: copy this file to serve-web.sh (gitignored) and set HOST below.
|
||||
#
|
||||
# Usage:
|
||||
# ./serve-web.sh
|
||||
#
|
||||
# Optional env vars:
|
||||
# HOST SSH target (user@host) (required — edit below)
|
||||
# REMOTE_DIR path on VPS (default: ~/waste-www)
|
||||
# PORT local port on VPS (default: 1337)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOST:-user@YOUR_VPS_IP}" # ← edit this
|
||||
REMOTE_DIR="${REMOTE_DIR:-~/waste-www}"
|
||||
REMOTE_LOG="~/waste-www.log"
|
||||
REMOTE_PID="~/waste-www.pid"
|
||||
PORT="${PORT:-1337}"
|
||||
|
||||
if [[ "$HOST" == *YOUR_VPS_IP* ]]; then
|
||||
echo "error: edit HOST in this script (or export HOST=user@your-vps before running)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh "$HOST" bash <<EOF
|
||||
if [ -f $REMOTE_PID ]; then
|
||||
kill \$(cat $REMOTE_PID) 2>/dev/null || true
|
||||
rm -f $REMOTE_PID
|
||||
fi
|
||||
|
||||
echo "[\$(date)] starting npx serve on port $PORT" >> $REMOTE_LOG
|
||||
nohup npx serve -s $REMOTE_DIR -l $PORT >> $REMOTE_LOG 2>&1 &
|
||||
echo \$! > $REMOTE_PID
|
||||
echo "→ started (pid \$(cat $REMOTE_PID)), logging to $REMOTE_LOG"
|
||||
EOF
|
||||
250
test-network.sh
@@ -25,6 +25,39 @@ 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=()
|
||||
cleanup() {
|
||||
@@ -35,7 +68,6 @@ cleanup() {
|
||||
done
|
||||
wait 2>/dev/null || true
|
||||
echo -e "${DIM}data left at: ${DATA_ROOT}${RESET}"
|
||||
echo -e "${DIM} inspect: sqlite3 /tmp/waste-test/alice/messages.db${RESET}"
|
||||
echo -e "${DIM}done.${RESET}"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
@@ -80,7 +112,7 @@ pretty() {
|
||||
message_received)
|
||||
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)
|
||||
to_field=$(echo "$line" | jq -r '.message.to // ""' 2>/dev/null)
|
||||
if [ -n "$to_field" ]; then
|
||||
@@ -91,6 +123,26 @@ pretty() {
|
||||
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
|
||||
msg=$(echo "$line" | jq -r '.error_message // .' 2>/dev/null)
|
||||
@@ -192,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})"
|
||||
@@ -201,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})"
|
||||
@@ -210,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"
|
||||
@@ -218,15 +270,25 @@ wait_port "$BOB_IPC" "bob"
|
||||
wait_port "$CHARLIE_IPC" "charlie"
|
||||
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 -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 ──────────────────────────────────────────────────────────
|
||||
# Each daemon knows its own id from the state_snapshot.
|
||||
# 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')
|
||||
@@ -236,21 +298,46 @@ log "$ANCHOR_COLOR" "ids" "bob = ${BOB_ID:0:16}…"
|
||||
log "$ANCHOR_COLOR" "ids" "charlie = ${CHARLIE_ID:0:16}…"
|
||||
echo ""
|
||||
|
||||
# ── join network ──────────────────────────────────────────────────────────────
|
||||
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"
|
||||
# ── 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"
|
||||
|
||||
echo -e "${DIM}waiting for ICE / DataChannel setup (up to 10s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
# ── 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 "YAW/2.1 forward-secret signaling check"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
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}"
|
||||
@@ -309,6 +396,117 @@ ipc "$ALICE_IPC" "$(jq -cn \
|
||||
'{"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 ─────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
@@ -323,15 +521,15 @@ echo -e "${DIM}─────────────────────
|
||||
echo -e "verifying persistence (SQLite)"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
for peer in alice bob charlie; do
|
||||
db="$DATA_ROOT/$peer/messages.db"
|
||||
if [ -f "$db" ]; then
|
||||
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}"
|
||||
echo -e " ${RED}${peer}: no messages-*.db found${RESET}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
54
test-tui.sh
@@ -19,6 +19,28 @@ 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
|
||||
@@ -75,36 +97,42 @@ wait_port "$ANCHOR_PORT"
|
||||
|
||||
ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws"
|
||||
|
||||
# Daemons
|
||||
# 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>/dev/null &
|
||||
-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>/dev/null &
|
||||
-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>/dev/null &
|
||||
-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"
|
||||
|
||||
# Resolve peer IDs
|
||||
# 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')
|
||||
|
||||
# Join all three
|
||||
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 WebRTC DataChannels (6s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
25
web/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!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="#00e87a" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<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": "#080808",
|
||||
"theme_color": "#00e87a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
254
web/src/App.css
Normal file
@@ -0,0 +1,254 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #080808;
|
||||
--surface: #0e0e0e;
|
||||
--border: rgba(255, 255, 255, 0.09);
|
||||
--accent: #00e87a;
|
||||
--accent-dim: rgba(0, 232, 122, 0.12);
|
||||
--accent-border: rgba(0, 232, 122, 0.3);
|
||||
--text: #c8c8c8;
|
||||
--heading: #f0f0f0;
|
||||
--muted: #666;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||
--sidebar-w: 220px;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 14px; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
button { cursor: pointer; background: var(--accent); color: #000; border: 1px solid var(--accent); border-radius: 6px; padding: 4px 10px; font-size: 13px; font-family: var(--mono); font-weight: 600; transition: all 0.15s; }
|
||||
button:hover:not(:disabled) { background: #00ff88; border-color: #00ff88; }
|
||||
button:active:not(:disabled) { transform: scale(0.98); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
input { background: var(--surface); color: var(--heading); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: var(--mono); outline: none; width: 100%; }
|
||||
input:focus { border-color: var(--accent-border); }
|
||||
|
||||
/* ── 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-family: var(--mono); font-size: 2rem; font-weight: 700; letter-spacing: -0.5px; color: var(--heading); margin-bottom: 4px; }
|
||||
.onboarding h1::before { content: '> '; color: var(--muted); font-weight: 400; }
|
||||
.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); }
|
||||
.saved-networks { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.saved-network-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.network-chip { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 20px; padding: 4px 14px; font-size: 13px; }
|
||||
.network-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
|
||||
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: #000; font-weight: 600; }
|
||||
.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: 72px; }
|
||||
.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: var(--accent-dim); }
|
||||
|
||||
/* ── 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; }
|
||||
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
|
||||
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
/* ── message links + image preview ── */
|
||||
.msg-link { color: var(--accent); text-decoration: underline; word-break: break-all; }
|
||||
.msg-link:hover { opacity: 0.8; }
|
||||
.message-text { display: flex; flex-direction: column; gap: 4px; }
|
||||
.msg-image-preview { max-width: 320px; max-height: 200px; border-radius: 6px; border: 1px solid var(--border); margin-top: 4px; object-fit: contain; display: block; }
|
||||
|
||||
/* ── reactions ── */
|
||||
.message-wrapper { display: flex; flex-direction: column; padding: 0; }
|
||||
.message-wrapper .message { padding: 2px 16px; }
|
||||
.reaction-add { background: none; color: var(--muted); font-size: 13px; padding: 0 4px; line-height: 1; opacity: 0; transition: opacity 0.1s; margin-left: 4px; flex-shrink: 0; }
|
||||
.message-wrapper:hover .reaction-add { opacity: 1; }
|
||||
.reaction-add:hover { color: var(--accent); background: none; }
|
||||
.reaction-picker { display: flex; gap: 4px; padding: 4px 16px 2px; }
|
||||
.reaction-picker-btn { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 18px; padding: 2px 6px; line-height: 1.4; cursor: pointer; }
|
||||
.reaction-picker-btn:hover { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.reaction-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 16px 4px; }
|
||||
.reaction-chip { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; padding: 1px 8px; cursor: pointer; color: var(--text); }
|
||||
.reaction-chip:hover { border-color: var(--accent); background: rgba(0,232,122,0.1); }
|
||||
.reaction-chip.mine { border-color: var(--accent); background: rgba(0,232,122,0.18); }
|
||||
|
||||
/* ── mobile hamburger / close buttons ── */
|
||||
.menu-btn-mobile { display: none; background: none; color: var(--muted); font-size: 18px; padding: 0 8px 0 0; line-height: 1; }
|
||||
.menu-btn-mobile:hover { color: var(--text); background: none; }
|
||||
.sidebar-close-mobile { display: none; background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; }
|
||||
.sidebar-close-mobile:hover { color: var(--text); background: none; }
|
||||
|
||||
/* ── responsive layout ── */
|
||||
@media (max-width: 600px) {
|
||||
:root { --sidebar-w: 80vw; }
|
||||
|
||||
.chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* sidebar slides in over the top */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: var(--sidebar-w);
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.22s ease;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.chat-layout.sidebar-open .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* dim overlay behind open sidebar */
|
||||
.chat-layout.sidebar-open::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.menu-btn-mobile { display: inline-block; }
|
||||
.sidebar-close-mobile { display: inline-block; }
|
||||
|
||||
/* message pane fills full width */
|
||||
.chat-layout > .message-pane { grid-column: 1; }
|
||||
|
||||
/* file browser stacks below on mobile */
|
||||
.chat-layout.has-file-browser { grid-template-columns: 1fr; }
|
||||
.chat-layout.has-file-browser > .file-browser { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; overflow-y: auto; }
|
||||
|
||||
/* slightly larger tap targets */
|
||||
.sidebar-item { padding: 8px 12px; font-size: 14px; }
|
||||
.peer-row { padding: 6px 12px; }
|
||||
.peer-row-actions { opacity: 1; }
|
||||
.peer-action { font-size: 18px; padding: 2px 6px; }
|
||||
|
||||
/* message layout: stack alias above text on very narrow screens */
|
||||
.message { flex-wrap: wrap; }
|
||||
.message-ts { width: auto; min-width: 56px; }
|
||||
.message-alias { width: auto; }
|
||||
}
|
||||
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 />
|
||||
}
|
||||
1016
web/src/adapter/browser.ts
Normal file
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>
|
||||
)
|
||||
}
|
||||
193
web/src/components/MessagePane.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayMs = today.getTime()
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
if (ts >= todayMs) return time
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time
|
||||
}
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
||||
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
|
||||
|
||||
function renderText(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = []
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
URL_RE.lastIndex = 0
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
if (m.index > last) parts.push(text.slice(last, m.index))
|
||||
const url = m[0]
|
||||
const isImage = IMAGE_EXT_RE.test(url) || url.startsWith('blob:') || url.startsWith('data:image')
|
||||
parts.push(
|
||||
<a key={m.index} href={url} target="_blank" rel="noopener noreferrer" className="msg-link">
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
if (isImage) {
|
||||
parts.push(
|
||||
<img key={`img-${m.index}`} src={url} alt="" className="msg-image-preview" loading="lazy" />
|
||||
)
|
||||
}
|
||||
last = m.index + url.length
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last))
|
||||
return parts.length > 1 ? <>{parts}</> : text
|
||||
}
|
||||
|
||||
const EMOJI_SET = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||
|
||||
export function MessagePane({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, reactions, sendReaction, send } = useWaste()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [pickerMid, setPickerMid] = useState<string | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
|
||||
const roomMessages = messages[msgKey] ?? []
|
||||
const cutoff = historyCutoff[msgKey] ?? 0
|
||||
|
||||
const firstLiveIdx = cutoff > 0
|
||||
? roomMessages.findIndex(m => m.ts > cutoff)
|
||||
: -1
|
||||
const dividerIdx = cutoff > 0
|
||||
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
|
||||
: -1
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [roomMessages.length])
|
||||
|
||||
// Close picker when clicking outside
|
||||
useEffect(() => {
|
||||
if (!pickerMid) return
|
||||
const handler = () => setPickerMid(null)
|
||||
document.addEventListener('click', handler)
|
||||
return () => document.removeEventListener('click', handler)
|
||||
}, [pickerMid])
|
||||
|
||||
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
|
||||
?? knownPeers[fromId]
|
||||
?? fromId.slice(0, 8)
|
||||
}
|
||||
|
||||
function toggleReaction(mid: string, emoji: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (!activeNetworkId || !mid) return
|
||||
sendReaction(activeNetworkId, mid, emoji)
|
||||
setPickerMid(null)
|
||||
}
|
||||
|
||||
function openPicker(mid: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setPickerMid(prev => prev === mid ? null : mid)
|
||||
}
|
||||
|
||||
const roomLabel = activeRoom.startsWith('dm:')
|
||||
? `@ ${activeRoom.slice(3, 11)}…`
|
||||
: `# ${activeRoom}`
|
||||
|
||||
return (
|
||||
<main className="message-pane">
|
||||
<div className="message-pane-header">
|
||||
<button className="menu-btn-mobile" onClick={onMenuClick} aria-label="Menu">☰</button>
|
||||
{roomLabel}
|
||||
</div>
|
||||
|
||||
<div className="messages">
|
||||
{dividerIdx === 0 && (
|
||||
<div className="history-divider"><span>earlier messages</span></div>
|
||||
)}
|
||||
{roomMessages.map((msg, i) => {
|
||||
const mine = msg.from === localPeer?.id
|
||||
const alias = aliasFor(String(msg.from))
|
||||
const ts = formatTs(msg.ts)
|
||||
const mid = msg.mid ?? ''
|
||||
const msgReactions = mid ? reactions[mid] : undefined
|
||||
const hasReactions = msgReactions && Object.keys(msgReactions).length > 0
|
||||
return (
|
||||
<div key={mid || i} className="message-wrapper">
|
||||
{i === dividerIdx && dividerIdx > 0 && (
|
||||
<div className="history-divider"><span>earlier messages</span></div>
|
||||
)}
|
||||
<div className={`message ${mine ? 'mine' : ''}`}>
|
||||
<span className="message-ts">{ts}</span>
|
||||
<span className="message-alias">{alias}</span>
|
||||
<span className="message-text">
|
||||
{renderText(msg.text)}
|
||||
</span>
|
||||
{mid && (
|
||||
<button
|
||||
className="reaction-add"
|
||||
onClick={e => openPicker(mid, e)}
|
||||
title="React"
|
||||
>+</button>
|
||||
)}
|
||||
</div>
|
||||
{pickerMid === mid && (
|
||||
<div className="reaction-picker" onClick={e => e.stopPropagation()}>
|
||||
{EMOJI_SET.map(emoji => (
|
||||
<button key={emoji} className="reaction-picker-btn" onClick={e => toggleReaction(mid, emoji, e)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasReactions && (
|
||||
<div className="reaction-bar">
|
||||
{Object.entries(msgReactions!).map(([emoji, fromIds]) => {
|
||||
const iMine = fromIds.includes(localPeer?.id ?? '')
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
className={`reaction-chip ${iMine ? 'mine' : ''}`}
|
||||
onClick={e => toggleReaction(mid, emoji, e)}
|
||||
title={fromIds.map(aliasFor).join(', ')}
|
||||
>
|
||||
{emoji} {fromIds.length}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
250
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
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({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
localPeer, masterId, masterAlias,
|
||||
networks, activeNetworkId, activeRoom,
|
||||
connectedPeers, peerStatus,
|
||||
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||
customRooms, createRoom, logout, send,
|
||||
} = useWaste()
|
||||
const [addingRoom, setAddingRoom] = useState(false)
|
||||
const [newRoomName, setNewRoomName] = useState('')
|
||||
const [addingNetwork, setAddingNetwork] = useState(false)
|
||||
const [newNetName, setNewNetName] = useState('')
|
||||
const [newNetAnchor, setNewNetAnchor] = useState('')
|
||||
|
||||
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
||||
const rooms = ['general', ...netCustomRooms]
|
||||
if (activeNetworkId) {
|
||||
const prefix = `${activeNetworkId}:dm:`
|
||||
Object.keys(messages).forEach(k => {
|
||||
if (k.startsWith(prefix)) {
|
||||
const r = k.slice(activeNetworkId.length + 1)
|
||||
if (!rooms.includes(r)) rooms.push(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function submitNewRoom(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (newRoomName.trim()) createRoom(newRoomName)
|
||||
setNewRoomName('')
|
||||
setAddingRoom(false)
|
||||
}
|
||||
|
||||
function submitNewNetwork(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = newNetName.trim()
|
||||
if (!name) return
|
||||
if (adapterMode === 'browser') {
|
||||
// Persist to saved networks list in localStorage.
|
||||
const anchor = newNetAnchor.trim() || localStorage.getItem('waste_anchor_url') || ''
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (!saved.some(n => n.name === name && n.anchor === anchor)) {
|
||||
saved.push({ name, anchor })
|
||||
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||
}
|
||||
}
|
||||
send({ type: 'join_network', network_name: name })
|
||||
setNewNetName('')
|
||||
setNewNetAnchor('')
|
||||
setAddingNetwork(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>
|
||||
<button className="sidebar-close-mobile" onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
<span style={{ display: 'flex', gap: 2 }}>
|
||||
{activeNetworkId && (
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link">🔗</button>
|
||||
)}
|
||||
<button className="sidebar-add" onClick={() => setAddingNetwork(v => !v)} title="Join network">+</button>
|
||||
</span>
|
||||
</div>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
||||
onClick={() => { setActiveNetwork(n.network_id); onClose() }}
|
||||
>
|
||||
{n.network_name}
|
||||
</button>
|
||||
))}
|
||||
{addingNetwork && (
|
||||
<form className="sidebar-new-room" onSubmit={submitNewNetwork}>
|
||||
<input
|
||||
autoFocus
|
||||
value={newNetName}
|
||||
onChange={e => setNewNetName(e.target.value)}
|
||||
placeholder="network name"
|
||||
onKeyDown={e => e.key === 'Escape' && (setAddingNetwork(false), setNewNetName(''))}
|
||||
/>
|
||||
{adapterMode === 'browser' && (
|
||||
<input
|
||||
value={newNetAnchor}
|
||||
onChange={e => setNewNetAnchor(e.target.value)}
|
||||
placeholder="anchor URL (blank = current)"
|
||||
className="mono"
|
||||
style={{ fontSize: '0.75rem', marginTop: 4 }}
|
||||
/>
|
||||
)}
|
||||
<button type="submit" disabled={!newNetName.trim()} style={{ marginTop: 4, width: '100%', fontSize: '12px', padding: '3px 8px' }}>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</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); onClose() }}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
70
web/src/components/Transfers.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||
|
||||
const hasPending = Object.keys(pendingOffers).length > 0
|
||||
const hasActive = Object.keys(fileProgress).length > 0
|
||||
const hasResumable = Object.keys(resumableFiles).length > 0
|
||||
|
||||
if (!hasPending && !hasActive && !hasResumable) 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>
|
||||
|
||||
{hasResumable && (
|
||||
<>
|
||||
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
|
||||
{Object.entries(resumableFiles).map(([sha256, f]) => {
|
||||
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
|
||||
return (
|
||||
<div key={sha256} className="transfer-row">
|
||||
<span className="transfer-name" title={f.name}>{f.name}</span>
|
||||
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
|
||||
<div className="transfer-progress">
|
||||
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
95
web/src/index.css
Normal file
@@ -0,0 +1,95 @@
|
||||
:root {
|
||||
--text: #c8c8c8;
|
||||
--text-h: #f0f0f0;
|
||||
--bg: #080808;
|
||||
--border: rgba(255, 255, 255, 0.09);
|
||||
--code-bg: #0e0e0e;
|
||||
--accent: #00e87a;
|
||||
--accent-bg: rgba(0, 232, 122, 0.12);
|
||||
--accent-border: rgba(0, 232, 122, 0.3);
|
||||
--social-bg: rgba(14, 14, 14, 0.5);
|
||||
--shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04), 0 4px 24px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--mono: 'JetBrains Mono', ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: 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;
|
||||
}
|
||||
}
|
||||
|
||||
#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>,
|
||||
)
|
||||
17
web/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
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()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
return (
|
||||
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}${sidebarOpen ? ' sidebar-open' : ''}`}>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
<MessagePane onMenuClick={() => setSidebarOpen(v => !v)} />
|
||||
{activeFilePeer && <FileBrowser />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
342
web/src/pages/Onboarding.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
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
|
||||
// Rejoin all saved networks.
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (saved.length > 0) {
|
||||
saved.forEach(s => doJoin(s.name, ''))
|
||||
} else {
|
||||
// Legacy single-network fallback.
|
||||
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) {
|
||||
// Persist to saved networks list.
|
||||
const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
if (!saved.some(n => n.name === name)) {
|
||||
saved.push({ name, anchor: anchorUrl })
|
||||
localStorage.setItem('waste_saved_networks', JSON.stringify(saved))
|
||||
}
|
||||
// Keep legacy key for backward compat.
|
||||
localStorage.setItem('waste_last_network', name)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const savedNetworks: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]')
|
||||
|
||||
// ── 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>
|
||||
)}
|
||||
|
||||
{adapterMode === 'browser' && savedNetworks.length > 0 && (
|
||||
<div className="saved-networks">
|
||||
<span className="join-label">Saved networks</span>
|
||||
<div className="saved-network-chips">
|
||||
{savedNetworks.map(n => (
|
||||
<button key={n.name} className="network-chip" type="button" onClick={() => doJoin(n.name, '')}>
|
||||
{n.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={joinNetwork} className="join-form">
|
||||
<label className="join-label">{savedNetworks.length > 0 ? 'Join another network' : '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>
|
||||
)
|
||||
}
|
||||
419
web/src/store/index.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
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[]
|
||||
knownPeers: Record<string, string> // id → alias for historical peers
|
||||
|
||||
// chat — keyed by room
|
||||
messages: Record<string, ChatMessage[]>
|
||||
// rooms for which we have received history: room → ts of last history message
|
||||
historyCutoff: Record<string, number>
|
||||
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 }>
|
||||
// partial downloads found on daemon startup: sha256 → info
|
||||
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
|
||||
// reactions: mid → emoji → [fromId, ...]
|
||||
reactions: Record<string, Record<string, string[]>>
|
||||
|
||||
// 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
|
||||
sendReaction: (networkId: string, mid: string, emoji: 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: [],
|
||||
knownPeers: {},
|
||||
messages: {},
|
||||
historyCutoff: {},
|
||||
activeRoom: 'general',
|
||||
customRooms: {},
|
||||
fileLists: {},
|
||||
exportedBackup: null,
|
||||
peerStatus: {},
|
||||
activeFilePeer: null,
|
||||
sharedFilesByNetwork: {},
|
||||
pendingOffers: {},
|
||||
fileProgress: {},
|
||||
resumableFiles: {},
|
||||
reactions: {},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
sendReaction(networkId, mid, emoji) {
|
||||
get().send({ type: 'send_reaction', network_id: networkId, reaction_mid: mid, reaction_emoji: emoji })
|
||||
},
|
||||
|
||||
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 ?? []
|
||||
const knownPeers: Record<string, string> = {}
|
||||
for (const p of msg.known_peers ?? []) knownPeers[p.id] = p.alias
|
||||
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,
|
||||
knownPeers,
|
||||
})
|
||||
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 key = `${msg.network_id}:${m.room}`
|
||||
const fromId = String(m.from)
|
||||
set(s => {
|
||||
const existing = s.messages[key] ?? []
|
||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||
const prev = s.peerStatus[fromId] ?? {}
|
||||
return {
|
||||
messages: { ...s.messages, [key]: [...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': {
|
||||
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
|
||||
const xid = msg.transfer_id ?? msg.offer?.xid
|
||||
if (xid) {
|
||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
||||
}
|
||||
// Browser mode: trigger download via anchor click.
|
||||
if (msg.path && msg.offer?.name) {
|
||||
const a = document.createElement('a')
|
||||
a.href = msg.path
|
||||
a.download = msg.offer.name
|
||||
a.click()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'resumable_transfers': {
|
||||
const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||
if (files.length === 0) break
|
||||
const byHash: Record<string, { name: string; from: string; size: number; offset: number }> = {}
|
||||
for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset }
|
||||
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
|
||||
break
|
||||
}
|
||||
case 'reaction': {
|
||||
const mid = msg.reaction_mid
|
||||
const emoji = msg.reaction_emoji
|
||||
const from = msg.peer_id
|
||||
if (!mid || !emoji || !from) break
|
||||
set(s => {
|
||||
const byEmoji = { ...(s.reactions[mid] ?? {}) }
|
||||
const existing = byEmoji[emoji] ?? []
|
||||
if (existing.includes(from)) return s
|
||||
return { reactions: { ...s.reactions, [mid]: { ...byEmoji, [emoji]: [...existing, from] } } }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'history_loaded': {
|
||||
const room = msg.room
|
||||
const incoming = (msg.messages ?? []) as ChatMessage[]
|
||||
if (!room || incoming.length === 0) break
|
||||
const key = `${msg.network_id}:${room}`
|
||||
set(s => {
|
||||
const existing = s.messages[key] ?? []
|
||||
const existingMids = new Set(existing.map(m => m.mid).filter(Boolean))
|
||||
const fresh = incoming.filter(m => !m.mid || !existingMids.has(m.mid))
|
||||
if (fresh.length === 0) return s
|
||||
const merged = [...fresh, ...existing].sort((a, b) => a.ts - b.ts)
|
||||
const cutoff = fresh[fresh.length - 1]?.ts ?? 0
|
||||
return {
|
||||
messages: { ...s.messages, [key]: merged },
|
||||
historyCutoff: { ...s.historyCutoff, [key]: cutoff },
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
141
web/src/types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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'
|
||||
| 'history_loaded'
|
||||
| 'room_created'
|
||||
| 'create_room'
|
||||
| 'resumable_transfers'
|
||||
| 'send_reaction'
|
||||
| 'reaction'
|
||||
|
||||
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[]
|
||||
known_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
|
||||
// history_loaded
|
||||
messages?: ChatMessage[]
|
||||
// resumable_transfers
|
||||
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||
// reaction
|
||||
reaction_mid?: string
|
||||
reaction_emoji?: 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,
|
||||
},
|
||||
})
|
||||