Compare commits
25 Commits
0e8ddbf4f4
...
v.0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
81
.gitea/workflows/build.yml
Normal file
81
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
|
- name: Install platform dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -q
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev
|
||||||
|
|
||||||
|
# ── Server binaries (CGO_ENABLED=0, cross-compile freely) ──────────────
|
||||||
|
|
||||||
|
- name: Build server binaries
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
build() {
|
||||||
|
local GOOS=$1 GOARCH=$2
|
||||||
|
local SUFFIX="${GOOS}-${GOARCH}"
|
||||||
|
local EXT=""
|
||||||
|
[ "$GOOS" = "windows" ] && EXT=".exe"
|
||||||
|
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||||
|
go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
|
||||||
|
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||||
|
go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
|
||||||
|
}
|
||||||
|
build linux amd64
|
||||||
|
build linux arm64
|
||||||
|
build darwin amd64
|
||||||
|
build darwin arm64
|
||||||
|
build windows amd64
|
||||||
|
|
||||||
|
# ── Desktop app (Linux amd64, CGo + Wails) ─────────────────────────────
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cp -r dist ../cmd/app/frontend/dist
|
||||||
|
|
||||||
|
- name: Build desktop app
|
||||||
|
run: |
|
||||||
|
cd cmd/app
|
||||||
|
wails build -trimpath -ldflags="-s -w" -tags webkit2_41 -o ../../dist/waste-linux-amd64
|
||||||
|
|
||||||
|
# ── Publish release (tags only) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
files: dist/*
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,10 +2,12 @@
|
|||||||
setup-anchor.sh
|
setup-anchor.sh
|
||||||
launch-tui.sh
|
launch-tui.sh
|
||||||
*.exe
|
*.exe
|
||||||
# compiled binary names that land in the repo root
|
# compiled binaries that land in the repo root
|
||||||
/anchor
|
/anchor
|
||||||
/waste-anchor
|
/waste-anchor
|
||||||
/waste-daemon
|
/waste-daemon
|
||||||
|
/waste
|
||||||
|
/app
|
||||||
/relay
|
/relay
|
||||||
*.identity.json
|
*.identity.json
|
||||||
/tmp/
|
/tmp/
|
||||||
@@ -20,3 +22,5 @@ deploy-web.sh
|
|||||||
deploy-daemon.sh
|
deploy-daemon.sh
|
||||||
serve-web.sh
|
serve-web.sh
|
||||||
web/public/config.js
|
web/public/config.js
|
||||||
|
cmd/app/frontend/dist/*
|
||||||
|
!cmd/app/frontend/dist/.gitkeep
|
||||||
|
|||||||
306
EXTENSIONS.md
Normal file
306
EXTENSIONS.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# waste-go Protocol Extensions
|
||||||
|
|
||||||
|
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
|
||||||
|
They do **not** break compatibility — YAW/2-only peers silently ignore all new
|
||||||
|
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
|
||||||
|
simply has no effect on that peer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-001 — Signed Invites
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `waste:` invite format, `hello` DataChannel message
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
The base YAW/2 network model is open to anyone who knows the anchor URL and
|
||||||
|
network name (or hash). This extension adds opt-in cryptographic membership
|
||||||
|
gating: invites are signed by an existing peer, and peers that enforce
|
||||||
|
`RequireInvite` reject hellos that carry no valid signed invite.
|
||||||
|
|
||||||
|
### Invite format changes
|
||||||
|
|
||||||
|
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"anchor": "wss://...",
|
||||||
|
"network": "friends",
|
||||||
|
"net": "<64-hex SHA-256(yaw2-net:name)>",
|
||||||
|
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
|
||||||
|
"sig": "<hex Ed25519 signature>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The signature covers the following bytes (null-separated):
|
||||||
|
|
||||||
|
```
|
||||||
|
anchor \x00 network \x00 net \x00 inviter
|
||||||
|
```
|
||||||
|
|
||||||
|
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
|
||||||
|
|
||||||
|
### Hello message extension
|
||||||
|
|
||||||
|
The YAW/2 §6 hello message gains one optional field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"id": "<hex pubkey>",
|
||||||
|
"nick": "alice",
|
||||||
|
"caps": ["chat", "file"],
|
||||||
|
"sig": "<DTLS binding sig>",
|
||||||
|
"invite": "waste:eyJ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`invite` carries the full `waste:` string the connecting peer used to join.
|
||||||
|
YAW/2-only peers ignore this field.
|
||||||
|
|
||||||
|
### Enforcement
|
||||||
|
|
||||||
|
Per-network flag `RequireInvite` (set via `join_network` IPC command).
|
||||||
|
When enabled:
|
||||||
|
|
||||||
|
1. A peer that presents no `invite` in hello is disconnected immediately.
|
||||||
|
2. A peer that presents an invite with no signature is disconnected.
|
||||||
|
3. A peer whose invite signature is invalid is disconnected.
|
||||||
|
4. A peer whose invite was signed by an unknown peer ID (not in the store or
|
||||||
|
currently connected) is disconnected.
|
||||||
|
|
||||||
|
The inviter's key must be a **known peer** — i.e. previously connected and
|
||||||
|
stored in the per-network SQLite store, or currently connected. This forms a
|
||||||
|
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
|
||||||
|
invite Carol, whose invite Alice will also accept.
|
||||||
|
|
||||||
|
**Default:** off. Networks opt in. Existing networks with no RequireInvite
|
||||||
|
behave exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-002 — Hash-based Hang Link
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** web UI URL handling only, no wire changes
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
A shareable URL that pre-fills the join form without conveying cryptographic
|
||||||
|
membership. Suitable for public announcements ("come hang out here"). The
|
||||||
|
fragment is never sent to the server, keeping the network name opaque to
|
||||||
|
server logs and HTTP intermediaries.
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
```
|
||||||
|
https://host/#waste:eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The fragment payload is the standard `waste:` base64 JSON with only `network`
|
||||||
|
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
|
||||||
|
membership on networks with `RequireInvite` enabled; it only pre-fills the
|
||||||
|
join form.
|
||||||
|
|
||||||
|
The web UI generates hang links via the 🔗 button in the Networks sidebar
|
||||||
|
section. Arriving users see the join form pre-populated and still need a
|
||||||
|
proper signed invite (if the network enforces it) to be accepted by peers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-003 — Multi-Share Configuration
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** IPC protocol only, no peer-to-peer wire changes
|
||||||
|
|
||||||
|
### New IPC commands
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"add_share","path":"/home/alice/Music"} // global
|
||||||
|
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped
|
||||||
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
|
{"type":"list_shares"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New IPC event
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
`shares.json` in the data directory (next to `identity.json`). Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "path": "/absolute/path", "networks": ["*"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
|
||||||
|
Coexists with the legacy `set_share_dir` single-dir mechanism.
|
||||||
|
|
||||||
|
File listings returned by `get_file_list` and `MsgFileListReq` include
|
||||||
|
entries from all applicable share roots, with relative `path` fields
|
||||||
|
(e.g. `"path": "docs/report.pdf"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-004 — TURN Relay (browser mode)
|
||||||
|
|
||||||
|
**Status:** implemented (browser mode + daemon mode)
|
||||||
|
**Affects:** ICE server configuration only, no wire changes
|
||||||
|
|
||||||
|
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
|
||||||
|
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
|
||||||
|
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
|
||||||
|
|
||||||
|
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
|
||||||
|
opt-in via server configuration and does not affect peers that omit it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-005 — Per-Network Path in FileEntry
|
||||||
|
|
||||||
|
**Status:** implemented
|
||||||
|
**Affects:** `MsgFileListResp` wire message (additive field)
|
||||||
|
|
||||||
|
`FileEntry` gains an optional `path` field carrying the file's relative path
|
||||||
|
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
|
||||||
|
this field continue to use `name` for display and download requests.
|
||||||
|
|
||||||
|
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
|
||||||
|
falling back to `name` for backward compat with peers that don't send `path`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-006 — File Transfer Resume
|
||||||
|
|
||||||
|
**Status:** implemented (daemon mode)
|
||||||
|
**Affects:** `file-accept` wire message (additive field)
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
A transfer interrupted mid-stream (peer disconnect, network drop) can be
|
||||||
|
continued from where it left off on the next offer of the same file, avoiding
|
||||||
|
a full re-download.
|
||||||
|
|
||||||
|
### Protocol change
|
||||||
|
|
||||||
|
`file-accept` gains one optional field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "file-accept", "xid": "...", "resume_offset": 65536 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`resume_offset` is the number of bytes the receiver already has on disk.
|
||||||
|
When non-zero, the sender seeks to that byte position before streaming.
|
||||||
|
Peers that don't understand this field ignore it and send from the start —
|
||||||
|
the receiver detects this by comparing incoming data to expected offset and
|
||||||
|
will still verify the final SHA-256, but the partial bytes from the interrupted
|
||||||
|
session will be overwritten (YAW/2-only interop degrades gracefully to a full
|
||||||
|
re-download, not corruption).
|
||||||
|
|
||||||
|
### Receiver behaviour
|
||||||
|
|
||||||
|
1. On `file-offer`, the receiver scans its download directory for a `.tmp.meta`
|
||||||
|
sidecar whose `sha256` matches the offer.
|
||||||
|
2. If found, the corresponding `.tmp` file's size is the resume offset. This is
|
||||||
|
sent back in `file-accept`.
|
||||||
|
3. On DC open, the receiver opens the existing `.tmp` in append mode and
|
||||||
|
re-hashes its existing bytes to restore the SHA-256 state.
|
||||||
|
4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar
|
||||||
|
removed, file renamed to final path. Hash mismatch → both files removed.
|
||||||
|
5. On DC close with fewer bytes than expected (interrupted again) → both files
|
||||||
|
kept for the next resume attempt.
|
||||||
|
|
||||||
|
### Sidecar format
|
||||||
|
|
||||||
|
Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "archive.zip", "sha256": "abc...", "from": "<peer-id>", "size": 1048576 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar is written when the transfer starts and removed on completion or
|
||||||
|
corruption. Interrupted transfers keep the sidecar indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXT-007 — P2P Message History Gossip
|
||||||
|
|
||||||
|
**Status:** implemented (daemon mode)
|
||||||
|
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
When a peer joins a network for the first time (or reconnects after an
|
||||||
|
absence), they have no history. This extension lets them request recent
|
||||||
|
messages from an existing peer over the already-established encrypted
|
||||||
|
DataChannel, without involving the anchor.
|
||||||
|
|
||||||
|
### Wire messages
|
||||||
|
|
||||||
|
#### `history_request`
|
||||||
|
|
||||||
|
Sent by the newly-connected peer to the first peer whose hello is verified.
|
||||||
|
One request per room.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "history_request",
|
||||||
|
"room": "general",
|
||||||
|
"since": 1700000000000,
|
||||||
|
"limit": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---------|---------------|-------------|
|
||||||
|
| `room` | string | Room to request history for. |
|
||||||
|
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
|
||||||
|
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
|
||||||
|
|
||||||
|
#### `history_chunk`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "history_chunk",
|
||||||
|
"room": "general",
|
||||||
|
"history": [
|
||||||
|
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
|
||||||
|
],
|
||||||
|
"history_done": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| `history` | array | Messages, oldest-first. |
|
||||||
|
| `history_done` | bool | Always `true` (single-chunk response). |
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
|
||||||
|
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
|
||||||
|
so receiving a message twice (live or via gossip) is a no-op. Messages
|
||||||
|
without a `mid` are assigned one at receive time and are not gossipped.
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- The **receiver** sends one `history_request` per known room immediately
|
||||||
|
after hello verification with the **first** peer it connects to. Requesting
|
||||||
|
only the first peer avoids fan-out amplification.
|
||||||
|
- The **responder** queries its SQLite store and replies with a single
|
||||||
|
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
|
||||||
|
request per (peer, room) per 60 seconds.
|
||||||
|
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
|
||||||
|
and emitted as `history_loaded` IPC events so the UI can display them.
|
||||||
|
|
||||||
|
### IPC event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "history_loaded", "room": "general", "messages": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Emitted once per room after a `history_chunk` is fully processed. The UI
|
||||||
|
should render these messages with a visual separator from live messages.
|
||||||
44
FUTURE.md
44
FUTURE.md
@@ -38,12 +38,12 @@ React + Vite frontend. Two modes:
|
|||||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
### 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.
|
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.
|
||||||
|
|
||||||
### TURN relay ✅ (shipped, browser mode)
|
### TURN relay ✅ (shipped)
|
||||||
Browser mode now supports TURN relay. `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. Mobile/CGNAT peers that fail STUN hole-punching automatically fall back to TURN relay.
|
Both browser and daemon modes support TURN relay.
|
||||||
|
|
||||||
The peer dot in the sidebar turns yellow for relayed connections (`candidate_type: relay`).
|
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
|
||||||
|
|
||||||
**Daemon mode:** not yet wired. Add `-turn-url` / `-turn-secret` flags to `cmd/daemon/main.go` and pass them into the `ICEServers` slice in `internal/anchor/client.go`.
|
**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.
|
||||||
|
|
||||||
### Signaling ✅ YAW/2.1 (shipped)
|
### 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.
|
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.
|
||||||
@@ -76,10 +76,14 @@ Multiple share roots per network, with global (all networks) or scoped visibilit
|
|||||||
- **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.
|
- **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.
|
- **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, web UI)
|
### Additional Channels / Rooms ✅ (shipped)
|
||||||
The `+` button in the Rooms sidebar section 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. DM rooms (`dm:<peerId>`) appear automatically when messages arrive.
|
Custom rooms are supported in both the web UI and the TUI.
|
||||||
|
|
||||||
**Not yet done:** TUI room creation, daemon-side SQLite persistence of room lists across restarts.
|
**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)
|
### File Transfer UX ✅ (shipped)
|
||||||
- Manual accept/reject via the Transfers panel in the sidebar
|
- Manual accept/reject via the Transfers panel in the sidebar
|
||||||
@@ -87,13 +91,10 @@ The `+` button in the Rooms sidebar section creates custom rooms, stored in `cus
|
|||||||
- Live progress bar per active transfer
|
- Live progress bar per active transfer
|
||||||
- Push (📎) sends directly to a peer without them needing to share a folder
|
- Push (📎) sends directly to a peer without them needing to share a folder
|
||||||
|
|
||||||
**Not yet done:** resume after disconnection, daemon-side download directory.
|
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.
|
||||||
|
|
||||||
### TURN Relay for Daemon Mode
|
|
||||||
The daemon doesn't yet support TURN. Add `-turn-url` and `-turn-secret` flags to `cmd/daemon/main.go` and wire them into the ICE server list in `internal/anchor/client.go`. The credential generation is the same HMAC-SHA1 scheme already implemented in browser mode.
|
|
||||||
|
|
||||||
### Native UI
|
### Native UI
|
||||||
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,10 +122,21 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
|||||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||||
| next | TURN relay for daemon mode |
|
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||||
| next | TUI room creation + daemon-side room persistence |
|
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||||
| next | File transfer resume after disconnection |
|
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||||
| future | Native UI (React + Tauri) |
|
| ✅ 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
89
QUICKSTART.md
Normal file
89
QUICKSTART.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# waste — quick start
|
||||||
|
|
||||||
|
waste is a private, encrypted chat and file sharing app for people you trust.
|
||||||
|
No accounts, no phone numbers, no central server that knows your messages.
|
||||||
|
|
||||||
|
Pick the option that fits you best.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1 — Just open it in your browser
|
||||||
|
|
||||||
|
If someone is running a waste anchor server and has shared the URL with you:
|
||||||
|
|
||||||
|
1. Open the URL in any modern browser
|
||||||
|
2. Enter your name and a network name your group has agreed on
|
||||||
|
3. Done — you're in
|
||||||
|
|
||||||
|
On mobile, tap **Share → Add to Home Screen** to install it as an app icon.
|
||||||
|
|
||||||
|
To invite someone: click the 🔗 button in the sidebar and share the link.
|
||||||
|
|
||||||
|
> Your identity and messages stay in your browser. Nothing is stored on the server — the server only helps peers find each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2 — Desktop app (recommended for regular use)
|
||||||
|
|
||||||
|
Download the latest `waste` binary for your platform from the [releases page](../../releases).
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
```bash
|
||||||
|
chmod +x waste-linux-amd64 # or waste-darwin-arm64, etc.
|
||||||
|
./waste-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:** double-click `waste-windows-amd64.exe`.
|
||||||
|
|
||||||
|
The app opens a window with the waste UI. Enter your name, the anchor URL, and a network name to join. Your identity is saved between sessions in your config directory (`~/.config/waste` on Linux, `~/Library/Application Support/waste` on macOS, `%APPDATA%\waste` on Windows).
|
||||||
|
|
||||||
|
On Linux and Windows a tray icon appears — closing the window hides to tray rather than quitting. Right-click the tray icon to reopen or quit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3 — Run the daemon manually (headless / power users)
|
||||||
|
|
||||||
|
If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download waste-daemon from the releases page, then:
|
||||||
|
./waste-daemon -alias yourname -anchor wss://your-anchor-server/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`.
|
||||||
|
|
||||||
|
Full flag reference:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-alias` | `anon` | Your display name |
|
||||||
|
| `-anchor` | — | Anchor server WebSocket URL |
|
||||||
|
| `-data-dir` | `~/.waste` | Where identity and messages are stored |
|
||||||
|
| `-download-dir` | same as data-dir | Where received files are saved |
|
||||||
|
| `-ipc-port` | `17337` | Local TCP IPC port |
|
||||||
|
| `-ws-port` | `0` (off) | WebSocket IPC port (needed for web UI) |
|
||||||
|
| `-turn-url` | — | TURN relay URL (fixes mobile/CGNAT) |
|
||||||
|
| `-turn-secret` | — | TURN shared secret |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inviting someone
|
||||||
|
|
||||||
|
1. Click `Ctrl+I` in the TUI, or click **Generate invite** in the web UI
|
||||||
|
2. Share the `waste:...` link with your friend (Signal, email, anything)
|
||||||
|
3. They open it in a browser or pass it to `waste-daemon --join 'waste:...'`
|
||||||
|
|
||||||
|
Invite links encode the anchor URL and network name. The anchor never sees your messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running your own anchor server
|
||||||
|
|
||||||
|
The anchor is a tiny signaling server that helps peers find each other — it never sees plaintext messages or file contents. You need a VPS with a domain and TLS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your VPS:
|
||||||
|
./waste-anchor -bind 127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Put it behind nginx with a `/ws` WebSocket proxy and serve the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.
|
||||||
104
README.md
104
README.md
@@ -169,6 +169,8 @@ The `use-auth-secret` mode generates short-lived TURN credentials from the share
|
|||||||
|
|
||||||
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
|
||||||
|
|
||||||
|
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works: daemon vs browser mode
|
## How it works: daemon vs browser mode
|
||||||
@@ -183,6 +185,8 @@ A user visits your domain, enters their name and a network name, and joins. Invi
|
|||||||
|
|
||||||
**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.
|
**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).
|
**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)
|
### Daemon mode (for users running the daemon locally)
|
||||||
@@ -213,8 +217,80 @@ Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to requ
|
|||||||
|
|
||||||
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.
|
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.
|
> 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
|
## Local development
|
||||||
@@ -284,6 +360,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec
|
|||||||
|
|
||||||
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
|
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
|
## Terminal UI
|
||||||
@@ -300,6 +394,8 @@ go run ./cmd/tui -network friends
|
|||||||
|
|
||||||
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
|
||||||
|
|
||||||
|
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IPC protocol
|
## IPC protocol
|
||||||
@@ -317,16 +413,19 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"get_file_list","peer_id":"<64-hex>"}
|
{"type":"get_file_list","peer_id":"<64-hex>"}
|
||||||
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
|
{"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/Music"} // global share
|
||||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
|
||||||
{"type":"remove_share","path":"/home/alice/Music"}
|
{"type":"remove_share","path":"/home/alice/Music"}
|
||||||
{"type":"list_shares"}
|
{"type":"list_shares"}
|
||||||
|
{"type":"create_room","room":"dev"}
|
||||||
|
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
|
||||||
|
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
|
||||||
{"type":"export_identity","passphrase":"..."}
|
{"type":"export_identity","passphrase":"..."}
|
||||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Events:**
|
**Events:**
|
||||||
```jsonc
|
```jsonc
|
||||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"}
|
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]}
|
||||||
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
|
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
|
||||||
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
||||||
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
||||||
@@ -335,6 +434,7 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
|||||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||||
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
{"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":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
||||||
|
{"type":"room_created","network_id":"...","room":"dev"}
|
||||||
{"type":"identity_exported","backup":"..."}
|
{"type":"identity_exported","backup":"..."}
|
||||||
{"type":"error","error_message":"..."}
|
{"type":"error","error_message":"..."}
|
||||||
```
|
```
|
||||||
|
|||||||
30
build-app.sh
Executable file
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
|
||||||
130
cmd/app/app.go
Normal file
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
BIN
cmd/app/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
0
cmd/app/frontend/dist/.gitkeep
vendored
Normal file
0
cmd/app/frontend/dist/.gitkeep
vendored
Normal file
66
cmd/app/main.go
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
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
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
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
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,10 @@ func main() {
|
|||||||
wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled")
|
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")
|
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")
|
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")
|
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")
|
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")
|
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -74,6 +77,9 @@ func main() {
|
|||||||
StoreDir: dir,
|
StoreDir: dir,
|
||||||
AnchorURL: *anchorURL,
|
AnchorURL: *anchorURL,
|
||||||
ShareDir: expandHome(*shareDir),
|
ShareDir: expandHome(*shareDir),
|
||||||
|
DownloadDir: expandHome(*downloadDir),
|
||||||
|
TurnURL: *turnURL,
|
||||||
|
TurnSecret: *turnSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
if autoJoinNetwork != "" {
|
if autoJoinNetwork != "" {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type model struct {
|
|||||||
rooms []string // "general" always first; DM rooms appended
|
rooms []string // "general" always first; DM rooms appended
|
||||||
activeRoom int
|
activeRoom int
|
||||||
messages map[string][]entry
|
messages map[string][]entry
|
||||||
|
unread map[string]bool // rooms with messages since last viewed
|
||||||
|
|
||||||
peers map[proto.PeerID]string // connected peers: id → alias
|
peers map[proto.PeerID]string // connected peers: id → alias
|
||||||
peerOrder []proto.PeerID
|
peerOrder []proto.PeerID
|
||||||
@@ -131,6 +132,7 @@ func newModel(ipcPort int, network string) model {
|
|||||||
networkName: network,
|
networkName: network,
|
||||||
rooms: []string{"general"},
|
rooms: []string{"general"},
|
||||||
messages: make(map[string][]entry),
|
messages: make(map[string][]entry),
|
||||||
|
unread: make(map[string]bool),
|
||||||
peers: make(map[proto.PeerID]string),
|
peers: make(map[proto.PeerID]string),
|
||||||
input: ti,
|
input: ti,
|
||||||
status: "connecting…",
|
status: "connecting…",
|
||||||
@@ -215,9 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m, cmds = m.doSend(cmds)
|
m, cmds = m.doSend(cmds)
|
||||||
case msg.Type == tea.KeyTab:
|
case msg.Type == tea.KeyTab:
|
||||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||||
|
delete(m.unread, m.activeRoomName())
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
case msg.Type == tea.KeyShiftTab:
|
case msg.Type == tea.KeyShiftTab:
|
||||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||||
|
delete(m.unread, m.activeRoomName())
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
default:
|
default:
|
||||||
var tiCmd tea.Cmd
|
var tiCmd tea.Cmd
|
||||||
@@ -251,8 +255,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
m.peers[p.ID] = p.Alias
|
m.peers[p.ID] = p.Alias
|
||||||
m.peerOrder = append(m.peerOrder, p.ID)
|
m.peerOrder = append(m.peerOrder, p.ID)
|
||||||
}
|
}
|
||||||
|
for _, r := range evt.Rooms {
|
||||||
|
m = m.addRoom(r)
|
||||||
|
}
|
||||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||||
|
|
||||||
|
case proto.EvtRoomCreated:
|
||||||
|
m = m.addRoom(evt.Room)
|
||||||
|
m = m.refreshViewport()
|
||||||
|
|
||||||
case proto.EvtSessionReady:
|
case proto.EvtSessionReady:
|
||||||
if evt.PeerID != nil {
|
if evt.PeerID != nil {
|
||||||
pid := *evt.PeerID
|
pid := *evt.PeerID
|
||||||
@@ -283,7 +294,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtInviteGenerated:
|
case proto.EvtInviteGenerated:
|
||||||
m.invitePopup = evt.InviteString
|
m.invitePopup = evt.InviteGenerated
|
||||||
|
|
||||||
case proto.EvtMessageReceived:
|
case proto.EvtMessageReceived:
|
||||||
if evt.Message != nil {
|
if evt.Message != nil {
|
||||||
@@ -296,6 +307,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
}
|
}
|
||||||
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
m.messages[msg.Room] = append(m.messages[msg.Room], e)
|
||||||
m = m.addRoom(msg.Room)
|
m = m.addRoom(msg.Room)
|
||||||
|
if msg.Room != m.activeRoomName() {
|
||||||
|
m.unread[msg.Room] = true
|
||||||
|
}
|
||||||
m = m.refreshViewport()
|
m = m.refreshViewport()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +323,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
|
|
||||||
|
if strings.HasPrefix(body, "/room ") {
|
||||||
|
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
||||||
|
if name != "" {
|
||||||
|
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
|
||||||
|
}
|
||||||
|
return m, cmds
|
||||||
|
}
|
||||||
|
|
||||||
room := m.rooms[m.activeRoom]
|
room := m.rooms[m.activeRoom]
|
||||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||||
if strings.HasPrefix(room, "dm:") {
|
if strings.HasPrefix(room, "dm:") {
|
||||||
@@ -361,7 +383,7 @@ func (m model) refreshViewport() model {
|
|||||||
w := m.vpContentWidth()
|
w := m.vpContentWidth()
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, e := range m.messages[room] {
|
for _, e := range m.messages[room] {
|
||||||
ts := styleMsgTime.Render(e.at.Format("15:04"))
|
ts := styleMsgTime.Render(formatMsgTime(e.at))
|
||||||
var from string
|
var from string
|
||||||
if e.fromMe {
|
if e.fromMe {
|
||||||
from = styleMsgMe.Render(e.from)
|
from = styleMsgMe.Render(e.from)
|
||||||
@@ -430,7 +452,7 @@ func (m model) View() string {
|
|||||||
if m.errMsg != "" {
|
if m.errMsg != "" {
|
||||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||||
} else {
|
} else {
|
||||||
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
|
hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
|
||||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +490,11 @@ func (m model) renderRooms(boxH int) string {
|
|||||||
if i == m.activeRoom {
|
if i == m.activeRoom {
|
||||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, styleRoom.Width(innerW).Render(" "+label))
|
prefix := " "
|
||||||
|
if m.unread[room] {
|
||||||
|
prefix = "* "
|
||||||
|
}
|
||||||
|
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for len(lines) < contentH {
|
for len(lines) < contentH {
|
||||||
@@ -588,6 +614,17 @@ func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
|
|||||||
return out
|
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")
|
||||||
|
}
|
||||||
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay()-1 {
|
||||||
|
return "Yesterday " + t.Format("15:04")
|
||||||
|
}
|
||||||
|
return t.Format("Jan 2 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
|
|||||||
39
go.mod
39
go.mod
@@ -6,14 +6,16 @@ require (
|
|||||||
filippo.io/edwards25519 v1.2.0
|
filippo.io/edwards25519 v1.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/pion/webrtc/v3 v3.3.6
|
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
|
modernc.org/sqlite v1.53.0
|
||||||
nhooyr.io/websocket v1.8.17
|
nhooyr.io/websocket v1.8.17
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/bubbles v1.0.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // 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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // 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/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.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/datachannel v1.5.8 // indirect
|
||||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
github.com/pion/ice/v2 v2.3.38 // 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/stun v0.6.1 // indirect
|
||||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||||
github.com/pion/turn/v2 v2.1.6 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // 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/wlynxg/anet v0.0.3 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.73.4 // indirect
|
modernc.org/libc v1.73.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
85
go.sum
85
go.sum
@@ -1,9 +1,13 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
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/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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
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=
|
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/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 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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.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.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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e 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.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 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
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-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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
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.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
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.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-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.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.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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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-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-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-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-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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.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/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.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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
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.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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
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=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
if sess == nil {
|
if sess == nil {
|
||||||
// Answerer: we haven't created a session yet, do it now.
|
// Answerer: we haven't created a session yet, do it now.
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
log.Printf("anchor: new PC for answerer: %v", err)
|
log.Printf("anchor: new PC for answerer: %v", err)
|
||||||
@@ -412,7 +412,7 @@ func dispatchSignaling(
|
|||||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
// 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) {
|
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||||
pc, err := newPC()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m
|
|||||||
|
|
||||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
// 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) {
|
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()
|
pc, err := newPC(m.ICEServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
|
|||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPC() (*webrtc.PeerConnection, error) {
|
func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|||||||
@@ -131,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.
|
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||||
func (id *Identity) Sign(data []byte) string {
|
func (id *Identity) Sign(data []byte) string {
|
||||||
sig := ed25519.Sign(id.privateKey, data)
|
sig := ed25519.Sign(id.privateKey, data)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
// 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
|
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||||
// the same network without knowing the plaintext name.
|
// 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
|
package invite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,26 +29,66 @@ type Invite struct {
|
|||||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||||
Network string `json:"network"` // plaintext network name
|
Network string `json:"network"` // plaintext network name
|
||||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
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) {
|
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 == "" {
|
if anchor == "" {
|
||||||
return "", fmt.Errorf("anchor URL is required")
|
return "", fmt.Errorf("anchor URL is required")
|
||||||
}
|
}
|
||||||
if network == "" {
|
if network == "" {
|
||||||
return "", fmt.Errorf("network name is required")
|
return "", fmt.Errorf("network name is required")
|
||||||
}
|
}
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
inviter := signer.PeerIDHex()
|
||||||
b, err := json.Marshal(Invite{
|
net := NetHash(network)
|
||||||
|
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||||
|
return marshal(Invite{
|
||||||
Anchor: anchor,
|
Anchor: anchor,
|
||||||
Network: network,
|
Network: network,
|
||||||
Net: hex.EncodeToString(h[:]),
|
Net: net,
|
||||||
|
Inviter: inviter,
|
||||||
|
Sig: sig,
|
||||||
})
|
})
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
|
||||||
|
// 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.
|
// Decode parses a waste: invite string and returns the Invite.
|
||||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
|||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetHash returns the full 64-char hex network hash for the given name
|
// NetHash returns the full 64-char hex network hash for the given name.
|
||||||
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
|
|
||||||
func NetHash(name string) string {
|
func NetHash(name string) string {
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||||
return hex.EncodeToString(h[:])
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
@@ -126,6 +127,8 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
|
|
||||||
// Send initial state snapshot.
|
// Send initial state snapshot.
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
// Send stored history for each room so the UI is populated on connect.
|
||||||
|
sendStoredHistory(mgr, send)
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -146,7 +149,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
case cmd.NetworkName != "":
|
case cmd.NetworkName != "":
|
||||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||||
case len(cmd.NetworkHash) == 64:
|
case len(cmd.NetworkHash) == 64:
|
||||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
|
||||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||||
default:
|
default:
|
||||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||||
@@ -156,7 +158,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash.
|
if n, ok := mgr.Get(netID); ok {
|
||||||
|
if cmd.RequireInvite {
|
||||||
|
n.Mesh.RequireInvite = true
|
||||||
|
}
|
||||||
|
if cmd.InviteString != "" {
|
||||||
|
n.Mesh.InviteString = cmd.InviteString
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = netID
|
_ = netID
|
||||||
|
|
||||||
case proto.CmdLeaveNetwork:
|
case proto.CmdLeaveNetwork:
|
||||||
@@ -207,6 +216,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
} else {
|
} else {
|
||||||
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
||||||
mid := randomHex(16)
|
mid := randomHex(16)
|
||||||
|
msgID := proto.ComputeMsgID(n.Identity.PeerID(), cmd.Room, ts, cmd.Body)
|
||||||
wire, err := json.Marshal(proto.PeerMessage{
|
wire, err := json.Marshal(proto.PeerMessage{
|
||||||
Type: proto.MsgChat,
|
Type: proto.MsgChat,
|
||||||
Mid: mid,
|
Mid: mid,
|
||||||
@@ -220,6 +230,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
n.Mesh.Broadcast(wire)
|
n.Mesh.Broadcast(wire)
|
||||||
local := &proto.ChatMessage{
|
local := &proto.ChatMessage{
|
||||||
Mid: mid,
|
Mid: mid,
|
||||||
|
MsgID: msgID,
|
||||||
From: n.Identity.PeerID(),
|
From: n.Identity.PeerID(),
|
||||||
Room: cmd.Room,
|
Room: cmd.Room,
|
||||||
Text: cmd.Body,
|
Text: cmd.Body,
|
||||||
@@ -233,6 +244,23 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
case proto.CmdGetState:
|
||||||
send(stateSnapshot(mgr))
|
send(stateSnapshot(mgr))
|
||||||
|
|
||||||
@@ -298,7 +326,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||||
continue
|
continue
|
||||||
@@ -306,7 +334,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(proto.IpcMessage{
|
send(proto.IpcMessage{
|
||||||
Type: proto.EvtInviteGenerated,
|
Type: proto.EvtInviteGenerated,
|
||||||
NetworkID: n.ID,
|
NetworkID: n.ID,
|
||||||
InviteString: inv,
|
InviteGenerated: inv,
|
||||||
})
|
})
|
||||||
|
|
||||||
case proto.CmdSetShareDir:
|
case proto.CmdSetShareDir:
|
||||||
@@ -320,6 +348,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
continue
|
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:
|
case proto.CmdSendFile:
|
||||||
n := mgr.Resolve(cmd.NetworkID)
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -410,11 +454,60 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
|||||||
pi := all[0].Identity.PeerInfo()
|
pi := all[0].Identity.PeerInfo()
|
||||||
msg.LocalPeer = &pi
|
msg.LocalPeer = &pi
|
||||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
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
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func errMsg(s string) proto.IpcMessage {
|
func errMsg(s string) proto.IpcMessage {
|
||||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package mesh
|
package mesh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,6 +14,9 @@ import (
|
|||||||
"github.com/waste-go/internal/store"
|
"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.
|
// PeerConn is a live connection to one peer.
|
||||||
type PeerConn struct {
|
type PeerConn struct {
|
||||||
Info proto.PeerInfo
|
Info proto.PeerInfo
|
||||||
@@ -29,9 +33,12 @@ type Mesh struct {
|
|||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
DownloadDir string // directory where received files are saved
|
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
|
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||||
// multi-share scanning without the mesh needing to know about shares.json.
|
// multi-share scanning without the mesh needing to know about shares.json.
|
||||||
ScanFiles func() []proto.FileEntry
|
ScanFiles func() []proto.FileEntry
|
||||||
|
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
peers map[proto.PeerID]*PeerConn
|
peers map[proto.PeerID]*PeerConn
|
||||||
@@ -45,6 +52,12 @@ type Mesh struct {
|
|||||||
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
||||||
PendingConnect chan proto.PeerID
|
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)
|
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||||
subMu sync.Mutex
|
subMu sync.Mutex
|
||||||
subs []chan proto.IpcMessage
|
subs []chan proto.IpcMessage
|
||||||
@@ -60,9 +73,33 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
|
|||||||
outbound: make(map[string]*outboundTransfer),
|
outbound: make(map[string]*outboundTransfer),
|
||||||
inbound: make(map[string]*inboundTransfer),
|
inbound: make(map[string]*inboundTransfer),
|
||||||
PendingConnect: make(chan proto.PeerID, 32),
|
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.
|
// ScanShareDir returns the list of files in the local share directory.
|
||||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||||
@@ -206,6 +243,122 @@ func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestHistoryFrom sends history_request messages to peerID for all rooms
|
||||||
|
// we know about but haven't yet requested this session. Only contacts the first
|
||||||
|
// peer we connect to, to avoid fan-out amplification.
|
||||||
|
func (m *Mesh) RequestHistoryFrom(peerID proto.PeerID) {
|
||||||
|
if m.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.historyMu.Lock()
|
||||||
|
if m.historyFirstPeer != "" && m.historyFirstPeer != peerID {
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
return // only request from the first peer
|
||||||
|
}
|
||||||
|
m.historyFirstPeer = peerID
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
|
||||||
|
rooms, err := m.Store.Rooms()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Always include "general" even if not explicitly created.
|
||||||
|
roomSet := map[string]bool{"general": true}
|
||||||
|
for _, r := range rooms {
|
||||||
|
roomSet[r] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
m.historyMu.Lock()
|
||||||
|
var toRequest []string
|
||||||
|
for r := range roomSet {
|
||||||
|
if !m.historyRequested[r] {
|
||||||
|
m.historyRequested[r] = true
|
||||||
|
toRequest = append(toRequest, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.historyMu.Unlock()
|
||||||
|
|
||||||
|
for _, room := range toRequest {
|
||||||
|
req, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgHistoryRequest,
|
||||||
|
Room: room,
|
||||||
|
Limit: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.SendTo(peerID, req)
|
||||||
|
log.Printf("mesh: sent history_request room=%s to %s", room, peerID.Short())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryRequest responds to a history_request from a peer.
|
||||||
|
func (m *Mesh) HandleHistoryRequest(from proto.PeerID, room string, sinceMs int64, limit int) {
|
||||||
|
if m.Store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgs, err := m.Store.RecentMessagesSince(room, sinceMs, limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mesh: history_request from %s room=%s: %v", from.Short(), room, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up aliases for from_peer values.
|
||||||
|
entries := make([]proto.HistoryEntry, 0, len(msgs))
|
||||||
|
for _, msg := range msgs {
|
||||||
|
entries = append(entries, proto.HistoryEntry{
|
||||||
|
Mid: msg.Mid,
|
||||||
|
From: string(msg.From),
|
||||||
|
FromAlias: m.Store.PeerAlias(msg.From),
|
||||||
|
Text: msg.Text,
|
||||||
|
Ts: msg.Ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgHistoryChunk,
|
||||||
|
Room: room,
|
||||||
|
History: entries,
|
||||||
|
HistoryDone: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SendTo(from, chunk)
|
||||||
|
log.Printf("mesh: sent history_chunk room=%s to %s: %d msgs", room, from.Short(), len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryChunk saves received history messages and emits history_loaded.
|
||||||
|
func (m *Mesh) HandleHistoryChunk(room string, entries []proto.HistoryEntry) {
|
||||||
|
if m.Store == nil || len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var saved []proto.ChatMessage
|
||||||
|
for _, e := range entries {
|
||||||
|
msg := &proto.ChatMessage{
|
||||||
|
Mid: e.Mid,
|
||||||
|
MsgID: e.Mid, // mid is already content-addressed for gossipped messages
|
||||||
|
From: proto.PeerID(e.From),
|
||||||
|
Room: room,
|
||||||
|
Text: e.Text,
|
||||||
|
Ts: e.Ts,
|
||||||
|
}
|
||||||
|
if err := m.Store.SaveMessage(msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
saved = append(saved, *msg)
|
||||||
|
}
|
||||||
|
if len(saved) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtHistoryLoaded,
|
||||||
|
Room: room,
|
||||||
|
Messages: saved,
|
||||||
|
})
|
||||||
|
log.Printf("mesh: history_chunk room=%s: %d/%d new messages", room, len(saved), len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
||||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||||
m.emit(msg)
|
m.emit(msg)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/pion/webrtc/v3"
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ func WireDataChannel(
|
|||||||
Nick: id.Alias,
|
Nick: id.Alias,
|
||||||
Caps: []string{"chat", "file"},
|
Caps: []string{"chat", "file"},
|
||||||
Sig: id.Sign(bindBytes),
|
Sig: id.Sign(bindBytes),
|
||||||
|
Invite: m.InviteString,
|
||||||
}
|
}
|
||||||
helloJSON, _ := json.Marshal(hello)
|
helloJSON, _ := json.Marshal(hello)
|
||||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||||
@@ -149,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)
|
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||||
return
|
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.
|
// Update alias once we have the verified nick.
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if conn, ok := m.peers[from]; ok {
|
if conn, ok := m.peers[from]; ok {
|
||||||
@@ -164,6 +197,8 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
|||||||
})
|
})
|
||||||
// Tell the new peer about everyone we can currently see.
|
// Tell the new peer about everyone we can currently see.
|
||||||
go m.sendGossipTo(from)
|
go m.sendGossipTo(from)
|
||||||
|
// Request message history from this peer (EXT-007).
|
||||||
|
go m.RequestHistoryFrom(from)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +215,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
case proto.MsgChat:
|
case proto.MsgChat:
|
||||||
chat := &proto.ChatMessage{
|
chat := &proto.ChatMessage{
|
||||||
Mid: midOrRandom(msg.Mid),
|
Mid: midOrRandom(msg.Mid),
|
||||||
|
MsgID: proto.ComputeMsgID(from, msg.Room, msg.Ts, msg.Text),
|
||||||
From: from,
|
From: from,
|
||||||
Room: msg.Room,
|
Room: msg.Room,
|
||||||
Text: msg.Text,
|
Text: msg.Text,
|
||||||
@@ -234,7 +270,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
m.acceptIncoming(msg, from)
|
m.acceptIncoming(msg, from)
|
||||||
|
|
||||||
case proto.MsgFileAccept:
|
case proto.MsgFileAccept:
|
||||||
m.startSend(msg.Xid, from)
|
m.startSend(msg.Xid, from, msg.ResumeOffset)
|
||||||
|
|
||||||
case proto.MsgFileCancel:
|
case proto.MsgFileCancel:
|
||||||
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
||||||
@@ -265,6 +301,12 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
||||||
|
case proto.MsgHistoryRequest:
|
||||||
|
go m.HandleHistoryRequest(from, msg.Room, msg.Since, msg.Limit)
|
||||||
|
|
||||||
|
case proto.MsgHistoryChunk:
|
||||||
|
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||||
|
|
||||||
case proto.MsgPing:
|
case proto.MsgPing:
|
||||||
log.Printf("mesh: ping from %s", from.Short())
|
log.Printf("mesh: ping from %s", from.Short())
|
||||||
case proto.MsgPong:
|
case proto.MsgPong:
|
||||||
|
|||||||
@@ -40,7 +40,96 @@ type inboundTransfer struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tmp *os.File
|
tmp *os.File
|
||||||
hasher hash.Hash
|
hasher hash.Hash
|
||||||
written int64
|
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
|
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||||
@@ -102,28 +191,49 @@ func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// acceptIncoming is called when we receive file-offer from a peer.
|
// acceptIncoming is called when we receive file-offer from a peer.
|
||||||
// It stores the inbound state and immediately sends file-accept (auto-accept).
|
// 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) {
|
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
|
||||||
m.transferMu.Lock()
|
t := &inboundTransfer{
|
||||||
m.inbound[msg.Xid] = &inboundTransfer{
|
|
||||||
from: from,
|
from: from,
|
||||||
name: msg.Name,
|
name: msg.Name,
|
||||||
size: msg.Size,
|
size: msg.Size,
|
||||||
sha256: msg.SHA256,
|
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()
|
m.transferMu.Unlock()
|
||||||
|
|
||||||
accept, _ := json.Marshal(proto.PeerMessage{
|
accept, _ := json.Marshal(proto.PeerMessage{
|
||||||
Type: proto.MsgFileAccept,
|
Type: proto.MsgFileAccept,
|
||||||
Xid: msg.Xid,
|
Xid: msg.Xid,
|
||||||
|
ResumeOffset: resumeOffset,
|
||||||
})
|
})
|
||||||
m.SendTo(from, accept)
|
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])
|
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.
|
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
||||||
// Called when we receive file-accept from the peer.
|
// Called when we receive file-accept from the peer. resumeOffset is non-zero
|
||||||
func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
// when the receiver is resuming a previous partial download.
|
||||||
|
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
t, ok := m.outbound[xid]
|
t, ok := m.outbound[xid]
|
||||||
m.transferMu.Unlock()
|
m.transferMu.Unlock()
|
||||||
@@ -156,13 +266,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
|||||||
dc.OnOpen(func() {
|
dc.OnOpen(func() {
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
if msg.IsString && string(msg.Data) == "ok" {
|
if msg.IsString && string(msg.Data) == "ok" {
|
||||||
go m.sendFileChunks(dc, t, xid)
|
go m.sendFileChunks(dc, t, xid, resumeOffset)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string) {
|
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string, offset int64) {
|
||||||
defer func() {
|
defer func() {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
delete(m.outbound, xid)
|
delete(m.outbound, xid)
|
||||||
@@ -177,6 +287,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
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.
|
// Backpressure: block when the DC buffer is full.
|
||||||
resume := make(chan struct{}, 1)
|
resume := make(chan struct{}, 1)
|
||||||
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
||||||
@@ -188,7 +307,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
})
|
})
|
||||||
|
|
||||||
buf := make([]byte, fileChunkSize)
|
buf := make([]byte, fileChunkSize)
|
||||||
var sent int64
|
sent := offset // start progress reporting from where the receiver left off
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, readErr := f.Read(buf)
|
n, readErr := f.Read(buf)
|
||||||
@@ -229,8 +348,9 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
||||||
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the
|
// arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
|
||||||
// SHA-256 on close, and emits EvtFileComplete.
|
// 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) {
|
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
t, ok := m.inbound[xid]
|
t, ok := m.inbound[xid]
|
||||||
@@ -247,16 +367,43 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
||||||
return
|
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")
|
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("transfer: create temp file: %v", err)
|
log.Printf("transfer: create temp file: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.mu.Lock()
|
|
||||||
t.tmp = tmp
|
t.tmp = tmp
|
||||||
t.hasher = sha256.New()
|
t.hasher = sha256.New()
|
||||||
t.mu.Unlock()
|
t.metaPath = tmp.Name() + ".meta"
|
||||||
|
writePartialMeta(t.metaPath, t)
|
||||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||||
|
}
|
||||||
|
|
||||||
// Signal sender that we are ready — it will not start streaming until
|
// Signal sender that we are ready — it will not start streaming until
|
||||||
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
||||||
dc.SendText("ok") //nolint:errcheck
|
dc.SendText("ok") //nolint:errcheck
|
||||||
@@ -298,11 +445,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
dc.OnClose(func() {
|
dc.OnClose(func() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
tmp := t.tmp
|
tmp := t.tmp
|
||||||
|
metaPath := t.metaPath
|
||||||
actualSha := ""
|
actualSha := ""
|
||||||
if t.hasher != nil {
|
if t.hasher != nil {
|
||||||
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
name, expectedSha := t.name, t.sha256
|
name, expectedSha, size, written := t.name, t.sha256, t.size, t.written
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
m.transferMu.Lock()
|
m.transferMu.Lock()
|
||||||
@@ -312,10 +460,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
if tmp == nil {
|
if tmp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
tmp.Close()
|
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 {
|
if actualSha != expectedSha {
|
||||||
os.Remove(tmp.Name())
|
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])
|
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
|
||||||
m.Emit(proto.IpcMessage{
|
m.Emit(proto.IpcMessage{
|
||||||
Type: proto.EvtError,
|
Type: proto.EvtError,
|
||||||
@@ -325,13 +484,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success — remove sidecar and move to final path.
|
||||||
|
if metaPath != "" {
|
||||||
|
os.Remove(metaPath)
|
||||||
|
}
|
||||||
final := filepath.Join(m.DownloadDir, name)
|
final := filepath.Join(m.DownloadDir, name)
|
||||||
if _, err := os.Stat(final); err == nil {
|
if _, err := os.Stat(final); err == nil {
|
||||||
ext := filepath.Ext(name)
|
ext := filepath.Ext(name)
|
||||||
base := strings.TrimSuffix(name, ext)
|
base := strings.TrimSuffix(name, ext)
|
||||||
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp.Name(), final); err != nil {
|
if err := os.Rename(tmpName, final); err != nil {
|
||||||
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ package netmgr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/anchor"
|
"github.com/waste-go/internal/anchor"
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
@@ -26,8 +33,11 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
MasterIdentity *crypto.Identity
|
MasterIdentity *crypto.Identity
|
||||||
StoreDir string // base directory for per-network SQLite files
|
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
|
AnchorURL string // WebSocket anchor URL used for all networks
|
||||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
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.
|
// Network is a single joined network context.
|
||||||
@@ -110,9 +120,12 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
} else if mgr.cfg.ShareDir != "" {
|
} else if mgr.cfg.ShareDir != "" {
|
||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID_full)
|
||||||
capturedNetID := netID
|
capturedNetID := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
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.
|
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
@@ -149,6 +162,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go m.ScanResumable()
|
||||||
|
|
||||||
mgr.emit(proto.IpcMessage{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
@@ -195,9 +210,12 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
} else if mgr.cfg.ShareDir != "" {
|
} else if mgr.cfg.ShareDir != "" {
|
||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID)
|
||||||
capturedNetID2 := netID
|
capturedNetID2 := netID
|
||||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||||
|
if ice := mgr.turnICEServers(); ice != nil {
|
||||||
|
m.ICEServers = ice
|
||||||
|
}
|
||||||
|
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -233,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go m.ScanResumable()
|
||||||
|
|
||||||
mgr.emit(proto.IpcMessage{
|
mgr.emit(proto.IpcMessage{
|
||||||
Type: proto.EvtNetworkJoined,
|
Type: proto.EvtNetworkJoined,
|
||||||
NetworkID: netID,
|
NetworkID: netID,
|
||||||
@@ -278,6 +298,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool {
|
|||||||
return true
|
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.
|
// LeaveAll leaves every joined network.
|
||||||
func (mgr *Manager) LeaveAll() {
|
func (mgr *Manager) LeaveAll() {
|
||||||
mgr.mu.RLock()
|
mgr.mu.RLock()
|
||||||
@@ -411,6 +453,26 @@ func (mgr *Manager) emit(msg proto.IpcMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func hashNetName(name string) string {
|
func hashNetName(name string) string {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
||||||
package proto
|
package proto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +49,8 @@ const (
|
|||||||
MsgFileDone MsgType = "file-done"
|
MsgFileDone MsgType = "file-done"
|
||||||
MsgPing MsgType = "ping"
|
MsgPing MsgType = "ping"
|
||||||
MsgPong MsgType = "pong"
|
MsgPong MsgType = "pong"
|
||||||
|
MsgHistoryRequest MsgType = "history_request"
|
||||||
|
MsgHistoryChunk MsgType = "history_chunk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
||||||
@@ -77,17 +83,45 @@ type PeerMessage struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
SHA256 string `json:"sha256,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)
|
// file-done / file-cancel / file-accept just need xid (already above)
|
||||||
Reason string `json:"reason,omitempty"` // file-cancel
|
Reason string `json:"reason,omitempty"` // file-cancel
|
||||||
|
|
||||||
Seq *uint64 `json:"seq,omitempty"` // ping/pong
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
// ChatMessage is a group chat message (wire type "chat", §8).
|
||||||
// Also used internally for persisting PMs after they are received.
|
// Also used internally for persisting PMs after they are received.
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
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
|
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
|
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
||||||
Room string `json:"room"`
|
Room string `json:"room"`
|
||||||
@@ -95,6 +129,14 @@ type ChatMessage struct {
|
|||||||
Ts int64 `json:"ts"` // Unix milliseconds
|
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.
|
// PeerGossip shares known peer addresses.
|
||||||
type PeerGossip struct {
|
type PeerGossip struct {
|
||||||
Peers []GossipEntry `json:"peers"`
|
Peers []GossipEntry `json:"peers"`
|
||||||
@@ -139,12 +181,16 @@ type FileOffer struct {
|
|||||||
|
|
||||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||||
// The signature binds this identity to the specific DTLS session.
|
// 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 HelloMessage struct {
|
||||||
Type string `json:"type"` // always "hello"
|
Type string `json:"type"` // always "hello"
|
||||||
ID string `json:"id"` // hex pubkey
|
ID string `json:"id"` // hex pubkey
|
||||||
Nick string `json:"nick"` // alias
|
Nick string `json:"nick"` // alias
|
||||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
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:
|
// HelloBindString returns the bytes the hello signature covers:
|
||||||
@@ -232,6 +278,8 @@ const (
|
|||||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||||
|
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||||
|
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -251,6 +299,9 @@ const (
|
|||||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
EvtSharesList IpcMsgType = "shares_list"
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||||
@@ -279,6 +330,8 @@ type IpcMessage struct {
|
|||||||
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
|
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
|
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 / set_share_dir / file_complete path
|
// send_file / set_share_dir / file_complete path
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -297,14 +350,17 @@ type IpcMessage struct {
|
|||||||
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
|
||||||
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
|
||||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||||
|
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
|
||||||
Rooms []string `json:"rooms,omitempty"`
|
Rooms []string `json:"rooms,omitempty"`
|
||||||
// multi-network: all joined networks (additive)
|
// multi-network: all joined networks (additive)
|
||||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteGenerated string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
|
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
||||||
|
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
|
||||||
Shares []ShareEntry `json:"shares,omitempty"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
|
||||||
// export_identity / import_identity
|
// export_identity / import_identity
|
||||||
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||||
Backup string `json:"backup,omitempty"` // JSON backup blob
|
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -28,8 +29,21 @@ CREATE TABLE IF NOT EXISTS peers (
|
|||||||
alias TEXT NOT NULL,
|
alias TEXT NOT NULL,
|
||||||
last_seen DATETIME NOT NULL
|
last_seen DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||||
|
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||||
|
var migrations = []string{
|
||||||
|
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||||
|
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||||
|
}
|
||||||
|
|
||||||
// Store is a local SQLite-backed message and peer store.
|
// Store is a local SQLite-backed message and peer store.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -46,6 +60,12 @@ func Open(path string) (*Store, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("migrate db: %w", err)
|
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
|
return &Store{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +79,9 @@ func (s *Store) Close() error {
|
|||||||
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
||||||
sentAt := time.UnixMilli(msg.Ts).UTC()
|
sentAt := time.UnixMilli(msg.Ts).UTC()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT OR IGNORE INTO messages (mid, room, from_peer, body, sent_at)
|
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
msg.Mid, msg.Room, string(msg.From), msg.Text, sentAt,
|
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -86,14 +106,39 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
|||||||
|
|
||||||
// RecentMessages returns up to limit messages for a room, oldest first.
|
// RecentMessages returns up to limit messages for a room, oldest first.
|
||||||
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
||||||
rows, err := s.db.Query(
|
return s.queryMessages(
|
||||||
`SELECT mid, from_peer, body, sent_at
|
`SELECT mid, from_peer, room, body, sent_at FROM messages
|
||||||
FROM messages
|
|
||||||
WHERE room = ?
|
WHERE room = ?
|
||||||
ORDER BY sent_at DESC
|
ORDER BY sent_at DESC LIMIT ?`,
|
||||||
LIMIT ?`,
|
|
||||||
room, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,11 +149,10 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
|||||||
var m proto.ChatMessage
|
var m proto.ChatMessage
|
||||||
var from string
|
var from string
|
||||||
var sentAt time.Time
|
var sentAt time.Time
|
||||||
if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil {
|
if err := rows.Scan(&m.Mid, &from, &m.Room, &m.Text, &sentAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.From = proto.PeerID(from)
|
m.From = proto.PeerID(from)
|
||||||
m.Room = room
|
|
||||||
m.Ts = sentAt.UnixMilli()
|
m.Ts = sentAt.UnixMilli()
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, m)
|
||||||
}
|
}
|
||||||
@@ -119,6 +163,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
|||||||
return msgs, rows.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.
|
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||||
@@ -136,3 +207,10 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#863bff" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="waste" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>waste</title>
|
<title>waste</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"wails:dev": "wails dev",
|
||||||
|
"wails:build": "wails build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
|
|||||||
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
web/public/icon-192.png
Normal file
BIN
web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
web/public/icon-512.png
Normal file
BIN
web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
23
web/public/manifest.json
Normal file
23
web/public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "waste",
|
||||||
|
"short_name": "waste",
|
||||||
|
"description": "Decentralized friend-to-friend encrypted mesh networking",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0d0d0d",
|
||||||
|
"theme_color": "#863bff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ input:focus { border-color: var(--accent); }
|
|||||||
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
|
.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-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); }
|
.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; }
|
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 { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
|
||||||
.toggle-link:hover { color: var(--text); }
|
.toggle-link:hover { color: var(--text); }
|
||||||
@@ -88,7 +92,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
|
.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 { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
||||||
.message:hover { background: rgba(255,255,255,0.02); }
|
.message:hover { background: rgba(255,255,255,0.02); }
|
||||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
.message-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-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.mine .message-alias { color: var(--accent); }
|
||||||
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
||||||
@@ -158,3 +162,5 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.file-entry-dir { cursor: pointer; }
|
.file-entry-dir { cursor: pointer; }
|
||||||
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
|
||||||
.file-entry-icon { font-size: 12px; flex-shrink: 0; }
|
.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); }
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WAS
|
|||||||
// Use browser mode if a signal URL is configured, even on localhost
|
// Use browser mode if a signal URL is configured, even on localhost
|
||||||
const useBrowser = !isLocal || !!cfg?.signalURL
|
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() {
|
export default function App() {
|
||||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||||
|
|
||||||
@@ -21,6 +50,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [connect, connectBrowser])
|
}, [connect, connectBrowser])
|
||||||
|
|
||||||
|
useWailsNotifications()
|
||||||
|
|
||||||
if (daemonStatus !== 'connected' || !localPeer) {
|
if (daemonStatus !== 'connected' || !localPeer) {
|
||||||
return <Onboarding status={daemonStatus} />
|
return <Onboarding status={daemonStatus} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useWaste } from '../store'
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export function MessagePane() {
|
export function MessagePane() {
|
||||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, send } = useWaste()
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
const roomMessages = messages[activeRoom] ?? []
|
const msgKey = activeNetworkId ? `${activeNetworkId}:${activeRoom}` : activeRoom
|
||||||
|
const roomMessages = messages[msgKey] ?? []
|
||||||
|
const cutoff = historyCutoff[msgKey] ?? 0
|
||||||
|
|
||||||
|
// Find the index of the first live message (ts > cutoff).
|
||||||
|
// The divider appears just before this index, or at the top if all are history.
|
||||||
|
const firstLiveIdx = cutoff > 0
|
||||||
|
? roomMessages.findIndex(m => m.ts > cutoff)
|
||||||
|
: -1
|
||||||
|
// If all messages are history (no live yet), put divider at the start.
|
||||||
|
const dividerIdx = cutoff > 0
|
||||||
|
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
|
||||||
|
: -1
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -30,7 +53,9 @@ export function MessagePane() {
|
|||||||
|
|
||||||
function aliasFor(fromId: string) {
|
function aliasFor(fromId: string) {
|
||||||
if (fromId === localPeer?.id) return localPeer.alias
|
if (fromId === localPeer?.id) return localPeer.alias
|
||||||
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
|
return connectedPeers.find(p => p.id === fromId)?.alias
|
||||||
|
?? knownPeers[fromId]
|
||||||
|
?? fromId.slice(0, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomLabel = activeRoom.startsWith('dm:')
|
const roomLabel = activeRoom.startsWith('dm:')
|
||||||
@@ -42,16 +67,24 @@ export function MessagePane() {
|
|||||||
<div className="message-pane-header">{roomLabel}</div>
|
<div className="message-pane-header">{roomLabel}</div>
|
||||||
|
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
|
{dividerIdx === 0 && (
|
||||||
|
<div className="history-divider"><span>earlier messages</span></div>
|
||||||
|
)}
|
||||||
{roomMessages.map((msg, i) => {
|
{roomMessages.map((msg, i) => {
|
||||||
const mine = msg.from === localPeer?.id
|
const mine = msg.from === localPeer?.id
|
||||||
const alias = aliasFor(msg.from)
|
const alias = aliasFor(String(msg.from))
|
||||||
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
const ts = formatTs(msg.ts)
|
||||||
return (
|
return (
|
||||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
<div key={msg.mid ?? i}>
|
||||||
<span className="message-ts">{time}</span>
|
{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-alias">{alias}</span>
|
||||||
<span className="message-text">{msg.text}</span>
|
<span className="message-text">{msg.text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -32,16 +32,25 @@ export function Sidebar() {
|
|||||||
networks, activeNetworkId, activeRoom,
|
networks, activeNetworkId, activeRoom,
|
||||||
connectedPeers, peerStatus,
|
connectedPeers, peerStatus,
|
||||||
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||||
customRooms, createRoom, logout,
|
customRooms, createRoom, logout, send,
|
||||||
} = useWaste()
|
} = useWaste()
|
||||||
const [addingRoom, setAddingRoom] = useState(false)
|
const [addingRoom, setAddingRoom] = useState(false)
|
||||||
const [newRoomName, setNewRoomName] = useState('')
|
const [newRoomName, setNewRoomName] = useState('')
|
||||||
|
const [addingNetwork, setAddingNetwork] = useState(false)
|
||||||
|
const [newNetName, setNewNetName] = useState('')
|
||||||
|
const [newNetAnchor, setNewNetAnchor] = useState('')
|
||||||
|
|
||||||
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
|
||||||
const rooms = ['general', ...netCustomRooms]
|
const rooms = ['general', ...netCustomRooms]
|
||||||
Object.keys(messages).forEach(r => {
|
if (activeNetworkId) {
|
||||||
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
|
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) {
|
function submitNewRoom(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -50,10 +59,40 @@ export function Sidebar() {
|
|||||||
setAddingRoom(false)
|
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 displayAlias = localPeer?.alias ?? masterAlias ?? ''
|
||||||
const displayId = localPeer?.id ?? masterId ?? ''
|
const displayId = localPeer?.id ?? masterId ?? ''
|
||||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
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() {
|
function handleLogout() {
|
||||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||||
logout(clearId)
|
logout(clearId)
|
||||||
@@ -82,7 +121,15 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-label-row">
|
||||||
<span className="sidebar-label">Networks</span>
|
<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 => (
|
{networks.map(n => (
|
||||||
<button
|
<button
|
||||||
key={n.network_id}
|
key={n.network_id}
|
||||||
@@ -92,6 +139,29 @@ export function Sidebar() {
|
|||||||
{n.network_name}
|
{n.network_name}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Transfers() {
|
export function Transfers() {
|
||||||
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
|
||||||
|
|
||||||
const hasPending = Object.keys(pendingOffers).length > 0
|
const hasPending = Object.keys(pendingOffers).length > 0
|
||||||
const hasActive = Object.keys(fileProgress).length > 0
|
const hasActive = Object.keys(fileProgress).length > 0
|
||||||
|
const hasResumable = Object.keys(resumableFiles).length > 0
|
||||||
|
|
||||||
if (!hasPending && !hasActive) return null
|
if (!hasPending && !hasActive && !hasResumable) return null
|
||||||
|
|
||||||
function alias(peerId: string) {
|
function alias(peerId: string) {
|
||||||
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
|
||||||
@@ -22,6 +23,24 @@ export function Transfers() {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<span className="sidebar-label">Transfers</span>
|
<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]) => (
|
{Object.entries(pendingOffers).map(([xid, offer]) => (
|
||||||
<div key={xid} className="transfer-row">
|
<div key={xid} className="transfer-row">
|
||||||
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
<span className="transfer-name" title={offer.name}>{offer.name}</span>
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ interface Props {
|
|||||||
// ?a=<url> anchor URL hint
|
// ?a=<url> anchor URL hint
|
||||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||||
const p = new URLSearchParams(window.location.search)
|
const p = new URLSearchParams(window.location.search)
|
||||||
const inviteString = p.get('invite') ?? ''
|
let inviteString = p.get('invite') ?? ''
|
||||||
let network = p.get('n') ?? p.get('network') ?? ''
|
let network = p.get('n') ?? p.get('network') ?? ''
|
||||||
let netHash = p.get('net') ?? ''
|
let netHash = p.get('net') ?? ''
|
||||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
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:')) {
|
if (inviteString.startsWith('waste:')) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
@@ -66,8 +72,15 @@ export function Onboarding({ status }: Props) {
|
|||||||
if (adapterMode !== 'browser' || status !== 'connected') return
|
if (adapterMode !== 'browser' || status !== 'connected') return
|
||||||
const { network: n, netHash: nh } = parseInviteParams()
|
const { network: n, netHash: nh } = parseInviteParams()
|
||||||
if (n || nh) return // explicit invite — don't auto-join, show form
|
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')
|
const savedNetwork = localStorage.getItem('waste_last_network')
|
||||||
if (savedNetwork) doJoin(savedNetwork, '')
|
if (savedNetwork) doJoin(savedNetwork, '')
|
||||||
|
}
|
||||||
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
function joinNetwork(e: React.FormEvent) {
|
function joinNetwork(e: React.FormEvent) {
|
||||||
@@ -81,8 +94,16 @@ export function Onboarding({ status }: Props) {
|
|||||||
if (adapterMode === 'browser') {
|
if (adapterMode === 'browser') {
|
||||||
localStorage.setItem('waste_anchor_url', anchorUrl)
|
localStorage.setItem('waste_anchor_url', anchorUrl)
|
||||||
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
||||||
if (name) localStorage.setItem('waste_last_network', name)
|
if (name) {
|
||||||
else localStorage.removeItem('waste_last_network')
|
// 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) {
|
if (hash.length === 64 && !name) {
|
||||||
@@ -115,6 +136,7 @@ export function Onboarding({ status }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
|
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 ────────────────────────────────────────────────
|
// ── disconnected / connecting ────────────────────────────────────────────────
|
||||||
if (status !== 'connected') {
|
if (status !== 'connected') {
|
||||||
@@ -153,8 +175,21 @@ export function Onboarding({ status }: Props) {
|
|||||||
</div>
|
</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">
|
<form onSubmit={joinNetwork} className="join-form">
|
||||||
<label className="join-label">Join a network</label>
|
<label className="join-label">{savedNetworks.length > 0 ? 'Join another network' : 'Join a network'}</label>
|
||||||
|
|
||||||
{adapterMode === 'browser' && (
|
{adapterMode === 'browser' && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ interface WasteState {
|
|||||||
|
|
||||||
// peers
|
// peers
|
||||||
connectedPeers: PeerInfo[]
|
connectedPeers: PeerInfo[]
|
||||||
|
knownPeers: Record<string, string> // id → alias for historical peers
|
||||||
|
|
||||||
// chat — keyed by room
|
// chat — keyed by room
|
||||||
messages: Record<string, ChatMessage[]>
|
messages: Record<string, ChatMessage[]>
|
||||||
|
// rooms for which we have received history: room → ts of last history message
|
||||||
|
historyCutoff: Record<string, number>
|
||||||
activeRoom: string
|
activeRoom: string
|
||||||
// user-created rooms, keyed by networkId
|
// user-created rooms, keyed by networkId
|
||||||
customRooms: Record<string, string[]>
|
customRooms: Record<string, string[]>
|
||||||
@@ -53,6 +56,8 @@ interface WasteState {
|
|||||||
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
|
||||||
// active in-progress transfers: xid → progress
|
// active in-progress transfers: xid → progress
|
||||||
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
|
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 }>
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
connect: (url: string) => void
|
connect: (url: string) => void
|
||||||
@@ -83,7 +88,9 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
networks: [],
|
networks: [],
|
||||||
activeNetworkId: null,
|
activeNetworkId: null,
|
||||||
connectedPeers: [],
|
connectedPeers: [],
|
||||||
|
knownPeers: {},
|
||||||
messages: {},
|
messages: {},
|
||||||
|
historyCutoff: {},
|
||||||
activeRoom: 'general',
|
activeRoom: 'general',
|
||||||
customRooms: {},
|
customRooms: {},
|
||||||
fileLists: {},
|
fileLists: {},
|
||||||
@@ -93,6 +100,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
sharedFilesByNetwork: {},
|
sharedFilesByNetwork: {},
|
||||||
pendingOffers: {},
|
pendingOffers: {},
|
||||||
fileProgress: {},
|
fileProgress: {},
|
||||||
|
resumableFiles: {},
|
||||||
|
|
||||||
connect(url: string) {
|
connect(url: string) {
|
||||||
const adapter = new DaemonAdapter(url)
|
const adapter = new DaemonAdapter(url)
|
||||||
@@ -209,6 +217,8 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'state_snapshot': {
|
case 'state_snapshot': {
|
||||||
const networks = msg.networks ?? []
|
const networks = msg.networks ?? []
|
||||||
|
const knownPeers: Record<string, string> = {}
|
||||||
|
for (const p of msg.known_peers ?? []) knownPeers[p.id] = p.alias
|
||||||
set({
|
set({
|
||||||
masterAlias: msg.master_alias ?? null,
|
masterAlias: msg.master_alias ?? null,
|
||||||
masterId: msg.master_id ?? null,
|
masterId: msg.master_id ?? null,
|
||||||
@@ -216,6 +226,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
networks,
|
networks,
|
||||||
connectedPeers: msg.connected_peers ?? [],
|
connectedPeers: msg.connected_peers ?? [],
|
||||||
activeNetworkId: networks[0]?.network_id ?? null,
|
activeNetworkId: networks[0]?.network_id ?? null,
|
||||||
|
knownPeers,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -272,14 +283,14 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
case 'message_received': {
|
case 'message_received': {
|
||||||
if (msg.message) {
|
if (msg.message) {
|
||||||
const m = msg.message
|
const m = msg.message
|
||||||
const room = m.room
|
const key = `${msg.network_id}:${m.room}`
|
||||||
const fromId = String(m.from)
|
const fromId = String(m.from)
|
||||||
set(s => {
|
set(s => {
|
||||||
const existing = s.messages[room] ?? []
|
const existing = s.messages[key] ?? []
|
||||||
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
if (m.mid && existing.some(e => e.mid === m.mid)) return s
|
||||||
const prev = s.peerStatus[fromId] ?? {}
|
const prev = s.peerStatus[fromId] ?? {}
|
||||||
return {
|
return {
|
||||||
messages: { ...s.messages, [room]: [...existing, m] },
|
messages: { ...s.messages, [key]: [...existing, m] },
|
||||||
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -341,10 +352,13 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'file_complete': {
|
case 'file_complete': {
|
||||||
if (msg.path && msg.offer?.name) {
|
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
|
||||||
// clear progress entry
|
const xid = msg.transfer_id ?? msg.offer?.xid
|
||||||
const xid = msg.offer.xid
|
if (xid) {
|
||||||
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
|
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')
|
const a = document.createElement('a')
|
||||||
a.href = msg.path
|
a.href = msg.path
|
||||||
a.download = msg.offer.name
|
a.download = msg.offer.name
|
||||||
@@ -352,6 +366,33 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
break
|
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 '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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export type IpcMsgType =
|
|||||||
| 'shares_list'
|
| 'shares_list'
|
||||||
| 'peer_status'
|
| 'peer_status'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'history_loaded'
|
||||||
|
| 'room_created'
|
||||||
|
| 'create_room'
|
||||||
|
| 'resumable_transfers'
|
||||||
|
|
||||||
export interface IpcMessage {
|
export interface IpcMessage {
|
||||||
type: IpcMsgType
|
type: IpcMsgType
|
||||||
@@ -113,6 +117,7 @@ export interface IpcMessage {
|
|||||||
master_id?: string
|
master_id?: string
|
||||||
local_peer?: PeerInfo
|
local_peer?: PeerInfo
|
||||||
connected_peers?: PeerInfo[]
|
connected_peers?: PeerInfo[]
|
||||||
|
known_peers?: PeerInfo[]
|
||||||
rooms?: string[]
|
rooms?: string[]
|
||||||
networks?: NetworkInfo[]
|
networks?: NetworkInfo[]
|
||||||
error_message?: string
|
error_message?: string
|
||||||
@@ -124,4 +129,8 @@ export interface IpcMessage {
|
|||||||
conn_state?: PeerConnState
|
conn_state?: PeerConnState
|
||||||
candidate_type?: CandidateType
|
candidate_type?: CandidateType
|
||||||
remote_address?: string
|
remote_address?: string
|
||||||
|
// history_loaded
|
||||||
|
messages?: ChatMessage[]
|
||||||
|
// resumable_transfers
|
||||||
|
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user