Compare commits
14 Commits
0e8ddbf4f4
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
launch-tui.sh
|
||||
*.exe
|
||||
# compiled binary names that land in the repo root
|
||||
# compiled binaries that land in the repo root
|
||||
/anchor
|
||||
/waste-anchor
|
||||
/waste-daemon
|
||||
/waste
|
||||
/app
|
||||
/relay
|
||||
*.identity.json
|
||||
/tmp/
|
||||
@@ -20,3 +22,5 @@ deploy-web.sh
|
||||
deploy-daemon.sh
|
||||
serve-web.sh
|
||||
web/public/config.js
|
||||
cmd/app/frontend/dist/*
|
||||
!cmd/app/frontend/dist/.gitkeep
|
||||
|
||||
306
EXTENSIONS.md
Normal file
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.
|
||||
40
FUTURE.md
40
FUTURE.md
@@ -38,12 +38,12 @@ React + Vite frontend. Two modes:
|
||||
### 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.
|
||||
|
||||
### TURN relay ✅ (shipped, browser mode)
|
||||
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.
|
||||
### TURN relay ✅ (shipped)
|
||||
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)
|
||||
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.
|
||||
- **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)
|
||||
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.
|
||||
### Additional Channels / Rooms ✅ (shipped)
|
||||
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)
|
||||
- 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
|
||||
- Push (📎) sends directly to a peer without them needing to share a folder
|
||||
|
||||
**Not yet done:** resume after disconnection, daemon-side download directory.
|
||||
|
||||
### 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.
|
||||
**Not yet done:** daemon-side resume UX (IPC event to surface resumable transfers to the UI on reconnect).
|
||||
|
||||
### Native UI
|
||||
Web frontend (React, already built) + 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`. Scaffolded in `cmd/app/`; build with `./build-app.sh`. Remaining work: system tray, OS notifications.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,10 +122,17 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
||||
| next | TURN relay for daemon mode |
|
||||
| next | TUI room creation + daemon-side room persistence |
|
||||
| next | File transfer resume after disconnection |
|
||||
| future | Native UI (React + Tauri) |
|
||||
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
|
||||
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
|
||||
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
|
||||
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
|
||||
| ✅ shipped | TUI room creation + daemon-side room persistence |
|
||||
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
|
||||
| ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) |
|
||||
| ✅ shipped | File transfer resume after disconnection |
|
||||
| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android |
|
||||
| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary |
|
||||
| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
154
PROPOSAL-history-gossip.md
Normal file
154
PROPOSAL-history-gossip.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Proposal: P2P Message History Gossip
|
||||
|
||||
## Goals
|
||||
|
||||
- New peers joining a network can retrieve recent message history from existing peers
|
||||
- No central storage — history lives only in peer daemons (SQLite)
|
||||
- No new trust requirements — history is shared only over already-established encrypted DataChannels
|
||||
- No duplicates in the local store or UI
|
||||
- No conflicts — history gossip is append-only and idempotent
|
||||
|
||||
---
|
||||
|
||||
## Privacy Model
|
||||
|
||||
History is shared **peer-to-peer over the encrypted mesh**, never via the anchor. The anchor remains dumb — it sees only signaling blobs. A peer only receives history from peers they have successfully completed a YAW/2 handshake with, so the same trust boundary as live messages applies.
|
||||
|
||||
A peer can choose not to share history by ignoring `history_request` messages — the protocol is advisory, not mandatory.
|
||||
|
||||
---
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
Two new YAW/2 extension messages (added to EXTENSIONS.md):
|
||||
|
||||
### `history_request`
|
||||
|
||||
Sent by a newly-connected peer to one or more existing peers shortly after the handshake completes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_request",
|
||||
"room": "general",
|
||||
"since": "2026-01-01T00:00:00Z",
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|-----------------|----------------------------------------------------------|
|
||||
| `room` | string | Room name to request history for. One request per room. |
|
||||
| `since` | ISO 8601 or "" | Only return messages newer than this timestamp. Empty = return up to `limit` most recent. |
|
||||
| `limit` | int (max 500) | Maximum number of messages to return. Responder may return fewer. |
|
||||
|
||||
### `history_chunk`
|
||||
|
||||
Response from an existing peer. May be sent in multiple chunks if `limit` is large.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "history_chunk",
|
||||
"room": "general",
|
||||
"messages": [
|
||||
{
|
||||
"id": "sha256:<hex>",
|
||||
"from": "<peer-alias>",
|
||||
"from_id": "<hex-pubkey>",
|
||||
"body": "hello",
|
||||
"ts": "2026-06-01T12:00:00Z",
|
||||
"room": "general"
|
||||
}
|
||||
],
|
||||
"done": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|---------|----------------------------------------------------------|
|
||||
| `messages` | array | Ordered oldest-first. |
|
||||
| `done` | bool | `true` on the final chunk. Receiver may display after this. |
|
||||
|
||||
---
|
||||
|
||||
## Message Identity and Deduplication
|
||||
|
||||
Each message has a **content-addressed ID**:
|
||||
|
||||
```
|
||||
id = "sha256:" + hex(SHA-256(from_id || room || ts || body))
|
||||
```
|
||||
|
||||
- Computed by the original sender and included in every live message going forward
|
||||
- The SQLite `messages` table gains an `id TEXT UNIQUE` column
|
||||
- On insert, use `INSERT OR IGNORE` — receiving the same message twice (live or via gossip) is a no-op
|
||||
- The UI sorts by `ts`, so late-arriving history slots in correctly without reordering visible messages
|
||||
|
||||
Legacy messages (before this feature) have no `id`. They are assigned a local-only ID on migration and are never gossipped (they have no canonical ID the receiver could deduplicate against).
|
||||
|
||||
---
|
||||
|
||||
## Daemon-Side Implementation
|
||||
|
||||
### SQLite schema change
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages ADD COLUMN msg_id TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages(msg_id) WHERE msg_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Handling `history_request`
|
||||
|
||||
```
|
||||
peer sends history_request{room, since, limit}
|
||||
→ query SQLite: SELECT * FROM messages WHERE room=? AND ts>? ORDER BY ts ASC LIMIT ?
|
||||
→ send history_chunk{room, messages, done:true}
|
||||
```
|
||||
|
||||
Responder enforces:
|
||||
- `limit` capped at 500
|
||||
- Only messages the responder itself received or sent (no re-gossipping of gossipped history to avoid amplification)
|
||||
- Rate limit: one `history_request` per peer per room per 60 seconds
|
||||
|
||||
### Handling `history_chunk`
|
||||
|
||||
```
|
||||
for each message in chunk:
|
||||
INSERT OR IGNORE INTO messages (msg_id, room, from_alias, from_id, body, ts) VALUES (...)
|
||||
emit IPC event: history_loaded{room, count}
|
||||
```
|
||||
|
||||
### When to request
|
||||
|
||||
- After handshake completes with the **first** peer in a network (only ask one peer — avoids fan-out)
|
||||
- Request rooms the local peer knows about (from its own SQLite `rooms` table)
|
||||
- If no rooms known yet: request `"general"` only; discover others from incoming live messages
|
||||
|
||||
---
|
||||
|
||||
## IPC / UI Integration
|
||||
|
||||
New IPC event emitted after history is loaded:
|
||||
|
||||
```json
|
||||
{ "type": "history_loaded", "network_id": "...", "room": "general", "count": 47 }
|
||||
```
|
||||
|
||||
The web UI and TUI insert a visual separator above the first gossipped message:
|
||||
|
||||
```
|
||||
── 47 earlier messages ─────────────────────────────
|
||||
[12:03] alice: hey
|
||||
[12:04] bob: yo
|
||||
── live ─────────────────────────────────────────────
|
||||
[14:22] you joined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What This Does Not Do
|
||||
|
||||
- **No conflict resolution** — messages are immutable append-only records; there is nothing to conflict
|
||||
- **No ordering guarantee beyond timestamp** — if two peers sent messages at the same millisecond, both are stored; the UI sorts by `ts` then `msg_id` for a stable tiebreak
|
||||
- **No full sync** — gossip is bounded by `limit` and `since`; it is not a replication protocol
|
||||
- **No anchor involvement** — the anchor never sees history
|
||||
- **No history from peers who were offline** — if no peer with history is online when you join, you get nothing (acceptable given the trust model)
|
||||
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.
|
||||
106
README.md
106
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).
|
||||
|
||||
**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
|
||||
@@ -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.
|
||||
|
||||
**Mobile:** the web UI ships a PWA manifest (`manifest.json`) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required.
|
||||
|
||||
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
|
||||
|
||||
### Daemon mode (for users running the daemon locally)
|
||||
@@ -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.
|
||||
|
||||
### Transfer resume (daemon mode)
|
||||
|
||||
When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A `.meta` sidecar is written alongside each in-progress `.tmp` file recording the file name, size, and SHA-256.
|
||||
|
||||
When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a `file-accept` with a non-zero `resume_offset`, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end.
|
||||
|
||||
Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the `.tmp`/`.meta` files are manually deleted.
|
||||
|
||||
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
|
||||
|
||||
### Daemon download directory
|
||||
|
||||
Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory:
|
||||
|
||||
```
|
||||
~/.waste/downloads-<network-id>/
|
||||
```
|
||||
|
||||
Pass `-download-dir` at startup to use a different base:
|
||||
|
||||
```bash
|
||||
go run ./cmd/daemon -download-dir ~/Downloads ...
|
||||
```
|
||||
|
||||
Files then land in `~/Downloads/downloads-<network-id>/`. The path can also be changed at runtime via the `set_download_dir` IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every `state_snapshot` event under `networks[].download_dir`.
|
||||
|
||||
---
|
||||
|
||||
## Desktop app (Wails)
|
||||
|
||||
`cmd/app/` is a [Wails v2](https://wails.io) shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview.
|
||||
|
||||
The daemon runs embedded in the same process. The webview connects to `ws://127.0.0.1:17338` exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (`~/Library/Application Support/waste` on macOS, `~/.config/waste` on Linux, `%APPDATA%\waste` on Windows).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24+
|
||||
- Node.js 20+
|
||||
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- Linux platform deps: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app)
|
||||
./build-app.sh
|
||||
|
||||
# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev)
|
||||
./build-app.sh dev
|
||||
|
||||
# Build without system tray (no libayatana-appindicator3-dev needed)
|
||||
CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh
|
||||
```
|
||||
|
||||
`build-app.sh` builds the React frontend, copies `web/dist/` into `cmd/app/frontend/dist/` for embedding, then runs `wails build`. The result is a single self-contained binary.
|
||||
|
||||
### What the desktop app provides
|
||||
|
||||
- **Single binary** — daemon logic is embedded, no subprocess to manage
|
||||
- **System tray** (Linux/Windows) — hide to tray, reopen from tray menu, Quit
|
||||
- **macOS** — hides to Dock on window close; full menu-bar tray is future work
|
||||
- **OS notifications** — native popup on new message or completed file transfer (uses the browser `Notification` API via Wails events; the app will request permission on first notification)
|
||||
- **Data directory** — OS-appropriate config dir (`~/.config/waste`, `~/Library/Application Support/waste`, `%APPDATA%\waste`)
|
||||
|
||||
### CI / automated builds
|
||||
|
||||
`.gitea/workflows/build.yml` runs on every `v*` tag push:
|
||||
|
||||
- **Server binaries** (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed
|
||||
- **Desktop app**: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically)
|
||||
- **Releases**: all artifacts attached to the Gitea release
|
||||
|
||||
To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the `desktop-linux` job.
|
||||
|
||||
---
|
||||
|
||||
## Local development
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
@@ -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
|
||||
|
||||
**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
|
||||
@@ -316,17 +412,20 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
|
||||
{"type":"get_file_list"}
|
||||
{"type":"get_file_list","peer_id":"<64-hex>"}
|
||||
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
|
||||
{"type":"add_share","path":"/home/alice/Music"} // global share
|
||||
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
|
||||
{"type":"add_share","path":"/home/alice/Music"} // global share
|
||||
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
|
||||
{"type":"remove_share","path":"/home/alice/Music"}
|
||||
{"type":"list_shares"}
|
||||
{"type":"create_room","room":"dev"}
|
||||
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
|
||||
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
|
||||
{"type":"export_identity","passphrase":"..."}
|
||||
{"type":"import_identity","passphrase":"...","backup":"..."}
|
||||
```
|
||||
|
||||
**Events:**
|
||||
```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":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
||||
{"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":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
|
||||
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
|
||||
{"type":"room_created","network_id":"...","room":"dev"}
|
||||
{"type":"identity_exported","backup":"..."}
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
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")
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||
downloadDir := flag.String("download-dir", "", "base directory for received files; defaults to data-dir, split by network")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
|
||||
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
|
||||
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||
flag.Parse()
|
||||
@@ -74,6 +77,9 @@ func main() {
|
||||
StoreDir: dir,
|
||||
AnchorURL: *anchorURL,
|
||||
ShareDir: expandHome(*shareDir),
|
||||
DownloadDir: expandHome(*downloadDir),
|
||||
TurnURL: *turnURL,
|
||||
TurnSecret: *turnSecret,
|
||||
})
|
||||
|
||||
if autoJoinNetwork != "" {
|
||||
|
||||
@@ -107,6 +107,7 @@ type model struct {
|
||||
rooms []string // "general" always first; DM rooms appended
|
||||
activeRoom int
|
||||
messages map[string][]entry
|
||||
unread map[string]bool // rooms with messages since last viewed
|
||||
|
||||
peers map[proto.PeerID]string // connected peers: id → alias
|
||||
peerOrder []proto.PeerID
|
||||
@@ -131,6 +132,7 @@ func newModel(ipcPort int, network string) model {
|
||||
networkName: network,
|
||||
rooms: []string{"general"},
|
||||
messages: make(map[string][]entry),
|
||||
unread: make(map[string]bool),
|
||||
peers: make(map[proto.PeerID]string),
|
||||
input: ti,
|
||||
status: "connecting…",
|
||||
@@ -215,9 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
case msg.Type == tea.KeyShiftTab:
|
||||
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
|
||||
delete(m.unread, m.activeRoomName())
|
||||
m = m.refreshViewport()
|
||||
default:
|
||||
var tiCmd tea.Cmd
|
||||
@@ -251,8 +255,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
m.peers[p.ID] = p.Alias
|
||||
m.peerOrder = append(m.peerOrder, p.ID)
|
||||
}
|
||||
for _, r := range evt.Rooms {
|
||||
m = m.addRoom(r)
|
||||
}
|
||||
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
|
||||
|
||||
case proto.EvtRoomCreated:
|
||||
m = m.addRoom(evt.Room)
|
||||
m = m.refreshViewport()
|
||||
|
||||
case proto.EvtSessionReady:
|
||||
if evt.PeerID != nil {
|
||||
pid := *evt.PeerID
|
||||
@@ -283,7 +294,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
}
|
||||
|
||||
case proto.EvtInviteGenerated:
|
||||
m.invitePopup = evt.InviteString
|
||||
m.invitePopup = evt.InviteGenerated
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
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 = m.addRoom(msg.Room)
|
||||
if msg.Room != m.activeRoomName() {
|
||||
m.unread[msg.Room] = true
|
||||
}
|
||||
m = m.refreshViewport()
|
||||
}
|
||||
}
|
||||
@@ -309,6 +323,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
if strings.HasPrefix(body, "/room ") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
|
||||
if name != "" {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
|
||||
}
|
||||
return m, cmds
|
||||
}
|
||||
|
||||
room := m.rooms[m.activeRoom]
|
||||
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
|
||||
if strings.HasPrefix(room, "dm:") {
|
||||
@@ -430,7 +452,7 @@ func (m model) View() string {
|
||||
if m.errMsg != "" {
|
||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -468,7 +490,11 @@ func (m model) renderRooms(boxH int) string {
|
||||
if i == m.activeRoom {
|
||||
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
|
||||
} 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 {
|
||||
|
||||
39
go.mod
39
go.mod
@@ -6,14 +6,16 @@ require (
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
@@ -27,7 +29,26 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||
github.com/getlantern/systray v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
@@ -35,6 +56,7 @@ require (
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
@@ -50,15 +72,24 @@ require (
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/wailsapp/wails/v2 v2.12.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
85
go.sum
85
go.sum
@@ -1,9 +1,13 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
@@ -31,21 +35,63 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
|
||||
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
@@ -60,6 +106,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
@@ -102,22 +150,45 @@ github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
@@ -130,12 +201,15 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
@@ -144,17 +218,24 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -173,6 +254,7 @@ golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
@@ -180,6 +262,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -187,6 +271,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
mu.Lock()
|
||||
if sess == nil {
|
||||
// Answerer: we haven't created a session yet, do it now.
|
||||
pc, err := newPC()
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
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
|
||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||
pc, err := newPC()
|
||||
pc, err := newPC(m.ICEServers)
|
||||
if err != nil {
|
||||
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.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newPC() (*webrtc.PeerConnection, error) {
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
||||
})
|
||||
func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||
servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
|
||||
return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -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.
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(id.privateKey, data)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
||||
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||
// the same network without knowing the plaintext name.
|
||||
//
|
||||
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
|
||||
// the invite was issued by a known peer. Receiving peers that enforce
|
||||
// RequireInvite will reject hellos that carry no valid signed invite.
|
||||
// YAW/2-only peers ignore both fields.
|
||||
package invite
|
||||
|
||||
import (
|
||||
@@ -21,29 +26,69 @@ const prefix = "waste:"
|
||||
|
||||
// Invite holds the information needed to join a network.
|
||||
type Invite struct {
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
||||
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
|
||||
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
|
||||
}
|
||||
|
||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
||||
// IsSigned reports whether the invite carries a signature.
|
||||
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
|
||||
|
||||
// Signer can sign data and report its own peer ID.
|
||||
type Signer interface {
|
||||
Sign(data []byte) string
|
||||
PeerIDHex() string
|
||||
}
|
||||
|
||||
// Verifier verifies an Ed25519 signature given a hex public key.
|
||||
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
|
||||
|
||||
// Encode returns an unsigned waste: invite string (backward compatible).
|
||||
func Encode(anchor, network string) (string, error) {
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
Network: network,
|
||||
Net: NetHash(network),
|
||||
})
|
||||
}
|
||||
|
||||
// EncodeSigned returns a signed waste: invite string.
|
||||
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
|
||||
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
|
||||
if anchor == "" {
|
||||
return "", fmt.Errorf("anchor URL is required")
|
||||
}
|
||||
if network == "" {
|
||||
return "", fmt.Errorf("network name is required")
|
||||
}
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
||||
b, err := json.Marshal(Invite{
|
||||
inviter := signer.PeerIDHex()
|
||||
net := NetHash(network)
|
||||
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||
return marshal(Invite{
|
||||
Anchor: anchor,
|
||||
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.
|
||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// NetHash returns the full 64-char hex network hash for the given name.
|
||||
func NetHash(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func marshal(inv Invite) (string, error) {
|
||||
b, err := json.Marshal(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func sigPayload(anchor, network, net, inviter string) []byte {
|
||||
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
@@ -146,7 +147,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
case cmd.NetworkName != "":
|
||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||
case len(cmd.NetworkHash) == 64:
|
||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||
default:
|
||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
||||
@@ -156,7 +156,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||
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
|
||||
|
||||
case proto.CmdLeaveNetwork:
|
||||
@@ -207,6 +214,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
} else {
|
||||
// Group chat → spec "chat" type: flat {type, mid, room, text, ts}
|
||||
mid := randomHex(16)
|
||||
msgID := proto.ComputeMsgID(n.Identity.PeerID(), cmd.Room, ts, cmd.Body)
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgChat,
|
||||
Mid: mid,
|
||||
@@ -219,11 +227,12 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
}
|
||||
n.Mesh.Broadcast(wire)
|
||||
local := &proto.ChatMessage{
|
||||
Mid: mid,
|
||||
From: n.Identity.PeerID(),
|
||||
Room: cmd.Room,
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
Mid: mid,
|
||||
MsgID: msgID,
|
||||
From: n.Identity.PeerID(),
|
||||
Room: cmd.Room,
|
||||
Text: cmd.Body,
|
||||
Ts: ts,
|
||||
}
|
||||
n.Mesh.SaveMessage(local)
|
||||
n.Mesh.Emit(proto.IpcMessage{
|
||||
@@ -233,6 +242,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:
|
||||
send(stateSnapshot(mgr))
|
||||
|
||||
@@ -298,15 +324,15 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||
continue
|
||||
}
|
||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
||||
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{
|
||||
Type: proto.EvtInviteGenerated,
|
||||
NetworkID: n.ID,
|
||||
InviteString: inv,
|
||||
Type: proto.EvtInviteGenerated,
|
||||
NetworkID: n.ID,
|
||||
InviteGenerated: inv,
|
||||
})
|
||||
|
||||
case proto.CmdSetShareDir:
|
||||
@@ -320,6 +346,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
||||
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:
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
@@ -410,6 +452,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
|
||||
pi := all[0].Identity.PeerInfo()
|
||||
msg.LocalPeer = &pi
|
||||
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
|
||||
if extra, err := all[0].Store.Rooms(); err == nil {
|
||||
for _, r := range extra {
|
||||
msg.Rooms = append(msg.Rooms, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"github.com/waste-go/internal/store"
|
||||
)
|
||||
|
||||
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
|
||||
type ICEServer = webrtc.ICEServer
|
||||
|
||||
// PeerConn is a live connection to one peer.
|
||||
type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
@@ -27,11 +31,14 @@ type PeerConn struct {
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
|
||||
InviteString string // the invite this peer used to join (sent in hello to other peers)
|
||||
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||
// multi-share scanning without the mesh needing to know about shares.json.
|
||||
ScanFiles func() []proto.FileEntry
|
||||
ScanFiles func() []proto.FileEntry
|
||||
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
@@ -45,6 +52,12 @@ type Mesh struct {
|
||||
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
||||
PendingConnect chan proto.PeerID
|
||||
|
||||
// historyRequested tracks rooms for which we have already sent a history_request
|
||||
// this session. Reset on reconnect is intentional (new peers may have newer history).
|
||||
historyMu sync.Mutex
|
||||
historyRequested map[string]bool // room → true
|
||||
historyFirstPeer proto.PeerID // ID of the peer we requested history from
|
||||
|
||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||
subMu sync.Mutex
|
||||
subs []chan proto.IpcMessage
|
||||
@@ -54,15 +67,39 @@ type Mesh struct {
|
||||
// Pass a non-nil store to enable message and peer persistence.
|
||||
func New(id *crypto.Identity, st *store.Store) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
Store: st,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
outbound: make(map[string]*outboundTransfer),
|
||||
inbound: make(map[string]*inboundTransfer),
|
||||
PendingConnect: make(chan proto.PeerID, 32),
|
||||
Identity: id,
|
||||
Store: st,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
outbound: make(map[string]*outboundTransfer),
|
||||
inbound: make(map[string]*inboundTransfer),
|
||||
PendingConnect: make(chan proto.PeerID, 32),
|
||||
historyRequested: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// trustedPeerIDs returns a set of peer IDs trusted on this network:
|
||||
// all currently connected peers plus all peers in the persistent store.
|
||||
func (m *Mesh) trustedPeerIDs() map[string]bool {
|
||||
trusted := map[string]bool{}
|
||||
// Own identity is always trusted.
|
||||
trusted[string(m.Identity.PeerID())] = true
|
||||
// Connected peers.
|
||||
m.mu.RLock()
|
||||
for id := range m.peers {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
// Previously seen peers from the store.
|
||||
if m.Store != nil {
|
||||
if known, err := m.Store.KnownPeers(); err == nil {
|
||||
for id := range known {
|
||||
trusted[string(id)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// ScanShareDir returns the list of files in the local share directory.
|
||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||
@@ -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).
|
||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||
m.emit(msg)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
@@ -43,11 +44,12 @@ func WireDataChannel(
|
||||
localFP, remoteFP := dtlsFingerprints(pc)
|
||||
bindBytes := proto.HelloBindString(localFP, remoteFP)
|
||||
hello := proto.HelloMessage{
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
Type: "hello",
|
||||
ID: string(id.PeerID()),
|
||||
Nick: id.Alias,
|
||||
Caps: []string{"chat", "file"},
|
||||
Sig: id.Sign(bindBytes),
|
||||
Invite: m.InviteString,
|
||||
}
|
||||
helloJSON, _ := json.Marshal(hello)
|
||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
||||
@@ -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)
|
||||
return
|
||||
}
|
||||
|
||||
// Invite enforcement (waste-go extension).
|
||||
if m.RequireInvite {
|
||||
if hello.Invite == "" {
|
||||
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
inv, err := invite.Decode(hello.Invite)
|
||||
if err != nil || !inv.IsSigned() {
|
||||
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
|
||||
})
|
||||
return
|
||||
}
|
||||
trusted := m.trustedPeerIDs()
|
||||
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
|
||||
log.Printf("peer: rejecting %s — %v", from.Short(), err)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update alias once we have the verified nick.
|
||||
m.mu.Lock()
|
||||
if conn, ok := m.peers[from]; ok {
|
||||
@@ -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.
|
||||
go m.sendGossipTo(from)
|
||||
// Request message history from this peer (EXT-007).
|
||||
go m.RequestHistoryFrom(from)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,11 +214,12 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
switch msg.Type {
|
||||
case proto.MsgChat:
|
||||
chat := &proto.ChatMessage{
|
||||
Mid: midOrRandom(msg.Mid),
|
||||
From: from,
|
||||
Room: msg.Room,
|
||||
Text: msg.Text,
|
||||
Ts: msg.Ts,
|
||||
Mid: midOrRandom(msg.Mid),
|
||||
MsgID: proto.ComputeMsgID(from, msg.Room, msg.Ts, msg.Text),
|
||||
From: from,
|
||||
Room: msg.Room,
|
||||
Text: msg.Text,
|
||||
Ts: msg.Ts,
|
||||
}
|
||||
m.SaveMessage(chat)
|
||||
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
|
||||
@@ -234,7 +270,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
m.acceptIncoming(msg, from)
|
||||
|
||||
case proto.MsgFileAccept:
|
||||
m.startSend(msg.Xid, from)
|
||||
m.startSend(msg.Xid, from, msg.ResumeOffset)
|
||||
|
||||
case proto.MsgFileCancel:
|
||||
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
||||
@@ -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)
|
||||
case proto.MsgHistoryRequest:
|
||||
go m.HandleHistoryRequest(from, msg.Room, msg.Since, msg.Limit)
|
||||
|
||||
case proto.MsgHistoryChunk:
|
||||
go m.HandleHistoryChunk(msg.Room, msg.History)
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
|
||||
@@ -33,14 +33,62 @@ type outboundTransfer struct {
|
||||
}
|
||||
|
||||
type inboundTransfer struct {
|
||||
from proto.PeerID
|
||||
name string
|
||||
size int64
|
||||
sha256 string
|
||||
mu sync.Mutex
|
||||
tmp *os.File
|
||||
hasher hash.Hash
|
||||
written int64
|
||||
from proto.PeerID
|
||||
name string
|
||||
size int64
|
||||
sha256 string
|
||||
mu sync.Mutex
|
||||
tmp *os.File
|
||||
hasher hash.Hash
|
||||
written int64 // total bytes received; starts at resumeOffset when resuming
|
||||
resumePath string // path to existing .tmp when resuming; empty for new transfers
|
||||
resumeOffset int64 // bytes already present in resumePath
|
||||
metaPath string // path to the .tmp.meta sidecar
|
||||
}
|
||||
|
||||
// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file.
|
||||
// It survives interruptions so the receiver can resume later.
|
||||
type partialMeta struct {
|
||||
Name string `json:"name"`
|
||||
SHA256 string `json:"sha256"`
|
||||
From string `json:"from"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches.
|
||||
// Returns (tmpPath, metaPath, offset) — all empty/zero if no match.
|
||||
func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) {
|
||||
metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta"))
|
||||
for _, mp := range metas {
|
||||
data, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m partialMeta
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
if m.SHA256 != sha256hex {
|
||||
continue
|
||||
}
|
||||
tp := strings.TrimSuffix(mp, ".meta")
|
||||
info, err := os.Stat(tp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return tp, mp, info.Size()
|
||||
}
|
||||
return "", "", 0
|
||||
}
|
||||
|
||||
func writePartialMeta(path string, t *inboundTransfer) {
|
||||
data, _ := json.Marshal(partialMeta{
|
||||
Name: t.name,
|
||||
SHA256: t.sha256,
|
||||
From: string(t.from),
|
||||
Size: t.size,
|
||||
})
|
||||
os.WriteFile(path, data, 0o644) //nolint:errcheck
|
||||
}
|
||||
|
||||
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||
@@ -102,28 +150,49 @@ func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
m.transferMu.Lock()
|
||||
m.inbound[msg.Xid] = &inboundTransfer{
|
||||
t := &inboundTransfer{
|
||||
from: from,
|
||||
name: msg.Name,
|
||||
size: msg.Size,
|
||||
sha256: msg.SHA256,
|
||||
}
|
||||
|
||||
var resumeOffset int64
|
||||
if m.DownloadDir != "" {
|
||||
if tp, mp, off := findPartial(m.DownloadDir, msg.SHA256); tp != "" {
|
||||
t.resumePath = tp
|
||||
t.metaPath = mp
|
||||
t.resumeOffset = off
|
||||
t.written = off
|
||||
resumeOffset = off
|
||||
log.Printf("transfer: found partial for %s at %s (%d/%d bytes)", msg.Name, tp, off, msg.Size)
|
||||
}
|
||||
}
|
||||
|
||||
m.transferMu.Lock()
|
||||
m.inbound[msg.Xid] = t
|
||||
m.transferMu.Unlock()
|
||||
|
||||
accept, _ := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileAccept,
|
||||
Xid: msg.Xid,
|
||||
Type: proto.MsgFileAccept,
|
||||
Xid: msg.Xid,
|
||||
ResumeOffset: resumeOffset,
|
||||
})
|
||||
m.SendTo(from, accept)
|
||||
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
||||
if resumeOffset > 0 {
|
||||
log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8])
|
||||
} else {
|
||||
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
||||
}
|
||||
}
|
||||
|
||||
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
||||
// Called when we receive file-accept from the peer.
|
||||
func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
||||
// Called when we receive file-accept from the peer. resumeOffset is non-zero
|
||||
// when the receiver is resuming a previous partial download.
|
||||
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.outbound[xid]
|
||||
m.transferMu.Unlock()
|
||||
@@ -156,13 +225,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
||||
dc.OnOpen(func() {
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
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() {
|
||||
m.transferMu.Lock()
|
||||
delete(m.outbound, xid)
|
||||
@@ -177,6 +246,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
||||
log.Printf("transfer: seek to %d xid=%s: %v", offset, xid[:8], err)
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
log.Printf("transfer: resuming send from byte %d xid=%s", offset, xid[:8])
|
||||
}
|
||||
|
||||
// Backpressure: block when the DC buffer is full.
|
||||
resume := make(chan struct{}, 1)
|
||||
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
||||
@@ -188,7 +266,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
||||
})
|
||||
|
||||
buf := make([]byte, fileChunkSize)
|
||||
var sent int64
|
||||
sent := offset // start progress reporting from where the receiver left off
|
||||
|
||||
for {
|
||||
n, readErr := f.Read(buf)
|
||||
@@ -229,8 +307,9 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
|
||||
}
|
||||
|
||||
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
||||
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the
|
||||
// SHA-256 on close, and emits EvtFileComplete.
|
||||
// arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
|
||||
// existing partial), verifies the SHA-256 on close, and emits EvtFileComplete.
|
||||
// Interrupted transfers keep their .tmp and .meta files for future resume.
|
||||
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.inbound[xid]
|
||||
@@ -247,16 +326,43 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
||||
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||
if err != nil {
|
||||
log.Printf("transfer: create temp file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.tmp = tmp
|
||||
t.hasher = sha256.New()
|
||||
t.mu.Unlock()
|
||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.resumePath != "" {
|
||||
// Resume: open existing .tmp in read-write mode, seek to end for appending.
|
||||
f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("transfer: open resume file %s: %v", t.resumePath, err)
|
||||
return
|
||||
}
|
||||
// Restore the hasher state by re-hashing the bytes already on disk.
|
||||
h := sha256.New()
|
||||
if _, err := f.Seek(0, io.SeekStart); err == nil {
|
||||
io.Copy(h, f) //nolint:errcheck
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekEnd); err != nil {
|
||||
log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err)
|
||||
f.Close()
|
||||
return
|
||||
}
|
||||
t.tmp = f
|
||||
t.hasher = h
|
||||
log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8])
|
||||
} else {
|
||||
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||
if err != nil {
|
||||
log.Printf("transfer: create temp file: %v", err)
|
||||
return
|
||||
}
|
||||
t.tmp = tmp
|
||||
t.hasher = sha256.New()
|
||||
t.metaPath = tmp.Name() + ".meta"
|
||||
writePartialMeta(t.metaPath, t)
|
||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||
}
|
||||
|
||||
// Signal sender that we are ready — it will not start streaming until
|
||||
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
||||
dc.SendText("ok") //nolint:errcheck
|
||||
@@ -298,11 +404,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
||||
dc.OnClose(func() {
|
||||
t.mu.Lock()
|
||||
tmp := t.tmp
|
||||
metaPath := t.metaPath
|
||||
actualSha := ""
|
||||
if t.hasher != nil {
|
||||
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
||||
}
|
||||
name, expectedSha := t.name, t.sha256
|
||||
name, expectedSha, size, written := t.name, t.sha256, t.size, t.written
|
||||
t.mu.Unlock()
|
||||
|
||||
m.transferMu.Lock()
|
||||
@@ -312,10 +419,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
||||
if tmp == nil {
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
tmp.Close()
|
||||
|
||||
// Incomplete — keep .tmp and .meta for future resume.
|
||||
if written < size {
|
||||
log.Printf("transfer: interrupted %s at %d/%d bytes xid=%s (resumable)", name, written, size, xid[:8])
|
||||
return
|
||||
}
|
||||
|
||||
// Full receive — verify integrity.
|
||||
if actualSha != expectedSha {
|
||||
os.Remove(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])
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
@@ -325,13 +443,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
|
||||
return
|
||||
}
|
||||
|
||||
// Success — remove sidecar and move to final path.
|
||||
if metaPath != "" {
|
||||
os.Remove(metaPath)
|
||||
}
|
||||
final := filepath.Join(m.DownloadDir, name)
|
||||
if _, err := os.Stat(final); err == nil {
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), final); err != nil {
|
||||
if err := os.Rename(tmpName, final); err != nil {
|
||||
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,13 +6,20 @@ package netmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
@@ -26,8 +33,11 @@ import (
|
||||
type Config struct {
|
||||
MasterIdentity *crypto.Identity
|
||||
StoreDir string // base directory for per-network SQLite files
|
||||
DownloadDir string // base directory for received files; defaults to StoreDir if empty
|
||||
AnchorURL string // WebSocket anchor URL used for all networks
|
||||
ShareDir string // default share directory; overridden per network via Join or SetShareDir
|
||||
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
|
||||
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
|
||||
}
|
||||
|
||||
// Network is a single joined network context.
|
||||
@@ -110,9 +120,12 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
||||
} else if 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
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||
meshEvents := m.Subscribe()
|
||||
@@ -195,9 +208,12 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
||||
} else if 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
|
||||
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||
if ice := mgr.turnICEServers(); ice != nil {
|
||||
m.ICEServers = ice
|
||||
}
|
||||
|
||||
meshEvents := m.Subscribe()
|
||||
go func() {
|
||||
@@ -278,6 +294,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadBase returns the configured download base directory, falling back to StoreDir.
|
||||
func (mgr *Manager) downloadBase() string {
|
||||
if mgr.cfg.DownloadDir != "" {
|
||||
return mgr.cfg.DownloadDir
|
||||
}
|
||||
return mgr.cfg.StoreDir
|
||||
}
|
||||
|
||||
// SetDownloadDir updates the download directory for an already-joined network.
|
||||
// Changes take effect for the next incoming file transfer on that network.
|
||||
func (mgr *Manager) SetDownloadDir(netID, path string) bool {
|
||||
mgr.mu.RLock()
|
||||
net, ok := mgr.networks[netID]
|
||||
mgr.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
net.Mesh.DownloadDir = path
|
||||
log.Printf("netmgr: download dir for %q set to %q", net.Name, path)
|
||||
return true
|
||||
}
|
||||
|
||||
// LeaveAll leaves every joined network.
|
||||
func (mgr *Manager) LeaveAll() {
|
||||
mgr.mu.RLock()
|
||||
@@ -411,6 +449,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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func hashNetName(name string) string {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,8 +47,10 @@ const (
|
||||
MsgFileAccept MsgType = "file-accept"
|
||||
MsgFileCancel MsgType = "file-cancel"
|
||||
MsgFileDone MsgType = "file-done"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
MsgPing MsgType = "ping"
|
||||
MsgPong MsgType = "pong"
|
||||
MsgHistoryRequest MsgType = "history_request"
|
||||
MsgHistoryChunk MsgType = "history_chunk"
|
||||
)
|
||||
|
||||
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
|
||||
@@ -73,26 +79,53 @@ type PeerMessage struct {
|
||||
FileListResp *FileListResp `json:"file_list_resp,omitempty"`
|
||||
|
||||
// file transfer (§9) — fields are flat on the wire
|
||||
Xid string `json:"xid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Xid string `json:"xid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
ResumeOffset int64 `json:"resume_offset,omitempty"` // waste-go ext: non-zero in file-accept when resuming
|
||||
|
||||
// file-done / file-cancel / file-accept just need xid (already above)
|
||||
Reason string `json:"reason,omitempty"` // file-cancel
|
||||
|
||||
Seq *uint64 `json:"seq,omitempty"` // ping/pong
|
||||
|
||||
// history_request fields
|
||||
Since int64 `json:"since,omitempty"` // Unix ms; 0 = no lower bound
|
||||
Limit int `json:"limit,omitempty"`
|
||||
|
||||
// history_chunk fields
|
||||
History []HistoryEntry `json:"history,omitempty"`
|
||||
HistoryDone bool `json:"history_done,omitempty"`
|
||||
}
|
||||
|
||||
// HistoryEntry is one message in a history_chunk response.
|
||||
type HistoryEntry struct {
|
||||
Mid string `json:"mid"`
|
||||
From string `json:"from"` // peer ID hex
|
||||
FromAlias string `json:"from_alias"` // advisory
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix ms
|
||||
}
|
||||
|
||||
// ChatMessage is a group chat message (wire type "chat", §8).
|
||||
// Also used internally for persisting PMs after they are received.
|
||||
type ChatMessage struct {
|
||||
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
||||
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
|
||||
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
||||
Room string `json:"room"`
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix milliseconds
|
||||
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
|
||||
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
|
||||
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
|
||||
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
|
||||
Room string `json:"room"`
|
||||
Text string `json:"text"`
|
||||
Ts int64 `json:"ts"` // Unix milliseconds
|
||||
}
|
||||
|
||||
// ComputeMsgID returns the EXT-007 content-addressed ID for a message.
|
||||
// sha256(fromID \x00 room \x00 ts_decimal \x00 text)
|
||||
func ComputeMsgID(fromID PeerID, room string, ts int64, text string) string {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%s", string(fromID), room, ts, text)
|
||||
return fmt.Sprintf("sha256:%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// PeerGossip shares known peer addresses.
|
||||
@@ -139,12 +172,16 @@ type FileOffer struct {
|
||||
|
||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||
// The signature binds this identity to the specific DTLS session.
|
||||
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
|
||||
// when RequireInvite is enabled on a network, peers that omit or present
|
||||
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
|
||||
type HelloMessage struct {
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||
Type string `json:"type"` // always "hello"
|
||||
ID string `json:"id"` // hex pubkey
|
||||
Nick string `json:"nick"` // alias
|
||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
||||
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
|
||||
}
|
||||
|
||||
// HelloBindString returns the bytes the hello signature covers:
|
||||
@@ -232,6 +269,8 @@ const (
|
||||
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
|
||||
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
@@ -251,6 +290,8 @@ const (
|
||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||
EvtSharesList IpcMsgType = "shares_list"
|
||||
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
|
||||
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
|
||||
)
|
||||
|
||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||
@@ -276,9 +317,11 @@ type IpcMessage struct {
|
||||
Body string `json:"body,omitempty"`
|
||||
|
||||
// join_network / leave_network
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||
NetworkName string `json:"network_name,omitempty"`
|
||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
||||
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
|
||||
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
|
||||
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
|
||||
|
||||
// send_file / set_share_dir / file_complete path
|
||||
Path string `json:"path,omitempty"`
|
||||
@@ -301,10 +344,11 @@ type IpcMessage struct {
|
||||
// multi-network: all joined networks (additive)
|
||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteString string `json:"invite,omitempty"`
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
InviteGenerated string `json:"invite,omitempty"`
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
|
||||
Shares []ShareEntry `json:"shares,omitempty"`
|
||||
ShareNetworks []string `json:"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
|
||||
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||
|
||||
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -28,8 +29,21 @@ CREATE TABLE IF NOT EXISTS peers (
|
||||
alias TEXT NOT NULL,
|
||||
last_seen DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
name TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// migrations run after the base schema. ALTER TABLE ADD COLUMN fails with
|
||||
// "duplicate column name" on subsequent opens — we swallow that error.
|
||||
var migrations = []string{
|
||||
// EXT-007: canonical message ID for history dedup (NULL for pre-feature messages).
|
||||
`ALTER TABLE messages ADD COLUMN msg_id TEXT`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msg_id ON messages (msg_id) WHERE msg_id IS NOT NULL`,
|
||||
}
|
||||
|
||||
// Store is a local SQLite-backed message and peer store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -46,6 +60,12 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate db: %w", err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migration %q: %w", m, err)
|
||||
}
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
@@ -59,9 +79,9 @@ func (s *Store) Close() error {
|
||||
func (s *Store) SaveMessage(msg *proto.ChatMessage) error {
|
||||
sentAt := time.UnixMilli(msg.Ts).UTC()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO messages (mid, room, from_peer, body, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
msg.Mid, msg.Room, string(msg.From), msg.Text, sentAt,
|
||||
`INSERT OR IGNORE INTO messages (mid, msg_id, room, from_peer, body, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
msg.Mid, nullableString(msg.MsgID), msg.Room, string(msg.From), msg.Text, sentAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -86,14 +106,31 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
|
||||
|
||||
// RecentMessages returns up to limit messages for a room, oldest first.
|
||||
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT mid, from_peer, body, sent_at
|
||||
FROM messages
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ?
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT ?`,
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, limit,
|
||||
)
|
||||
}
|
||||
|
||||
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
|
||||
// Only messages that have a msg_id (i.e. gossip-safe) are returned.
|
||||
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
since := time.UnixMilli(sinceMs).UTC()
|
||||
return s.queryMessages(
|
||||
`SELECT mid, from_peer, body, sent_at FROM messages
|
||||
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL
|
||||
ORDER BY sent_at DESC LIMIT ?`,
|
||||
room, since, limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error) {
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,7 +145,6 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
return nil, err
|
||||
}
|
||||
m.From = proto.PeerID(from)
|
||||
m.Room = room
|
||||
m.Ts = sentAt.UnixMilli()
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
@@ -119,6 +155,33 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
// SaveRoom persists a room name. Duplicate names are silently ignored.
|
||||
func (s *Store) SaveRoom(name string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
|
||||
name, time.Now().UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Rooms returns all persisted room names, ordered by creation time.
|
||||
func (s *Store) Rooms() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// KnownPeers returns all peers seen since this daemon started storing data.
|
||||
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)
|
||||
@@ -136,3 +199,10 @@ func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#863bff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="waste" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>waste</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"wails:dev": "wails dev",
|
||||
"wails:build": "wails build"
|
||||
},
|
||||
"dependencies": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
const useBrowser = !isLocal || !!cfg?.signalURL
|
||||
|
||||
// Wails runtime is injected at /wails/runtime.js when running inside the desktop app.
|
||||
// EventsOn/EventsOff are no-ops in plain browser mode.
|
||||
type WailsRuntime = { EventsOn: (event: string, cb: (data: unknown) => void) => void }
|
||||
const wails = (window as unknown as { runtime?: WailsRuntime }).runtime
|
||||
|
||||
function useWailsNotifications() {
|
||||
useEffect(() => {
|
||||
if (!wails) return
|
||||
|
||||
const handler = (data: unknown) => {
|
||||
const { title, body } = data as { title: string; body: string }
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, { body })
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then(p => {
|
||||
if (p === 'granted') new Notification(title, { body })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
wails.EventsOn('notify', handler)
|
||||
return () => {
|
||||
// EventsOff added in Wails v2.4; guard in case of older runtime.
|
||||
;(window as unknown as { runtime?: { EventsOff?: (...e: string[]) => void } })
|
||||
.runtime?.EventsOff?.('notify')
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||
|
||||
@@ -21,6 +50,8 @@ export default function App() {
|
||||
}
|
||||
}, [connect, connectBrowser])
|
||||
|
||||
useWailsNotifications()
|
||||
|
||||
if (daemonStatus !== 'connected' || !localPeer) {
|
||||
return <Onboarding status={daemonStatus} />
|
||||
}
|
||||
|
||||
@@ -54,6 +54,17 @@ export function Sidebar() {
|
||||
const displayId = localPeer?.id ?? masterId ?? ''
|
||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
||||
|
||||
function copyHangLink() {
|
||||
const net = networks.find(n => n.network_id === activeNetworkId)
|
||||
if (!net) return
|
||||
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||
const anchor = cfg?.signalURL ?? ''
|
||||
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_')
|
||||
const url = `${window.location.origin}/#waste:${payload}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||
logout(clearId)
|
||||
@@ -82,7 +93,12 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
<div className="sidebar-label-row">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
{activeNetworkId && (
|
||||
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||
)}
|
||||
</div>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
|
||||
@@ -14,11 +14,17 @@ interface Props {
|
||||
// ?a=<url> anchor URL hint
|
||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
const inviteString = p.get('invite') ?? ''
|
||||
let inviteString = p.get('invite') ?? ''
|
||||
let network = p.get('n') ?? p.get('network') ?? ''
|
||||
let netHash = p.get('net') ?? ''
|
||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||
|
||||
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||
const hash = window.location.hash.slice(1) // strip leading #
|
||||
if (!inviteString && hash.startsWith('waste:')) {
|
||||
inviteString = hash
|
||||
}
|
||||
|
||||
if (inviteString.startsWith('waste:')) {
|
||||
try {
|
||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||
|
||||
Reference in New Issue
Block a user