7 Commits

Author SHA1 Message Date
Fredrik Johansson
c426fa8c08 chore: gitignore root-level app and waste binaries
Some checks failed
Build / Server binaries (amd64, linux) (push) Has been cancelled
Build / Server binaries (amd64, windows) (push) Has been cancelled
Build / Server binaries (arm64, darwin) (push) Has been cancelled
Build / Server binaries (arm64, linux) (push) Has been cancelled
Build / Desktop app (Linux amd64) (push) Has been cancelled
Build / Publish release (push) Has been cancelled
Build / Server binaries (amd64, darwin) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:38:50 +02:00
Fredrik Johansson
0789cf8840 feat: download dirs, file transfer resume, Wails desktop app, PWA, CI
Daemon:
- Per-network download directories (-download-dir flag, set_download_dir IPC)
- File transfer resume after disconnection: .tmp.meta sidecars survive
  interruption; resume_offset in file-accept lets sender seek and continue
- set_download_dir IPC command; download_dir reported in state_snapshot

Protocol:
- PeerMessage.ResumeOffset (EXT-006) for file transfer resume
- IpcMessage.ShareNetworks json tag changed from "networks" to "network_ids"
  to fix duplicate json tag collision with Networks []NetworkInfo

Desktop app (cmd/app):
- Wails v2 shell embedding daemon logic directly (no subprocess)
- System tray on Linux/Windows via getlantern/systray; macOS hides to Dock
- OS notifications for message_received and file_complete via Wails events
- notray build tag for headless/CI builds without GTK tray headers
- build-app.sh: builds web frontend, copies dist, runs wails build

Web / PWA:
- manifest.json + Apple touch icon meta tags for mobile "Add to Home Screen"
- PNG icons (192px, 512px, 180px) generated from SVG
- Wails EventsOn("notify") hook in App.tsx for native OS notifications

CI:
- .gitea/workflows/build.yml: server binaries cross-compiled for 5 platforms,
  desktop app for Linux amd64, release artifacts published on v* tags

Docs:
- README: download dir, file transfer resume, desktop app, PWA, CI sections
- EXTENSIONS.md: EXT-004 daemon mode marked shipped; EXT-006 resume added
- FUTURE.md: roadmap updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:38:28 +02:00
Fredrik Johansson
d233f4d79e docs: update README and FUTURE for TURN daemon, room creation, unread indicators
Mark TURN daemon mode, TUI room creation + SQLite persistence, and unread
room indicators as shipped. Update IPC reference with create_room/room_created.
Add TUI slash commands section. Remove now-stale "not yet done" notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:36:13 +02:00
Fredrik Johansson
95fd29ae8d feat: unread indicator (*) for rooms with new messages
Rooms that receive a message while not active show a * prefix in the
sidebar. The marker clears when you tab to that room.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:30:10 +02:00
Fredrik Johansson
340735f992 feat: TUI room creation + daemon-side room persistence
/room <name> in the TUI sends create_room to the daemon, which persists
it in the rooms SQLite table and echoes room_created back. state_snapshot
now includes persisted rooms so they survive reconnects. Tab navigation
and room rendering pick them up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:26:12 +02:00
Fredrik Johansson
1308082c7b Add TURN relay support for daemon mode
-turn-url and -turn-secret flags on the daemon; credentials generated
using coturn use-auth-secret HMAC-SHA1 scheme (same as browser mode).
ICEServers field on mesh.Mesh threads extra ICE servers through to
every PeerConnection created by the anchor client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:16:18 +02:00
Fredrik Johansson
31e13fd509 Add signed invites, hang links, multi-share, and EXTENSIONS.md
- Signed invites: waste: URI gains inviter+sig fields (Ed25519); hello
  carries the invite so receiving peers can verify against known keys
- RequireInvite per-network flag: rejects peers without valid signed invite
- Hash-based hang links: #waste:base64 fragment pre-fills join form without
  server-side leakage of network name
- Multi-share: shares.json (daemon) + waste_shares localStorage (browser);
  IPC add_share/remove_share/list_shares commands
- EXTENSIONS.md: addendum documenting all waste-go protocol deviations from
  YAW/2; all extensions are additive and backward compatible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:05:56 +02:00
37 changed files with 1487 additions and 114 deletions

120
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,120 @@
name: Build
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
# ── Server binaries (no CGo, cross-compile freely) ───────────────────────────
server:
name: Server binaries
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build daemon + anchor
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
[ "${{ matrix.goos }}" = "windows" ] && EXT=".exe" || EXT=""
go build -trimpath -ldflags="-s -w" -o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
go build -trimpath -ldflags="-s -w" -o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
- uses: actions/upload-artifact@v4
with:
name: server-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/
# ── Desktop app (Wails, requires CGo + webview libs) ─────────────────────────
# Runs only on Linux amd64 with the default runner.
# For macOS/Windows desktop builds, add self-hosted runners with those platforms
# and duplicate this job (adjusting the runs-on and platform deps).
desktop-linux:
name: Desktop app (Linux amd64)
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: '20'
- 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.0-dev \
libayatana-appindicator3-dev
- name: Build frontend
run: |
cd web
npm ci
npm run build
cp -r dist ../cmd/app/frontend/dist
- name: Build desktop app
run: |
mkdir -p dist
cd cmd/app
wails build -trimpath -ldflags="-s -w" -o ../../dist/waste-linux-amd64
- uses: actions/upload-artifact@v4
with:
name: desktop-linux-amd64
path: dist/waste-linux-amd64
# ── Release: collect all artifacts and publish ────────────────────────────────
release:
name: Publish release
needs: [server, desktop-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@main
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: artifacts/*
prerelease: ${{ contains(github.ref_name, '-') }}

6
.gitignore vendored
View File

@@ -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

226
EXTENSIONS.md Normal file
View File

@@ -0,0 +1,226 @@
# 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.

View File

@@ -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) |
---

104
README.md
View File

@@ -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
@@ -317,16 +413,19 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"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/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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

0
cmd/app/frontend/dist/.gitkeep vendored Normal file
View File

66
cmd/app/main.go Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -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 != "" {

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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 (
@@ -24,26 +29,66 @@ type Invite struct {
Anchor string `json:"anchor"` // WebSocket anchor URL
Network string `json:"network"` // plaintext network name
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
}
// 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)
}

View File

@@ -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:
@@ -233,6 +240,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,7 +322,7 @@ 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
@@ -306,7 +330,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(proto.IpcMessage{
Type: proto.EvtInviteGenerated,
NetworkID: n.ID,
InviteString: inv,
InviteGenerated: inv,
})
case proto.CmdSetShareDir:
@@ -320,6 +344,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 +450,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

View File

@@ -13,6 +13,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
@@ -29,9 +32,12 @@ type Mesh struct {
Store *store.Store // may be nil if persistence is disabled
ShareDir string // directory whose contents are shared with peers; "" = no sharing
DownloadDir string // directory where received files are saved
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
InviteString string // the invite this peer used to join (sent in hello to other peers)
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
// multi-share scanning without the mesh needing to know about shares.json.
ScanFiles func() []proto.FileEntry
ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry
mu sync.RWMutex
peers map[proto.PeerID]*PeerConn
@@ -63,6 +69,29 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
}
}
// 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 {

View File

@@ -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"
)
@@ -48,6 +49,7 @@ func WireDataChannel(
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 {
@@ -234,7 +267,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)

View File

@@ -40,7 +40,55 @@ type inboundTransfer struct {
mu sync.Mutex
tmp *os.File
hasher hash.Hash
written int64
written int64 // total bytes received; starts at resumeOffset when resuming
resumePath string // path to existing .tmp when resuming; empty for new transfers
resumeOffset int64 // bytes already present in resumePath
metaPath string // path to the .tmp.meta sidecar
}
// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file.
// It survives interruptions so the receiver can resume later.
type partialMeta struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
From string `json:"from"`
Size int64 `json:"size"`
}
// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches.
// Returns (tmpPath, metaPath, offset) — all empty/zero if no match.
func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) {
metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta"))
for _, mp := range metas {
data, err := os.ReadFile(mp)
if err != nil {
continue
}
var m partialMeta
if err := json.Unmarshal(data, &m); err != nil {
continue
}
if m.SHA256 != sha256hex {
continue
}
tp := strings.TrimSuffix(mp, ".meta")
info, err := os.Stat(tp)
if err != nil {
continue
}
return tp, mp, info.Size()
}
return "", "", 0
}
func writePartialMeta(path string, t *inboundTransfer) {
data, _ := json.Marshal(partialMeta{
Name: t.name,
SHA256: t.sha256,
From: string(t.from),
Size: t.size,
})
os.WriteFile(path, data, 0o644) //nolint:errcheck
}
// 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,
ResumeOffset: resumeOffset,
})
m.SendTo(from, accept)
if resumeOffset > 0 {
log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8])
} else {
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
}
}
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
// Called when we receive file-accept from the peer.
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
}
t.mu.Lock()
defer t.mu.Unlock()
if t.resumePath != "" {
// Resume: open existing .tmp in read-write mode, seek to end for appending.
f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644)
if err != nil {
log.Printf("transfer: open resume file %s: %v", t.resumePath, err)
return
}
// Restore the hasher state by re-hashing the bytes already on disk.
h := sha256.New()
if _, err := f.Seek(0, io.SeekStart); err == nil {
io.Copy(h, f) //nolint:errcheck
}
if _, err := f.Seek(0, io.SeekEnd); err != nil {
log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err)
f.Close()
return
}
t.tmp = f
t.hasher = h
log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8])
} else {
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
if err != nil {
log.Printf("transfer: create temp file: %v", err)
return
}
t.mu.Lock()
t.tmp = tmp
t.hasher = sha256.New()
t.mu.Unlock()
t.metaPath = tmp.Name() + ".meta"
writePartialMeta(t.metaPath, t)
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
}
// 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
}

View File

@@ -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 {

View File

@@ -77,6 +77,7 @@ type PeerMessage struct {
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
@@ -139,12 +140,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
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
}
// HelloBindString returns the bytes the hello signature covers:
@@ -232,6 +237,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 +258,7 @@ const (
EvtIdentityExported IpcMsgType = "identity_exported"
EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list"
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
)
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -279,6 +287,8 @@ type IpcMessage struct {
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 +311,10 @@ 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"`
InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"`
Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// export_identity / import_identity
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
Backup string `json:"backup,omitempty"` // JSON backup blob

View File

@@ -28,6 +28,11 @@ 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
);
`
// Store is a local SQLite-backed message and peer store.
@@ -119,6 +124,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`)

View File

@@ -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>

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

23
web/public/manifest.json Normal file
View 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"
}
]
}

View File

@@ -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} />
}

View File

@@ -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">
<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}

View File

@@ -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, '/')))