42 Commits

Author SHA1 Message Date
Fredrik Johansson
b2b5c8c7cb ci: fix webkit dep for Ubuntu 24.04 (Noble)
Some checks failed
Build / Build & release (push) Failing after 7m5s
libwebkit2gtk-4.0-dev was dropped in Noble; use 4.1 and pass
-tags webkit2_41 to wails build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:28:04 +02:00
Fredrik Johansson
b6ff30de78 ci: rewrite workflow as single job to avoid upload-artifact
Some checks failed
Build / Build & release (push) Failing after 5m15s
Gitea act_runner intercepts actions/upload-artifact regardless of version
tag and uses an incompatible built-in. Restructured as one job that builds
all server binaries (cross-compiled) and the Linux desktop app, then
publishes directly to the release — no artifact handoff needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:02:43 +02:00
Fredrik Johansson
1bd719fa58 ci: downgrade artifact actions to v3 for Gitea GHES compatibility
upload-artifact@v4 and download-artifact@v4 are not supported on GHES.
Also drop merge-multiple (v4-only) and adjust release glob to artifacts/*/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:53:07 +02:00
Fredrik Johansson
be297d3a49 docs: add QUICKSTART.md for non-technical users
Three paths: browser-only, desktop app download, headless daemon.
Covers invites, anchor server basics, and mobile install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 21:46:37 +02:00
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
Fredrik Johansson
0e8ddbf4f4 docs: update README and FUTURE for multi-share, file browser, session persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:18:56 +02:00
Fredrik Johansson
f326ff2605 fix: remove unused useEffect import in ShareManager
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:49:17 +02:00
Fredrik Johansson
e0704f210c feat: persistent multi-share configuration (shares.json + localStorage)
Daemon side:
- internal/shares: new package reads/writes shares.json next to identity
- proto: add ShareEntry type, add_share/remove_share/list_shares IPC commands,
  shares_list event, path field on FileEntry
- netmgr: load shares.json on startup; ScanAllShares combines legacy ShareDir
  with shares.json entries (recursive walk with relative paths)
- mesh: ScanFiles callback lets manager inject multi-share scanning without
  mesh knowing about shares.json
- ipc: handle add_share, remove_share, list_shares commands

Browser side:
- ShareManager component replaces FolderPicker: shows list of named shares
  with remove/re-pick buttons; persists share records to waste_shares in
  localStorage (name, global flag, networkId)
- FolderPicker retained for internal use; Sidebar now uses ShareManager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:47:11 +02:00
Fredrik Johansson
0f54f3bbad feat: subfolder support in file sharing + directory browser UI
- FolderPicker: uses webkitRelativePath so paths like docs/report.pdf
  are preserved; adds "include subfolders" checkbox (default on)
- FileBrowser: virtual directory tree with breadcrumb navigation,
  sort by name/size, search filter, folders shown before files
- Protocol: browse sends path alongside name; get uses path as key
- FileEntry type gains optional path field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:29:00 +02:00
Fredrik Johansson
80e05b81ac docs: clarify browser folder-picker upload prompt is a browser warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:18:34 +02:00
Fredrik Johansson
2c71d9c5c6 feat: session persistence, logout button, TURN HMAC fix; update docs
- Browser mode auto-rejoins saved network on reload (waste_last_network)
- Logout button (⏻) in sidebar clears session; optionally clears identity keypair
- TURN credentials now use HMAC-SHA1 of username (coturn use-auth-secret compatible)
- README: deploy scripts documented, session persistence section, serve-web.sh noted
- FUTURE.md: mark shipped items, add remaining work for daemon TURN + TUI rooms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:16:18 +02:00
Fredrik Johansson
dfbdd34aaa fix(turn): generate HMAC-SHA1 credentials for coturn use-auth-secret
coturn's use-auth-secret mode requires the TURN password to be
HMAC-SHA1(secret, username), not the raw secret. Pass ice servers
as a constructor argument so the async credential generation happens
before RTCPeerConnection is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:07:53 +02:00
Fredrik Johansson
295851f966 docs: replace example TURN secret with placeholder
Committed a generated hex string that looked like a real secret.
Replaced with YOUR_SECRET_HERE to make it obviously an example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:52:50 +02:00
Fredrik Johansson
02eb83b63a docs: use a real generated secret in coturn example
Replace placeholder YOUR_RANDOM_SECRET with an actual openssl rand -hex 32
output, with a note to generate a fresh one. Keeps the example copy-pasteable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:16:48 +02:00
Fredrik Johansson
ea1eb767f1 Add TURN relay support and document coturn setup
Browser adapter reads turnURL + turnSecret from WASTE_CONFIG and adds a
TURN ICEServer entry automatically. Uses time-limited credentials
compatible with coturn's use-auth-secret mode.

README: new section under Hosting with coturn install, turnserver.conf,
firewall note (UDP 3478, no NPM needed), and config.js snippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:12:03 +02:00
Fredrik Johansson
c4032417ae Show peers immediately on discovery, before DataChannel opens
Emit peer_connected + peer_status(connecting) as soon as a PeerConn is
created, so peers appear in the sidebar even if ICE is still negotiating
or ultimately fails. The alias updates in-place once the DataChannel hello
arrives with the real nick.

Fixes: mobile/CGNAT peers being completely invisible when STUN hole
punching fails — they now show with a connecting/failed dot instead of
not appearing at all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:54:47 +02:00
Fredrik Johansson
fb14ca82af Add rooms, per-network file sharing, and file transfer UX
Additional rooms:
- Store tracks customRooms per networkId; createRoom action slugifies and
  deduplicates, switches active room on creation
- Sidebar: + button next to Rooms label opens an inline input; Escape
  dismisses; Enter creates the room

Per-network share directories:
- Store tracks sharedFilesByNetwork keyed by networkId instead of a single
  global map; setSharedFiles(files, networkId?) defaults to activeNetworkId
- FolderPicker reads per-network map to display accurate file count label

File transfer UX:
- Incoming file-offer now emits pending_offer instead of auto-accepting;
  user sees Accept / Reject buttons in the sidebar Transfers section
- Progress events emitted per chunk during receive; Transfers panel shows
  a live progress bar with received/total sizes
- file-cancel protocol message: sender or receiver can cancel an in-flight
  transfer; DataChannel is closed and buffers discarded
- PeerConn: acceptOffer(), rejectOffer(), cancelRecv(), cancelSend()
- BrowserAdapter: acceptOffer(), rejectOffer(), cancelTransfer()
- Store: pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer
- Transfers component renders in sidebar; auto-hides when no activity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:49:15 +02:00
Fredrik Johansson
5421352e62 docs: update FUTURE.md to reflect current shipped state
Mark web UI, peer gossip, and identity backup as shipped. Expand TURN
section with concrete implementation notes (one-liner ICEServers change +
3 daemon flags). Add per-network share directories and additional
channels/rooms as next items. Remove stale "not implemented" notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:40:33 +02:00
Fredrik Johansson
068e7e6566 docs: update README with file sharing and browser mode clarifications
Document the folder sharing, peer file browsing, and direct push (📎)
workflows added in the previous commit. Clarify that browser mode also
activates locally when config.js sets signalURL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:31:54 +02:00
Fredrik Johansson
91b7406d01 Add peer-to-peer file transfer in browser mode
Implements both pull (browse & download from a shared folder) and push
(send a file directly to a peer) using the yaw2 file protocol over WebRTC
DataChannels.

- PeerConn: handle browse/files/get/file-offer/file-accept/file-done
  control messages; stream files in 64KB chunks over f:xid binary
  DataChannels; auto-accept incoming offers (push and pull)
- PeerConn.sendFilePush(): initiate an outbound file transfer without
  requiring the peer to browse first
- BrowserAdapter: sharedFiles map, setSharedFiles(), requestBrowse(),
  requestGet(), sendFileTo() — propagates share map to all active peers
- Store: activeFilePeer, setActiveFilePeer, setSharedFiles, browseFiles,
  sendFileTo; file_complete handler triggers browser download automatically
- FileBrowser component: third-column panel listing a peer's shared files
  with name, size, and a download button
- FolderPicker component: webkitdirectory input in the sidebar (browser
  mode only) for selecting a local folder to share
- Sidebar: 📎 send-file button on peer rows (hover) with inline file picker
- App.tsx: use browser mode when WASTE_CONFIG.signalURL is set, even on
  localhost — allows testing browser mode via npm run dev with config.js
- deploy-daemon.sh: kill existing daemon before scp to avoid upload failure
  when the binary is locked; remove duplicate kill block in restart step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:29:54 +02:00
Fredrik Johansson
ea66e2eb58 ui: fix peer row spacing — alias no longer flex:1
ID takes flex:1 now, sitting right after the alias. Actions push right via margin-left:auto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:51:27 +02:00
Fredrik Johansson
076f9641c2 ui: clean up self entry in peer list
Replace bold duplicate-looking self row with dimmed name + small accent
"you" badge. Removes the ID (already shown in identity section at top).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:48:28 +02:00
Fredrik Johansson
1d38766006 ui: fix message layout — no-wrap timestamp, tighter alias column
Switch back to flex, set explicit widths: 52px timestamp (nowrap, 24h),
90px alias (right-aligned). Eliminates wrapping and the large gap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:47:19 +02:00
Fredrik Johansson
f5a11cb22b ui: fix message alignment, show self in peer list
Messages now use a fixed grid (44px ts · 120px alias right-aligned · 1fr text)
instead of flex, so text always starts at the same column regardless of alias length.

Self shown at top of peer list with accent dot and bold name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:45:08 +02:00
Fredrik Johansson
478a0a32af chore: gitignore serve-web.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:37:48 +02:00
Fredrik Johansson
4e5e41b733 chore: gitignore deploy-web.sh and deploy-daemon.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:33:56 +02:00
Fredrik Johansson
27874cd721 chore: gitignore build-web.sh and build-daemon.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:30:21 +02:00
Fredrik Johansson
6e8c8180b5 feat: emit peer_status (ICE state + candidate type) from daemon
Hooks pc.OnConnectionStateChange in WireDataChannel to emit peer_status
events. On connected, calls pc.GetStats() to find the nominated candidate
pair and report candidateType (host/srflx/relay) and remote address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:28:27 +02:00
Fredrik Johansson
bb53357bc8 redesign: 2-col layout, peers in sidebar, IRC-style messages, NAT dot
- Peers moved from right panel into sidebar below Rooms
- Messages left-aligned IRC style: time · alias · text
- Per-peer colored dot: green=direct/NAT-punched, amber=relay/connecting, red=failed
- Hover title on peer row shows full ID, connection type, remote address, last seen
- Removed right PeerList panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:28:40 +02:00
Fredrik Johansson
cb80040ddb chore: gitignore web/public/config.js; fix netHash to use WebCrypto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:21:12 +02:00
Fredrik Johansson
f7047b7bfe fix: align offer/answer tiebreak with yaw2/browser (lower ID offers)
The Go daemon was using higher-ID-offers while the browser adapter and
yaw2 use lower-ID-offers. This meant daemon↔browser pairs where the
daemon had the lower ID would never connect — neither side would offer.

All three comparison sites in anchor/client.go flipped: > 0 → < 0,
<= 0 → >= 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:47:13 +02:00
Fredrik Johansson
d529f58ddc Update README: hosting guide, browser vs daemon mode, NPM setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:57:44 +02:00
Fredrik Johansson
bbd78ac4de Remove hardcoded anchor URL fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:21 +02:00
Fredrik Johansson
de8d3ff70d Remove unsolicited deploy/ and config.example.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:00 +02:00
Fredrik Johansson
739c63f6b3 Add deploy/ with nginx config and web deploy script
nginx-waste.conf: serve web/dist/ at root, proxy /ws to anchor (port
8080), SPA fallback for clean invite URLs, immutable cache for assets.

deploy-web.sh: build + rsync to anchor host, skip config.js so the
host's runtime config is not clobbered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:44:46 +02:00
Fredrik Johansson
5bc16daae1 Add standalone browser mode: no daemon required
BrowserAdapter speaks yaw/2.1 signaling + WebRTC protocol entirely in
the browser (libsodium-wrappers for Ed25519/X25519). Identity stored in
localStorage; peers connect via any waste/yaw2 anchor.

When served from a non-localhost origin the app defaults to browser
mode. On localhost the daemon adapter is tried first with a one-click
switch to browser mode. config.js (gitignored, served by anchor) lets
the host pre-inject signalURL and other defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 00:06:41 +02:00
51 changed files with 4157 additions and 416 deletions

View File

@@ -1,7 +1,9 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(go build *)" "Bash(go build *)",
"Bash(npx tsc *)",
"Bash(npm run *)"
] ]
} }
} }

View File

@@ -0,0 +1,81 @@
name: Build
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build & release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: '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.1-dev \
libayatana-appindicator3-dev
# ── Server binaries (CGO_ENABLED=0, cross-compile freely) ──────────────
- name: Build server binaries
run: |
mkdir -p dist
build() {
local GOOS=$1 GOARCH=$2
local SUFFIX="${GOOS}-${GOARCH}"
local EXT=""
[ "$GOOS" = "windows" ] && EXT=".exe"
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
go build -trimpath -ldflags="-s -w" \
-o "dist/waste-daemon-${SUFFIX}${EXT}" ./cmd/daemon
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
go build -trimpath -ldflags="-s -w" \
-o "dist/waste-anchor-${SUFFIX}${EXT}" ./cmd/anchor
}
build linux amd64
build linux arm64
build darwin amd64
build darwin arm64
build windows amd64
# ── Desktop app (Linux amd64, CGo + Wails) ─────────────────────────────
- name: Build frontend
run: |
cd web
npm ci
npm run build
cp -r dist ../cmd/app/frontend/dist
- name: Build desktop app
run: |
cd cmd/app
wails build -trimpath -ldflags="-s -w" -tags webkit2_41 -o ../../dist/waste-linux-amd64
# ── Publish release (tags only) ─────────────────────────────────────────
- name: Create release
if: startsWith(github.ref, 'refs/tags/')
uses: https://gitea.com/actions/gitea-release-action@main
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}

12
.gitignore vendored
View File

@@ -2,10 +2,12 @@
setup-anchor.sh setup-anchor.sh
launch-tui.sh launch-tui.sh
*.exe *.exe
# compiled binary names that land in the repo root # compiled binaries that land in the repo root
/anchor /anchor
/waste-anchor /waste-anchor
/waste-daemon /waste-daemon
/waste
/app
/relay /relay
*.identity.json *.identity.json
/tmp/ /tmp/
@@ -14,3 +16,11 @@ launch-tui.sh
*.swp *.swp
/tui /tui
launch-web.sh launch-web.sh
build-web.sh
build-daemon.sh
deploy-web.sh
deploy-daemon.sh
serve-web.sh
web/public/config.js
cmd/app/frontend/dist/*
!cmd/app/frontend/dist/.gitkeep

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

@@ -26,6 +26,11 @@ Target: a web frontend (React or similar) wrapped in a native binary using a Tau
#### TUI ✅ (shipped) #### TUI ✅ (shipped)
A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Three-pane layout: rooms, messages, peers. Supports group chat, DMs, room switching, invite generation. Works over SSH. A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Three-pane layout: rooms, messages, peers. Supports group chat, DMs, room switching, invite generation. Works over SSH.
#### Web UI ✅ (shipped)
React + Vite frontend. Two modes:
- **Browser mode** — runs entirely in-browser, connects directly to the anchor via WebSocket. No daemon required. Identity persists in `localStorage`. File sharing, file push, per-peer ICE/NAT status.
- **Daemon mode** — web UI connects to a local daemon over WebSocket IPC. Same UI, different adapter.
--- ---
## Protocol ## Protocol
@@ -33,41 +38,63 @@ A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bu
### NAT Traversal ✅ (WebRTC ICE/STUN) ### NAT Traversal ✅ (WebRTC ICE/STUN)
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478. Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
The one remaining gap: symmetric-NAT pairs fail without TURN. An optional TURN relay (coturn in relay mode) can recover these. The protocol spec (§11) explicitly leaves this as an opt-in — no relay is the default. ### TURN relay ✅ (shipped)
Both browser and daemon modes support TURN 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:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.
### Signaling ✅ YAW/2.1 (shipped) ### Signaling ✅ YAW/2.1 (shipped)
Forward-secret signaling via per-session ephemeral X25519 keys. See PROTOCOL.md §2.1 and `internal/anchor/client.go`. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1. Forward-secret signaling via per-session ephemeral X25519 keys. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
### Multi-Network Support ✅ (shipped) ### Multi-Network Support ✅ (shipped)
One daemon, multiple simultaneously-joined networks. Per-network HKDF-derived identities prevent cross-network correlation. Network-scoped SQLite stores, IPC commands carry `network_id`. One daemon, multiple simultaneously-joined networks. Per-network HKDF-derived identities prevent cross-network correlation. Network-scoped SQLite stores.
### Invite System ✅ (shipped) ### Invite System ✅ (shipped)
`waste:<base64>` URIs encoding anchor URL + network name. `--join` flag on daemon and TUI. Generated via IPC `generate_invite` or `Ctrl+I` in the TUI. `waste:<base64>` URIs encoding anchor URL + network name. `--join` flag on daemon and TUI. `Ctrl+I` in TUI or `generate_invite` via IPC.
### File Transfer ✅ (shipped) ### File Transfer ✅ (shipped)
Dedicated binary DataChannel per transfer (`f:<xid>`). SHA-256 integrity verification. 64 KiB chunks with backpressure. Auto-accept. Downloads to `<data-dir>/downloads-<netid>/`. Dedicated binary DataChannel per transfer (`f:<xid>`). SHA-256 integrity verification. 64 KiB chunks with backpressure. Auto-accept. In browser mode: both pull (browse peer's shared folder) and push (📎 send directly to a peer).
### Peer Gossip ✅ (shipped)
When a new peer connects, the mesh immediately gossips the full peer list to them (`peer_gossip` wire message). New arrivals discover existing peers without needing the anchor to re-introduce them. The anchor becomes optional once the first handshake has happened — the mesh self-heals around anchor downtime.
--- ---
## Remaining Work ## Remaining Work
### Per-Network Share Directories ### Session Persistence ✅ (shipped)
The `-share-dir` flag is still global. A peer on two networks shares the same directory with both. The fix: make share dir per-network, configurable via an IPC command `set_share_dir {network_id, path}` or a config file, so Alice can share work files on "work" and music on "friends" independently. Browser mode now auto-rejoins on reload. The last-used network name, alias, and anchor URL are saved to `localStorage` on join and restored on load. A ⏻ logout button in the sidebar clears session state (optionally including the identity keypair) and reloads the page.
### Peer Gossip (§8.4) ### Per-Network Share Directories ✅ (shipped)
The `peer_gossip` wire type exists in proto but is not implemented. When a peer knows about other peers (from prior connections), it could share address hints so the mesh can reconnect without the anchor. This makes the anchor optional once the initial handshake has happened — the network self-heals if the anchor goes down. Share state is tracked per `network_id` in the store (`sharedFilesByNetwork`). Switching networks switches the active share.
### File Transfer UX ### Persistent Multi-Share Configuration ✅ (shipped)
- Manual accept/reject (currently auto-accept) Multiple share roots per network, with global (all networks) or scoped visibility.
- Transfer cancellation via `file-cancel`
- Progress display in TUI - **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.
- Resume after disconnection - **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
### Additional Channels / Rooms ✅ (shipped)
Custom rooms are supported in both the web UI and the TUI.
**Web UI:** The `+` button in the Rooms sidebar creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically.
**TUI:** Type `/room <name>` in the input to create a room. The daemon persists it in the `rooms` SQLite table and echoes a `room_created` IPC event back. On reconnect, rooms are restored via `state_snapshot`. Rooms that receive messages while not active show a `*` prefix in the sidebar; the marker clears when you switch to that room.
DM rooms (`dm:<peerId>`) appear automatically in both interfaces when messages arrive.
### File Transfer UX ✅ (shipped)
- Manual accept/reject via the Transfers panel in the sidebar
- Transfer cancellation (`file-cancel` message, closes DataChannel)
- Live progress bar per active transfer
- Push (📎) sends directly to a peer without them needing to share a folder
**Not yet done:** daemon-side resume UX (IPC event to surface resumable transfers to the UI on reconnect).
### Native UI ### Native UI
Web frontend (React) + Tauri/native packaging. The IPC protocol is already the full boundary — the UI is a pure consumer. 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.
### TURN relay (optional)
For symmetric-NAT pairs. Opt-in, off by default. Operates blind (relay sees only opaque DTLS-encrypted blobs). Run coturn in relay mode on the same VPS as the anchor.
--- ---
@@ -77,19 +104,35 @@ For symmetric-NAT pairs. Opt-in, off by default. Operates blind (relay sees only
|---|---| |---|---|
| ✅ shipped | Daemon + anchor server | | ✅ shipped | Daemon + anchor server |
| ✅ shipped | WebRTC DataChannels (ICE/STUN hole punching) | | ✅ shipped | WebRTC DataChannels (ICE/STUN hole punching) |
| ✅ shipped | Ed25519 identity, nacl/box signaling (YAW/2.0) | | ✅ shipped | Ed25519 identity, nacl/box signaling (YAW/2.0 + 2.1) |
| ✅ shipped | IPC protocol — join/leave/chat/DM/state | | ✅ shipped | IPC protocol — join/leave/chat/DM/state |
| ✅ shipped | Message persistence (SQLite, per-network) | | ✅ shipped | Message persistence (SQLite, per-network) |
| ✅ shipped | TUI (`cmd/tui`, Bubble Tea) | | ✅ shipped | TUI (`cmd/tui`, Bubble Tea) |
| ✅ shipped | Invite system (`waste:` URI, `--join` flag) | | ✅ shipped | Invite system (`waste:` URI, `--join` flag) |
| ✅ shipped | Multi-network support (HKDF derived identities) | | ✅ shipped | Multi-network support (HKDF derived identities) |
| ✅ shipped | File transfer (dedicated binary DataChannels) | | ✅ shipped | File transfer (binary DataChannels, pull + push) |
| ✅ shipped | Forward-secret signaling (YAW/2.1 ephemeral X25519) | | ✅ shipped | Forward-secret signaling (YAW/2.1 ephemeral X25519) |
| next | Per-network share directories | | ✅ shipped | Peer gossip (anchor-free mesh reconnection) |
| next | Peer gossip (anchor-free reconnection) | | ✅ shipped | Web UI — browser mode + daemon mode |
| next | File transfer UX (manual accept, cancel, TUI progress) | | ✅ shipped | Per-peer NAT/ICE status, identity backup/restore |
| future | Native UI (React + Tauri) | | ✅ shipped | Per-network share directories |
| future | TURN relay (opt-in, for symmetric-NAT pairs) | | ✅ shipped | Additional channels/rooms per network |
| ✅ shipped | TURN relay (browser mode, coturn `use-auth-secret`) |
| ✅ shipped | File transfer UX (progress, cancel, manual accept) |
| ✅ shipped | Session persistence + logout (browser mode) |
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
| ✅ shipped | Subfolder support + directory browser UI in file browser |
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
| ✅ shipped | TURN relay for daemon mode (`-turn-url` / `-turn-secret`) |
| ✅ shipped | TUI room creation + daemon-side room persistence |
| ✅ shipped | Unread room indicators in TUI (`*` prefix) |
| ✅ shipped | Per-network download directories (`-download-dir` flag + `set_download_dir` IPC) |
| ✅ shipped | File transfer resume after disconnection |
| ✅ shipped | PWA manifest — installable via "Add to Home Screen" on iOS and Android |
| ✅ shipped | Native desktop app (Wails 2) — system tray (Linux/Windows), OS notifications, single binary |
| ✅ shipped | Gitea Actions CI — server binaries (all platforms via cross-compile) + desktop app (Linux amd64) |
--- ---

89
QUICKSTART.md Normal file
View File

@@ -0,0 +1,89 @@
# waste — quick start
waste is a private, encrypted chat and file sharing app for people you trust.
No accounts, no phone numbers, no central server that knows your messages.
Pick the option that fits you best.
---
## Option 1 — Just open it in your browser
If someone is running a waste anchor server and has shared the URL with you:
1. Open the URL in any modern browser
2. Enter your name and a network name your group has agreed on
3. Done — you're in
On mobile, tap **Share → Add to Home Screen** to install it as an app icon.
To invite someone: click the 🔗 button in the sidebar and share the link.
> Your identity and messages stay in your browser. Nothing is stored on the server — the server only helps peers find each other.
---
## Option 2 — Desktop app (recommended for regular use)
Download the latest `waste` binary for your platform from the [releases page](../../releases).
**Linux / macOS:**
```bash
chmod +x waste-linux-amd64 # or waste-darwin-arm64, etc.
./waste-linux-amd64
```
**Windows:** double-click `waste-windows-amd64.exe`.
The app opens a window with the waste UI. Enter your name, the anchor URL, and a network name to join. Your identity is saved between sessions in your config directory (`~/.config/waste` on Linux, `~/Library/Application Support/waste` on macOS, `%APPDATA%\waste` on Windows).
On Linux and Windows a tray icon appears — closing the window hides to tray rather than quitting. Right-click the tray icon to reopen or quit.
---
## Option 3 — Run the daemon manually (headless / power users)
If you want the daemon running in the background without the desktop UI — on a server, over SSH, or with the web UI in a browser pointed at your local machine:
```bash
# Download waste-daemon from the releases page, then:
./waste-daemon -alias yourname -anchor wss://your-anchor-server/ws
```
Then open the web UI in a browser at the anchor URL, or point the web UI's daemon mode at `ws://127.0.0.1:17338`.
Full flag reference:
| Flag | Default | Description |
|---|---|---|
| `-alias` | `anon` | Your display name |
| `-anchor` | — | Anchor server WebSocket URL |
| `-data-dir` | `~/.waste` | Where identity and messages are stored |
| `-download-dir` | same as data-dir | Where received files are saved |
| `-ipc-port` | `17337` | Local TCP IPC port |
| `-ws-port` | `0` (off) | WebSocket IPC port (needed for web UI) |
| `-turn-url` | — | TURN relay URL (fixes mobile/CGNAT) |
| `-turn-secret` | — | TURN shared secret |
---
## Inviting someone
1. Click `Ctrl+I` in the TUI, or click **Generate invite** in the web UI
2. Share the `waste:...` link with your friend (Signal, email, anything)
3. They open it in a browser or pass it to `waste-daemon --join 'waste:...'`
Invite links encode the anchor URL and network name. The anchor never sees your messages.
---
## Running your own anchor server
The anchor is a tiny signaling server that helps peers find each other — it never sees plaintext messages or file contents. You need a VPS with a domain and TLS.
```bash
# On your VPS:
./waste-anchor -bind 127.0.0.1:8080
```
Put it behind nginx with a `/ws` WebSocket proxy and serve the web UI static files at `/`. See [README.md](README.md#hosting-on-a-vps) for the full nginx setup.

589
README.md
View File

@@ -9,7 +9,7 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
waste-go/ waste-go/
├── cmd/ ├── cmd/
│ ├── daemon/ The peer process — run one on each friend's machine │ ├── daemon/ The peer process — run one on each friend's machine
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS │ ├── anchor/ WebSocket signaling server — run this on your VPS
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon) │ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
└── internal/ └── internal/
├── proto/ All wire types (shared by daemon and anchor) ├── proto/ All wire types (shared by daemon and anchor)
@@ -19,235 +19,438 @@ waste-go/
└── ipc/ Local JSON API (UI talks to daemon here, port 17337) └── ipc/ Local JSON API (UI talks to daemon here, port 17337)
``` ```
## Prerequisites ---
- Go 1.24+ → https://go.dev/dl/ ## Hosting on a VPS
- VS Code with the Go extension (`golang.go`)
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them. You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
## Getting started ### 1. Build and run the anchor
```bash ```bash
# Fetch dependencies # On your local machine — cross-compile for Linux
go mod tidy GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
# Build everything (confirms it compiles) # Copy to VPS
go build ./... scp bin/waste-anchor user@your-vps:~/waste-anchor
```
# Terminal 1 — anchor (required for peers to find each other) On the VPS, run the anchor and keep it alive (systemd, screen, whatever you use):
```bash
./waste-anchor -bind 127.0.0.1:8080
```
Or use the helper script which handles background execution and logging:
```bash
./setup-anchor.sh --bg # start in background, logs to waste-anchor.log
./setup-anchor.sh --stop # stop it
```
To cross-compile and redeploy the anchor binary from your local machine:
```bash
./deploy-daemon.sh
```
This kills the existing anchor, uploads the new binary, and restarts it.
The anchor listens locally on port 8080 — Nginx Proxy Manager will expose it over TLS.
### 2. Build and upload the web UI
```bash
# On your local machine
cd web
npm install
npm run build
# Produces web/dist/
# Copy to VPS
rsync -az web/dist/ user@your-vps:~/waste-www/
```
Or use the deploy script (builds + rsyncs in one step):
```bash
./deploy-web.sh
```
Create a `/var/www/waste-web/config.js` on the VPS (not in git — this is host-specific):
```js
window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws',
}
```
This tells the browser where to connect for signaling. Without it the join form shows a blank signal server field and the user must fill it in manually.
### 3. Nginx Proxy Manager setup
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations:
**Location 1 — WebSocket signaling (`/ws`)**
- Location: `/ws`
- Forward hostname/IP: `127.0.0.1`
- Forward port: `8080`
- Enable: WebSockets Support
**Location 2 — Web UI (catch-all)**
- Location: `/`
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
- Enable the SPA fallback so unknown paths return `index.html` — this is required for invite links to work
If NPM doesn't support static file serving directly, run a small static server on a spare port and proxy `/` to it:
```bash
nohup npx serve -s ~/waste-www -l 1337 &
```
Or use `serve-web.sh` which handles PID tracking and restart:
```bash
./serve-web.sh # kills existing instance, starts fresh, logs to waste-www.log
```
The key requirements:
- `/ws` → anchor process (WebSocket, keep-alive)
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
### 4. TURN relay (optional, fixes mobile / CGNAT)
WebRTC hole-punching fails when both peers are behind symmetric NAT — common on mobile data and some ISPs. A TURN relay fixes this. It runs directly on the VPS, not through Nginx Proxy Manager.
**Firewall:** open UDP 3478 (and optionally TCP 3478) on the Hetzner firewall. No NPM config needed — coturn speaks its own protocol.
**Install coturn:**
```bash
apt install coturn
```
**`/etc/turnserver.conf`:**
```
listening-port=3478
fingerprint
use-auth-secret
static-auth-secret=YOUR_SECRET_HERE
realm=your-domain.com
no-tcp-relay
```
Generate your own secret (do not reuse the example above):
```bash
openssl rand -hex 32
```
Enable and start:
```bash
systemctl enable coturn
systemctl start coturn
```
**Update `config.js`** to tell browsers about the TURN server:
```js
window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws',
turnURL: 'turn:your-domain.com:3478',
turnSecret: 'YOUR_SECRET_HERE',
}
```
The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs.
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations).
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode.
---
## How it works: daemon vs browser mode
There are two ways to use the web UI.
### Browser mode (for anyone with just a URL)
When the web UI is served from a non-localhost origin — or locally with `config.js` setting `signalURL` — it runs entirely in the browser. No daemon, no install. Crypto (Ed25519/X25519) runs via libsodium compiled to WebAssembly. The identity seed is stored in `localStorage` and persists across sessions.
A user visits your domain, enters their name and a network name, and joins. Invite links (`waste:…` or `?n=name&a=wss://…`) pre-fill the join form.
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
**Mobile:** the web UI ships a PWA manifest (`manifest.json`) so iOS and Android users can pin it to their home screen via "Add to Home Screen" in Safari or Chrome. It runs as a standalone app with no browser chrome. No app store, no native install required.
**Session persistence:** the last-used network name, alias, and anchor URL are saved to `localStorage`. On reload, the browser automatically rejoins the saved network — no login screen. To leave a network or switch identity, click the **⏻** button in the top-left of the sidebar. You'll be asked whether to also clear the identity keypair (export a backup first if you want to keep it).
### Daemon mode (for users running the daemon locally)
`launch-web.sh` starts the Go daemon and the Vite dev server. The web UI connects to the local daemon over WebSocket IPC (`ws://127.0.0.1:17338`). The daemon handles all crypto and connects to the anchor.
When the web UI is loaded from `localhost` without a `config.js`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
---
## File sharing (browser mode)
File transfer runs peer-to-peer over WebRTC DataChannels — files never touch the anchor or any server.
### Sharing a folder
In the sidebar under **Sharing**, click **+ Share folder** to pick a local directory. The selected files become available for peers to browse and download. A checkbox lets you control whether subfolders are included (default: yes).
Multiple folders can be shared — each appears in the list with a ↺ re-pick button (to restore after a page reload) and a ✕ remove button. The share list is saved in `localStorage` so it survives reloads; you'll be prompted to re-pick any folder whose files were lost on reload.
> Your browser will show a warning along the lines of "really upload X files?" when you pick a folder. This is a built-in browser security prompt — **no files are uploaded anywhere.** Files are transferred directly to a peer only when they explicitly request one via the file browser.
### Browsing a peer's files
Hover over a peer in the sidebar to reveal action buttons. Click **⊞** to request their file list. A panel opens on the right showing their shared files. Folders appear first and are clickable — navigate into them with a breadcrumb trail at the top. Sort by name or size; search to filter across all files in the current directory. Click **↓** next to any file to download it directly from that peer.
### Sending a file directly
Hover over a peer and click **📎** to open a file picker. The selected file is pushed immediately to that peer — they don't need to be sharing anything. The recipient's browser auto-downloads the file on arrival.
### Transfer resume (daemon mode)
When a file transfer is interrupted mid-stream — peer disconnects, network drops — the partially-received data is kept on disk. A `.meta` sidecar is written alongside each in-progress `.tmp` file recording the file name, size, and SHA-256.
When the peer reconnects and re-offers the same file, the daemon finds the matching partial by SHA-256, sends back a `file-accept` with a non-zero `resume_offset`, and the sender seeks to that byte position before streaming. The receiver appends to the existing file and verifies the full SHA-256 at the end.
Corrupted transfers (all bytes received but hash mismatch) are removed. Interrupted transfers are kept indefinitely until either successfully completed or the `.tmp`/`.meta` files are manually deleted.
> In daemon mode, use `add_share` / `remove_share` via IPC to manage share roots. Share configuration is stored in `shares.json` next to `identity.json` in the data directory and survives restarts. `networks: ["*"]` makes a share visible on all networks; omit to scope it to specific network IDs. The legacy `set_share_dir` single-dir command still works alongside it.
### Daemon download directory
Received files are saved into a per-network subdirectory so networks stay isolated. By default this is inside the data directory:
```
~/.waste/downloads-<network-id>/
```
Pass `-download-dir` at startup to use a different base:
```bash
go run ./cmd/daemon -download-dir ~/Downloads ...
```
Files then land in `~/Downloads/downloads-<network-id>/`. The path can also be changed at runtime via the `set_download_dir` IPC command (takes effect for the next incoming transfer on that network). The current path is reported in every `state_snapshot` event under `networks[].download_dir`.
---
## Desktop app (Wails)
`cmd/app/` is a [Wails v2](https://wails.io) shell that packages the React frontend and the daemon logic into a single native binary for Windows, macOS, and Linux. No Rust, no Electron — just Go + the OS webview.
The daemon runs embedded in the same process. The webview connects to `ws://127.0.0.1:17338` exactly as it does in browser-daemon mode, so the frontend code is unchanged. Identity and stores use the OS config directory (`~/Library/Application Support/waste` on macOS, `~/.config/waste` on Linux, `%APPDATA%\waste` on Windows).
### Prerequisites
- Go 1.24+
- Node.js 20+
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- Linux platform deps: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev`
### Build
```bash
# Production build → dist/waste-linux-amd64 (or waste.exe / waste.app)
./build-app.sh
# Dev mode (hot-reload; start Vite in another terminal: cd web && npm run dev)
./build-app.sh dev
# Build without system tray (no libayatana-appindicator3-dev needed)
CGO_ENABLED=1 WAILS_TAGS=notray ./build-app.sh
```
`build-app.sh` builds the React frontend, copies `web/dist/` into `cmd/app/frontend/dist/` for embedding, then runs `wails build`. The result is a single self-contained binary.
### What the desktop app provides
- **Single binary** — daemon logic is embedded, no subprocess to manage
- **System tray** (Linux/Windows) — hide to tray, reopen from tray menu, Quit
- **macOS** — hides to Dock on window close; full menu-bar tray is future work
- **OS notifications** — native popup on new message or completed file transfer (uses the browser `Notification` API via Wails events; the app will request permission on first notification)
- **Data directory** — OS-appropriate config dir (`~/.config/waste`, `~/Library/Application Support/waste`, `%APPDATA%\waste`)
### CI / automated builds
`.gitea/workflows/build.yml` runs on every `v*` tag push:
- **Server binaries** (daemon + anchor): cross-compiled for Linux amd64/arm64, macOS amd64/arm64, Windows amd64 — no CGo, no special runner needed
- **Desktop app**: Linux amd64 on the default runner (installs GTK + WebKit + appindicator deps automatically)
- **Releases**: all artifacts attached to the Gitea release
To add macOS or Windows desktop builds, add self-hosted Gitea runners on those platforms and mirror the `desktop-linux` job.
---
## Local development
### Prerequisites
- Go 1.24+ — https://go.dev/dl/
- Node.js 20+
### Quick start (three peers in one terminal session)
```bash
# Terminal 1 — local anchor
go run ./cmd/anchor -bind 127.0.0.1:17339 go run ./cmd/anchor -bind 127.0.0.1:17339
# Terminal 2 — peer A # Terminal 2 — peer A
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
# Terminal 3 — peer B (or use --join with an invite from peer A) # Terminal 3 — peer B
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
``` ```
Both peers join the same named network via IPC: Join both to a network:
```bash ```bash
# Join peer A to a network called "friends"
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337 echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
# Join peer B to the same network
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341 echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
# Subscribe to peer A's events (in a separate terminal)
nc 127.0.0.1 17337 &
# Send a message from B
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
``` ```
**On Windows** — use PowerShell's built-in TCP client instead of `nc`: ### Web UI (daemon mode)
```powershell
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
# In a separate terminal — subscribe to peer A's events
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
$reader = [System.IO.StreamReader]::new($r.GetStream())
while ($true) { $reader.ReadLine() }
```
## Deploying the anchor on your Hetzner VPS
```bash ```bash
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor # Requires a running daemon on port 17337
scp bin/waste-anchor user@your-vps:~/ ./launch-web.sh
# On the VPS (also run coturn in STUN-only mode on port 3478) # Or with a custom alias and network:
./waste-anchor -bind 0.0.0.0:17339 ALIAS=alice NETWORK=friends ./launch-web.sh
``` ```
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC ### Automated test
with ICE (STUN-assisted hole punching) through the anchor for signaling.
## IPC protocol (plain JSON over TCP)
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
**Commands you send:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"leave_network"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
{"type":"get_state"}
{"type":"get_file_list"} // own share dir
{"type":"get_file_list","peer_id":"<64-hex>"} // remote peer's share dir
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} // offer a file from share dir
{"type":"generate_invite"}
```
**Events the daemon pushes:**
```jsonc
// Sent immediately on connect and in response to get_state
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
// Peer lifecycle
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
// File events
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"<32-hex>","name":"notes.txt","size":1024,"sha256":"<64-hex>"}}
{"type":"file_progress","transfer_id":"<32-hex>","bytes_received":65536,"total_bytes":1048576}
{"type":"file_complete","transfer_id":"<32-hex>","path":"/data-dir/downloads-<netid>/notes.txt"}
{"type":"file_list","peer_id":"<64-hex>","files":[{"name":"notes.txt","size_bytes":1024}]}
// Invite generation response
{"type":"invite_generated","invite":"waste:<base64>"}
// Error
{"type":"error","error_message":"..."}
```
## Crypto choices
| Purpose | Algorithm | Notes |
|---|---|---|
| Identity | Ed25519 | Fast, small keys, standard |
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
| Signaling encryption (2.0) | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
| **Signaling encryption (2.1)** | **XSalsa20-Poly1305, ephemeral X25519** | **Per-session keypair; `esk` wiped on close → forward secrecy** |
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
| Hashing | SHA-256 | File integrity, network name hashing |
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
### Forward-secret signaling (YAW/2.1)
By default waste-go speaks **YAW/2.1**: before sending an offer each peer generates a fresh
X25519 keypair (`esk`/`epk`), broadcasts its `epk` in a signed `ekey` message, then seals
`offer`/`answer`/`candidate` payloads with the *ephemeral* keys. `esk` is zeroed when the
session ends. Recorded signaling traffic cannot be decrypted even if the long-term Ed25519
keys later leak.
A 2.0 peer ignores the `ekey` message (unknown type → silently dropped) and the offerer
falls back to static-key sealing after a 2 s timeout, so **2.1 ↔ 2.0 sessions work** — the
session just isn't forward-secret. The log line `anchor: 2.0 fallback offer to …` flags this.
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
> on disk are unaffected — only the over-the-wire representation changed from base64url.
## Onboarding a new peer
Alice is already on the network and wants to add Bob.
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
```bash
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
```
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
```bash
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
**Bob opens the TUI**`--join` also accepts the invite to skip the `-network` flag:
```bash
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
## Terminal UI
Start the daemon first (see Getting started above), then:
```bash
go run ./cmd/tui -network friends
```
Options:
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string — sets the network name automatically |
| `-ipc` | `17337` | Daemon IPC port |
**Layout:**
```
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
│ ││ 15:05 charlie the mesh works ││ ● charlie │
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
╭─────────────────────────────────────────────────────────────╮
│ Type a message… │
╰─────────────────────────────────────────────────────────────╯
```
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
## Testing
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
```bash ```bash
./test-network.sh ./test-network.sh
``` ```
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run: Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
---
## Onboarding a new peer
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
```bash ```bash
# DB name includes the network ID (first 8 hex chars of sha256("yaw2-net:"+name)) echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
sqlite3 /tmp/waste-test/alice/messages-<netid>.db # → {"type":"invite_generated","invite":"waste:eyJ..."}
.headers on
SELECT room, from_peer, text, sent_at FROM messages;
SELECT peer_id, alias, last_seen FROM peers;
``` ```
There is also a TUI integration test that boots the same three-peer network and Bob joins using the invite:
launches the Bubble Tea UI as alice:
```bash ```bash
./test-tui.sh go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJ...'
go run ./cmd/tui --join 'waste:eyJ...'
``` ```
## Roadmap The invite encodes the anchor URL and network name. Sharing it only lets the recipient join the same network — the anchor never sees plaintext messages.
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
- [x] **Proto additions**`mid` dedup field, signaling types, anchor wire types, `hello` message
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server replacing TCP relay ### Signed invites and invite-only networks (waste-go extension)
- [x] **WebRTC peer connections** — pion/webrtc DataChannels; ICE hole-punching via STUN
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing 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.
- [x] **IPC updates**`join_network`/`leave_network`; `session_ready` event; DMs via `to` field
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache 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.
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs
- [x] **File transfer** — chunked binary DataChannel (`f:<xid>`); SHA-256 verified; backpressure; auto-accept ### "Come hang" hang links
- [x] **Forward-secret signaling (YAW/2.1)** — ephemeral X25519 per session; `esk` wiped on close; 2.0 fallback
- [ ] **Native UI** — web frontend with native packaging (Tauri-style) The 🔗 button in the web UI copies a **hash-based hang link**:
```
https://your-domain.com/#waste:eyJ...
```
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
---
## Terminal UI
```bash
go run ./cmd/tui -network friends
```
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port |
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
**Slash commands:** `/room <name>` — create a new room (persisted in SQLite, restored on reconnect). Rooms with unread messages show a `*` prefix in the sidebar.
---
## IPC protocol
Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
**Commands:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","to":"<64-hex>","body":"hey"} // DM
{"type":"generate_invite"}
{"type":"get_state"}
{"type":"get_file_list"}
{"type":"get_file_list","peer_id":"<64-hex>"}
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
{"type":"add_share","path":"/home/alice/Music"} // global share
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // network-scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}
{"type":"create_room","room":"dev"}
{"type":"set_download_dir","path":"/home/alice/Downloads"} // change download base for current network
{"type":"set_download_dir","network_id":"<16-hex>","path":"/home/alice/Downloads/friends"}
{"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."}
```
**Events:**
```jsonc
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>","networks":[{"network_id":"...","network_name":"friends","share_dir":"...","download_dir":"..."}]}
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
{"type":"network_joined","network_id":"...","network_name":"friends"}
{"type":"invite_generated","invite":"waste:<base64>"}
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"room_created","network_id":"...","room":"dev"}
{"type":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."}
```
---
## Crypto
| Purpose | Algorithm |
|---|---|
| Identity | Ed25519 |
| Signaling (2.0) | XSalsa20-Poly1305, X25519 keys derived from Ed25519 |
| Signaling (2.1) | XSalsa20-Poly1305, ephemeral X25519 per session (forward secrecy) |
| Transport | WebRTC DataChannels (DTLS+SCTP via pion/webrtc) |
| File integrity | SHA-256 |
### Forward-secret signaling (YAW/2.1)
Each peer generates a fresh X25519 keypair per session and broadcasts the public half in a signed `ekey` message before sending an offer. The `esk` is zeroed when the session ends. A 2.0 peer ignores `ekey` and the offerer falls back to static-key sealing after 2 s — so 2.1↔2.0 sessions work, just without forward secrecy.

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") wsPort := flag.Int("ws-port", 0, "port for WebSocket IPC (web UI); 0 = disabled")
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
shareDir := flag.String("share-dir", "", "directory to share with peers on the network") shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
downloadDir := flag.String("download-dir", "", "base directory for received files; defaults to data-dir, split by network")
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478")
turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential")
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import") importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity") importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
flag.Parse() flag.Parse()
@@ -74,6 +77,9 @@ func main() {
StoreDir: dir, StoreDir: dir,
AnchorURL: *anchorURL, AnchorURL: *anchorURL,
ShareDir: expandHome(*shareDir), ShareDir: expandHome(*shareDir),
DownloadDir: expandHome(*downloadDir),
TurnURL: *turnURL,
TurnSecret: *turnSecret,
}) })
if autoJoinNetwork != "" { if autoJoinNetwork != "" {

View File

@@ -107,6 +107,7 @@ type model struct {
rooms []string // "general" always first; DM rooms appended rooms []string // "general" always first; DM rooms appended
activeRoom int activeRoom int
messages map[string][]entry messages map[string][]entry
unread map[string]bool // rooms with messages since last viewed
peers map[proto.PeerID]string // connected peers: id → alias peers map[proto.PeerID]string // connected peers: id → alias
peerOrder []proto.PeerID peerOrder []proto.PeerID
@@ -131,6 +132,7 @@ func newModel(ipcPort int, network string) model {
networkName: network, networkName: network,
rooms: []string{"general"}, rooms: []string{"general"},
messages: make(map[string][]entry), messages: make(map[string][]entry),
unread: make(map[string]bool),
peers: make(map[proto.PeerID]string), peers: make(map[proto.PeerID]string),
input: ti, input: ti,
status: "connecting…", status: "connecting…",
@@ -215,9 +217,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m, cmds = m.doSend(cmds) m, cmds = m.doSend(cmds)
case msg.Type == tea.KeyTab: case msg.Type == tea.KeyTab:
m.activeRoom = (m.activeRoom + 1) % len(m.rooms) m.activeRoom = (m.activeRoom + 1) % len(m.rooms)
delete(m.unread, m.activeRoomName())
m = m.refreshViewport() m = m.refreshViewport()
case msg.Type == tea.KeyShiftTab: case msg.Type == tea.KeyShiftTab:
m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms) m.activeRoom = (m.activeRoom - 1 + len(m.rooms)) % len(m.rooms)
delete(m.unread, m.activeRoomName())
m = m.refreshViewport() m = m.refreshViewport()
default: default:
var tiCmd tea.Cmd var tiCmd tea.Cmd
@@ -251,8 +255,15 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
m.peers[p.ID] = p.Alias m.peers[p.ID] = p.Alias
m.peerOrder = append(m.peerOrder, p.ID) m.peerOrder = append(m.peerOrder, p.ID)
} }
for _, r := range evt.Rooms {
m = m.addRoom(r)
}
m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName) m.status = fmt.Sprintf("● %s · %s", m.localAlias, m.networkName)
case proto.EvtRoomCreated:
m = m.addRoom(evt.Room)
m = m.refreshViewport()
case proto.EvtSessionReady: case proto.EvtSessionReady:
if evt.PeerID != nil { if evt.PeerID != nil {
pid := *evt.PeerID pid := *evt.PeerID
@@ -283,7 +294,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
} }
case proto.EvtInviteGenerated: case proto.EvtInviteGenerated:
m.invitePopup = evt.InviteString m.invitePopup = evt.InviteGenerated
case proto.EvtMessageReceived: case proto.EvtMessageReceived:
if evt.Message != nil { if evt.Message != nil {
@@ -296,6 +307,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
} }
m.messages[msg.Room] = append(m.messages[msg.Room], e) m.messages[msg.Room] = append(m.messages[msg.Room], e)
m = m.addRoom(msg.Room) m = m.addRoom(msg.Room)
if msg.Room != m.activeRoomName() {
m.unread[msg.Room] = true
}
m = m.refreshViewport() m = m.refreshViewport()
} }
} }
@@ -309,6 +323,14 @@ func (m model) doSend(cmds []tea.Cmd) (model, []tea.Cmd) {
} }
m.input.SetValue("") m.input.SetValue("")
if strings.HasPrefix(body, "/room ") {
name := strings.TrimSpace(strings.TrimPrefix(body, "/room "))
if name != "" {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdCreateRoom, Room: name}))
}
return m, cmds
}
room := m.rooms[m.activeRoom] room := m.rooms[m.activeRoom]
ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body} ipcMsg := proto.IpcMessage{Type: proto.CmdSendMessage, Room: room, Body: body}
if strings.HasPrefix(room, "dm:") { if strings.HasPrefix(room, "dm:") {
@@ -430,7 +452,7 @@ func (m model) View() string {
if m.errMsg != "" { if m.errMsg != "" {
statusLine = styleErr.Render(" ✗ " + m.errMsg) statusLine = styleErr.Render(" ✗ " + m.errMsg)
} else { } else {
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit" hint := " tab: rooms · /room <name>: new room · ctrl+i: invite · ctrl+c: quit"
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
} }
@@ -468,7 +490,11 @@ func (m model) renderRooms(boxH int) string {
if i == m.activeRoom { if i == m.activeRoom {
lines = append(lines, styleActive.Width(innerW).Render("▶ "+label)) lines = append(lines, styleActive.Width(innerW).Render("▶ "+label))
} else { } else {
lines = append(lines, styleRoom.Width(innerW).Render(" "+label)) prefix := " "
if m.unread[room] {
prefix = "* "
}
lines = append(lines, styleRoom.Width(innerW).Render(prefix+label))
} }
} }
for len(lines) < contentH { for len(lines) < contentH {

39
go.mod
View File

@@ -6,14 +6,16 @@ require (
filippo.io/edwards25519 v1.2.0 filippo.io/edwards25519 v1.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pion/webrtc/v3 v3.3.6 github.com/pion/webrtc/v3 v3.3.6
golang.org/x/crypto v0.24.0 golang.org/x/crypto v0.33.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
nhooyr.io/websocket v1.8.17 nhooyr.io/websocket v1.8.17
) )
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
@@ -27,7 +29,26 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/getlantern/systray v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -35,6 +56,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pion/datachannel v1.5.8 // indirect github.com/pion/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.38 // indirect github.com/pion/ice/v2 v2.3.38 // indirect
@@ -50,15 +72,24 @@ require (
github.com/pion/stun v0.6.1 // indirect github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/stretchr/testify v1.9.0 // indirect github.com/samber/lo v1.49.1 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/wailsapp/wails/v2 v2.12.0 // indirect
github.com/wlynxg/anet v0.0.3 // indirect github.com/wlynxg/anet v0.0.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.22.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

85
go.sum
View File

@@ -1,9 +1,13 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -31,21 +35,63 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -60,6 +106,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
@@ -102,22 +150,45 @@ github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE= github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -130,12 +201,15 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
@@ -144,17 +218,24 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -173,6 +254,7 @@ golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@@ -180,6 +262,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -187,6 +271,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -122,9 +122,8 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
if already { if already {
continue continue
} }
// Use the same lexicographic tiebreak as anchor join to avoid // Lower ID offers (matches yaw2/browser convention).
// both sides trying to offer simultaneously. if strings.Compare(string(id.PeerID()), string(pid)) >= 0 {
if strings.Compare(string(id.PeerID()), string(pid)) <= 0 {
continue continue
} }
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
@@ -170,7 +169,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers)) log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers))
for _, peerHex := range msg.Peers { for _, peerHex := range msg.Peers {
pid := proto.PeerID(peerHex) pid := proto.PeerID(peerHex)
if strings.Compare(string(id.PeerID()), peerHex) > 0 { if strings.Compare(string(id.PeerID()), peerHex) < 0 {
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s) sess, err := startOffer(ctx, pid, id, m, s)
if err != nil { if err != nil {
@@ -187,7 +186,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
case proto.AnchorPeerJoin: case proto.AnchorPeerJoin:
pid := proto.PeerID(msg.ID) pid := proto.PeerID(msg.ID)
log.Printf("anchor: peer joined: %s", pid.Short()) log.Printf("anchor: peer joined: %s", pid.Short())
if strings.Compare(string(id.PeerID()), msg.ID) > 0 { if strings.Compare(string(id.PeerID()), msg.ID) < 0 {
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s) sess, err := startOffer(ctx, pid, id, m, s)
if err != nil { if err != nil {
@@ -230,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
mu.Lock() mu.Lock()
if sess == nil { if sess == nil {
// Answerer: we haven't created a session yet, do it now. // Answerer: we haven't created a session yet, do it now.
pc, err := newPC() pc, err := newPC(m.ICEServers)
if err != nil { if err != nil {
mu.Unlock() mu.Unlock()
log.Printf("anchor: new PC for answerer: %v", err) log.Printf("anchor: new PC for answerer: %v", err)
@@ -413,7 +412,7 @@ func dispatchSignaling(
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for // startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
// the peer's ekey, then sends the offer (ephemeral or static). // the peer's ekey, then sends the offer (ephemeral or static).
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) { func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
pc, err := newPC() pc, err := newPC(m.ICEServers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -474,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m
// answerOffer processes an incoming offer and returns the PeerConnection. // answerOffer processes an incoming offer and returns the PeerConnection.
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) { func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
pc, err := newPC() pc, err := newPC(m.ICEServers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -619,10 +618,9 @@ func hashNetName(name string) string {
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
func newPC() (*webrtc.PeerConnection, error) { func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
return webrtc.NewPeerConnection(webrtc.Configuration{ servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
})
} }
func boolPtr(b bool) *bool { return &b } func boolPtr(b bool) *bool { return &b }

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. // Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
func (id *Identity) Sign(data []byte) string { func (id *Identity) Sign(data []byte) string {
sig := ed25519.Sign(id.privateKey, data) sig := ed25519.Sign(id.privateKey, data)

View File

@@ -6,6 +6,11 @@
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly // 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
// to the signaling server. A yaw2 client that can parse the base64 JSON can join // to the signaling server. A yaw2 client that can parse the base64 JSON can join
// the same network without knowing the plaintext name. // the same network without knowing the plaintext name.
//
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
// the invite was issued by a known peer. Receiving peers that enforce
// RequireInvite will reject hellos that carry no valid signed invite.
// YAW/2-only peers ignore both fields.
package invite package invite
import ( import (
@@ -24,26 +29,66 @@ type Invite struct {
Anchor string `json:"anchor"` // WebSocket anchor URL Anchor string `json:"anchor"` // WebSocket anchor URL
Network string `json:"network"` // plaintext network name Network string `json:"network"` // plaintext network name
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
} }
// Encode returns a waste: invite string for the given anchor URL and network name. // IsSigned reports whether the invite carries a signature.
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
// Signer can sign data and report its own peer ID.
type Signer interface {
Sign(data []byte) string
PeerIDHex() string
}
// Verifier verifies an Ed25519 signature given a hex public key.
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
// Encode returns an unsigned waste: invite string (backward compatible).
func Encode(anchor, network string) (string, error) { func Encode(anchor, network string) (string, error) {
return marshal(Invite{
Anchor: anchor,
Network: network,
Net: NetHash(network),
})
}
// EncodeSigned returns a signed waste: invite string.
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
if anchor == "" { if anchor == "" {
return "", fmt.Errorf("anchor URL is required") return "", fmt.Errorf("anchor URL is required")
} }
if network == "" { if network == "" {
return "", fmt.Errorf("network name is required") return "", fmt.Errorf("network name is required")
} }
h := sha256.Sum256([]byte("yaw2-net:" + network)) inviter := signer.PeerIDHex()
b, err := json.Marshal(Invite{ net := NetHash(network)
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
return marshal(Invite{
Anchor: anchor, Anchor: anchor,
Network: network, Network: network,
Net: hex.EncodeToString(h[:]), Net: net,
Inviter: inviter,
Sig: sig,
}) })
if err != nil {
return "", err
} }
return prefix + base64.URLEncoding.EncodeToString(b), nil
// Verify checks the invite signature and that the inviter is in the trusted set.
// Unsigned invites return nil — the caller decides whether to accept them.
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
if !inv.IsSigned() {
return nil
}
payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
return fmt.Errorf("invite signature invalid: %w", err)
}
if !trusted[inv.Inviter] {
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
}
return nil
} }
// Decode parses a waste: invite string and returns the Invite. // Decode parses a waste: invite string and returns the Invite.
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
return inv, nil return inv, nil
} }
// NetHash returns the full 64-char hex network hash for the given name // NetHash returns the full 64-char hex network hash for the given name.
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
func NetHash(name string) string { func NetHash(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name)) h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
func marshal(inv Invite) (string, error) {
b, err := json.Marshal(inv)
if err != nil {
return "", err
}
return prefix + base64.URLEncoding.EncodeToString(b), nil
}
func sigPayload(anchor, network, net, inviter string) []byte {
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
}

View File

@@ -15,6 +15,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"strings"
"time" "time"
"nhooyr.io/websocket" "nhooyr.io/websocket"
@@ -23,6 +24,7 @@ import (
"github.com/waste-go/internal/invite" "github.com/waste-go/internal/invite"
"github.com/waste-go/internal/netmgr" "github.com/waste-go/internal/netmgr"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
"github.com/waste-go/internal/shares"
) )
// RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort. // RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort.
@@ -145,7 +147,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
case cmd.NetworkName != "": case cmd.NetworkName != "":
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir) netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
case len(cmd.NetworkHash) == 64: case len(cmd.NetworkHash) == 64:
// yaw2-compatible: join by full 64-char hex hash (net field)
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir) netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
default: default:
send(errMsg("join_network: network_name or network_hash (64 hex chars) required")) send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
@@ -155,7 +156,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg(fmt.Sprintf("join_network: %v", err))) send(errMsg(fmt.Sprintf("join_network: %v", err)))
continue continue
} }
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash. if n, ok := mgr.Get(netID); ok {
if cmd.RequireInvite {
n.Mesh.RequireInvite = true
}
if cmd.InviteString != "" {
n.Mesh.InviteString = cmd.InviteString
}
}
_ = netID _ = netID
case proto.CmdLeaveNetwork: case proto.CmdLeaveNetwork:
@@ -232,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: case proto.CmdGetState:
send(stateSnapshot(mgr)) send(stateSnapshot(mgr))
@@ -246,7 +271,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
Type: proto.EvtFileList, Type: proto.EvtFileList,
NetworkID: n.ID, NetworkID: n.ID,
PeerID: ptr(n.Identity.PeerID()), PeerID: ptr(n.Identity.PeerID()),
Files: n.Mesh.ScanShareDir(), Files: mgr.ScanAllShares(n.ID),
}) })
} else { } else {
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq}) req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
@@ -258,6 +283,35 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
} }
} }
case proto.CmdAddShare:
if cmd.Path == "" {
send(errMsg("add_share: path is required"))
continue
}
networks := cmd.ShareNetworks
if len(networks) == 0 {
networks = []string{"*"}
}
if err := mgr.Shares.Add(shares.Share{Path: cmd.Path, Networks: networks}); err != nil {
send(errMsg(fmt.Sprintf("add_share: %v", err)))
continue
}
send(sharesListMsg(mgr))
case proto.CmdRemoveShare:
if cmd.Path == "" {
send(errMsg("remove_share: path is required"))
continue
}
if err := mgr.Shares.Remove(cmd.Path); err != nil {
send(errMsg(fmt.Sprintf("remove_share: %v", err)))
continue
}
send(sharesListMsg(mgr))
case proto.CmdListShares:
send(sharesListMsg(mgr))
case proto.CmdGenerateInvite: case proto.CmdGenerateInvite:
n := mgr.Resolve(cmd.NetworkID) n := mgr.Resolve(cmd.NetworkID)
if n == nil { if n == nil {
@@ -268,7 +322,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg("generate_invite: daemon was started without -anchor flag")) send(errMsg("generate_invite: daemon was started without -anchor flag"))
continue continue
} }
inv, err := invite.Encode(mgr.AnchorURL(), n.Name) inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
if err != nil { if err != nil {
send(errMsg(fmt.Sprintf("generate_invite: %v", err))) send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
continue continue
@@ -276,7 +330,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(proto.IpcMessage{ send(proto.IpcMessage{
Type: proto.EvtInviteGenerated, Type: proto.EvtInviteGenerated,
NetworkID: n.ID, NetworkID: n.ID,
InviteString: inv, InviteGenerated: inv,
}) })
case proto.CmdSetShareDir: case proto.CmdSetShareDir:
@@ -290,6 +344,22 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
continue continue
} }
case proto.CmdSetDownloadDir:
n := mgr.Resolve(cmd.NetworkID)
if n == nil {
send(errMsg("set_download_dir: not joined to any network"))
continue
}
if cmd.Path == "" {
send(errMsg("set_download_dir: path is required"))
continue
}
if !mgr.SetDownloadDir(n.ID, cmd.Path) {
send(errMsg("set_download_dir: network not found"))
continue
}
send(stateSnapshot(mgr))
case proto.CmdSendFile: case proto.CmdSendFile:
n := mgr.Resolve(cmd.NetworkID) n := mgr.Resolve(cmd.NetworkID)
if n == nil { if n == nil {
@@ -380,6 +450,11 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
pi := all[0].Identity.PeerInfo() pi := all[0].Identity.PeerInfo()
msg.LocalPeer = &pi msg.LocalPeer = &pi
msg.ConnectedPeers = all[0].Mesh.ConnectedPeers() msg.ConnectedPeers = all[0].Mesh.ConnectedPeers()
if extra, err := all[0].Store.Rooms(); err == nil {
for _, r := range extra {
msg.Rooms = append(msg.Rooms, r)
}
}
} }
return msg return msg
@@ -389,6 +464,18 @@ func errMsg(s string) proto.IpcMessage {
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s} return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
} }
func sharesListMsg(mgr *netmgr.Manager) proto.IpcMessage {
all := mgr.Shares.All()
entries := make([]proto.ShareEntry, len(all))
for i, sh := range all {
entries[i] = proto.ShareEntry{Path: sh.Path, Networks: sh.Networks}
}
return proto.IpcMessage{Type: proto.EvtSharesList, Shares: entries}
}
// ensure shares import is used
var _ = shares.Share{}
func ptr[T any](v T) *T { return &v } func ptr[T any](v T) *T { return &v }
func randomHex(n int) string { func randomHex(n int) string {

View File

@@ -13,6 +13,9 @@ import (
"github.com/waste-go/internal/store" "github.com/waste-go/internal/store"
) )
// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly.
type ICEServer = webrtc.ICEServer
// PeerConn is a live connection to one peer. // PeerConn is a live connection to one peer.
type PeerConn struct { type PeerConn struct {
Info proto.PeerInfo Info proto.PeerInfo
@@ -29,6 +32,12 @@ type Mesh struct {
Store *store.Store // may be nil if persistence is disabled Store *store.Store // may be nil if persistence is disabled
ShareDir string // directory whose contents are shared with peers; "" = no sharing ShareDir string // directory whose contents are shared with peers; "" = no sharing
DownloadDir string // directory where received files are saved DownloadDir string // directory where received files are saved
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
InviteString string // the invite this peer used to join (sent in hello to other peers)
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
// 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 mu sync.RWMutex
peers map[proto.PeerID]*PeerConn peers map[proto.PeerID]*PeerConn
@@ -60,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. // ScanShareDir returns the list of files in the local share directory.
// Returns an empty slice if ShareDir is unset or the directory is empty. // Returns an empty slice if ShareDir is unset or the directory is empty.
func (m *Mesh) ScanShareDir() []proto.FileEntry { func (m *Mesh) ScanShareDir() []proto.FileEntry {

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"strings" "strings"
"sync" "sync"
@@ -13,6 +14,7 @@ import (
"github.com/pion/webrtc/v3" "github.com/pion/webrtc/v3"
"github.com/waste-go/internal/crypto" "github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
) )
@@ -47,6 +49,7 @@ func WireDataChannel(
Nick: id.Alias, Nick: id.Alias,
Caps: []string{"chat", "file"}, Caps: []string{"chat", "file"},
Sig: id.Sign(bindBytes), Sig: id.Sign(bindBytes),
Invite: m.InviteString,
} }
helloJSON, _ := json.Marshal(hello) helloJSON, _ := json.Marshal(hello)
if err := dc.SendText(string(helloJSON)); err != nil { if err := dc.SendText(string(helloJSON)); err != nil {
@@ -94,6 +97,17 @@ func WireDataChannel(
m.RemovePeer(peerID) m.RemovePeer(peerID)
pc.Close() pc.Close()
}) })
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
m.Emit(proto.IpcMessage{
Type: proto.EvtPeerStatus,
PeerID: &peerID,
ConnState: state.String(),
})
if state == webrtc.PeerConnectionStateConnected {
go emitICEStats(pc, peerID, m)
}
})
} }
// WireCandidateTrickle seals and forwards each ICE candidate via the anchor as it arrives. // WireCandidateTrickle seals and forwards each ICE candidate via the anchor as it arrives.
@@ -137,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
log.Printf("peer: bad hello from %s: %v", from.Short(), err) log.Printf("peer: bad hello from %s: %v", from.Short(), err)
return return
} }
// Invite enforcement (waste-go extension).
if m.RequireInvite {
if hello.Invite == "" {
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
})
return
}
inv, err := invite.Decode(hello.Invite)
if err != nil || !inv.IsSigned() {
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
})
return
}
trusted := m.trustedPeerIDs()
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
log.Printf("peer: rejecting %s — %v", from.Short(), err)
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
})
return
}
}
// Update alias once we have the verified nick. // Update alias once we have the verified nick.
m.mu.Lock() m.mu.Lock()
if conn, ok := m.peers[from]; ok { if conn, ok := m.peers[from]; ok {
@@ -189,7 +234,12 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat}) m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
case proto.MsgFileListReq: case proto.MsgFileListReq:
files := m.ScanShareDir() var files []proto.FileEntry
if m.ScanFiles != nil {
files = m.ScanFiles()
} else {
files = m.ScanShareDir()
}
resp, err := json.Marshal(proto.PeerMessage{ resp, err := json.Marshal(proto.PeerMessage{
Type: proto.MsgFileListResp, Type: proto.MsgFileListResp,
FileListResp: &proto.FileListResp{Files: files}, FileListResp: &proto.FileListResp{Files: files},
@@ -217,7 +267,7 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
m.acceptIncoming(msg, from) m.acceptIncoming(msg, from)
case proto.MsgFileAccept: case proto.MsgFileAccept:
m.startSend(msg.Xid, from) m.startSend(msg.Xid, from, msg.ResumeOffset)
case proto.MsgFileCancel: case proto.MsgFileCancel:
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason) log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
@@ -257,6 +307,42 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
} }
} }
// emitICEStats reads the active (nominated) ICE candidate pair from pion's stats
// and emits a peer_status event with the candidate type and remote address.
func emitICEStats(pc *webrtc.PeerConnection, peerID proto.PeerID, m *Mesh) {
stats := pc.GetStats()
candidateType := "unknown"
remoteAddress := ""
// Find the nominated candidate pair and its remote candidate.
for _, s := range stats {
pair, ok := s.(webrtc.ICECandidatePairStats)
if !ok || !pair.Nominated {
continue
}
// Look up remote candidate by its ID.
if rc, ok2 := stats[pair.RemoteCandidateID]; ok2 {
remote, ok3 := rc.(webrtc.ICECandidateStats)
if ok3 {
candidateType = remote.CandidateType.String()
if remote.Port > 0 {
remoteAddress = fmt.Sprintf("%s:%d", remote.IP, remote.Port)
} else {
remoteAddress = remote.IP
}
}
}
break
}
m.Emit(proto.IpcMessage{
Type: proto.EvtPeerStatus,
PeerID: &peerID,
CandidateType: candidateType,
RemoteAddress: remoteAddress,
})
}
// midOrRandom returns mid if non-empty, otherwise generates a random 16-byte hex string. // midOrRandom returns mid if non-empty, otherwise generates a random 16-byte hex string.
// Ensures every stored message has a unique mid even from peers that don't send one. // Ensures every stored message has a unique mid even from peers that don't send one.
func midOrRandom(mid string) string { func midOrRandom(mid string) string {

View File

@@ -40,7 +40,55 @@ type inboundTransfer struct {
mu sync.Mutex mu sync.Mutex
tmp *os.File tmp *os.File
hasher hash.Hash hasher hash.Hash
written int64 written int64 // total bytes received; starts at resumeOffset when resuming
resumePath string // path to existing .tmp when resuming; empty for new transfers
resumeOffset int64 // bytes already present in resumePath
metaPath string // path to the .tmp.meta sidecar
}
// partialMeta is written as a JSON sidecar alongside each in-progress .tmp file.
// It survives interruptions so the receiver can resume later.
type partialMeta struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
From string `json:"from"`
Size int64 `json:"size"`
}
// findPartial scans dir for a .tmp.meta sidecar whose sha256 matches.
// Returns (tmpPath, metaPath, offset) — all empty/zero if no match.
func findPartial(dir, sha256hex string) (tmpPath, metaPath string, offset int64) {
metas, _ := filepath.Glob(filepath.Join(dir, "*.tmp.meta"))
for _, mp := range metas {
data, err := os.ReadFile(mp)
if err != nil {
continue
}
var m partialMeta
if err := json.Unmarshal(data, &m); err != nil {
continue
}
if m.SHA256 != sha256hex {
continue
}
tp := strings.TrimSuffix(mp, ".meta")
info, err := os.Stat(tp)
if err != nil {
continue
}
return tp, mp, info.Size()
}
return "", "", 0
}
func writePartialMeta(path string, t *inboundTransfer) {
data, _ := json.Marshal(partialMeta{
Name: t.name,
SHA256: t.sha256,
From: string(t.from),
Size: t.size,
})
os.WriteFile(path, data, 0o644) //nolint:errcheck
} }
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a // OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
@@ -102,28 +150,49 @@ func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
} }
// acceptIncoming is called when we receive file-offer from a peer. // acceptIncoming is called when we receive file-offer from a peer.
// It stores the inbound state and immediately sends file-accept (auto-accept). // Checks for a resumable partial download matching the offer's sha256.
// Sends file-accept immediately, with a non-zero resume_offset when resuming.
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) { func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
m.transferMu.Lock() t := &inboundTransfer{
m.inbound[msg.Xid] = &inboundTransfer{
from: from, from: from,
name: msg.Name, name: msg.Name,
size: msg.Size, size: msg.Size,
sha256: msg.SHA256, sha256: msg.SHA256,
} }
var resumeOffset int64
if m.DownloadDir != "" {
if tp, mp, off := findPartial(m.DownloadDir, msg.SHA256); tp != "" {
t.resumePath = tp
t.metaPath = mp
t.resumeOffset = off
t.written = off
resumeOffset = off
log.Printf("transfer: found partial for %s at %s (%d/%d bytes)", msg.Name, tp, off, msg.Size)
}
}
m.transferMu.Lock()
m.inbound[msg.Xid] = t
m.transferMu.Unlock() m.transferMu.Unlock()
accept, _ := json.Marshal(proto.PeerMessage{ accept, _ := json.Marshal(proto.PeerMessage{
Type: proto.MsgFileAccept, Type: proto.MsgFileAccept,
Xid: msg.Xid, Xid: msg.Xid,
ResumeOffset: resumeOffset,
}) })
m.SendTo(from, accept) m.SendTo(from, accept)
if resumeOffset > 0 {
log.Printf("transfer: resuming %s from byte %d xid=%s", msg.Name, resumeOffset, msg.Xid[:8])
} else {
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8]) log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
} }
}
// startSend opens the binary DataChannel "f:<xid>" and streams the file. // startSend opens the binary DataChannel "f:<xid>" and streams the file.
// Called when we receive file-accept from the peer. // Called when we receive file-accept from the peer. resumeOffset is non-zero
func (m *Mesh) startSend(xid string, from proto.PeerID) { // when the receiver is resuming a previous partial download.
func (m *Mesh) startSend(xid string, from proto.PeerID, resumeOffset int64) {
m.transferMu.Lock() m.transferMu.Lock()
t, ok := m.outbound[xid] t, ok := m.outbound[xid]
m.transferMu.Unlock() m.transferMu.Unlock()
@@ -156,13 +225,13 @@ func (m *Mesh) startSend(xid string, from proto.PeerID) {
dc.OnOpen(func() { dc.OnOpen(func() {
dc.OnMessage(func(msg webrtc.DataChannelMessage) { dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if msg.IsString && string(msg.Data) == "ok" { if msg.IsString && string(msg.Data) == "ok" {
go m.sendFileChunks(dc, t, xid) go m.sendFileChunks(dc, t, xid, resumeOffset)
} }
}) })
}) })
} }
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string) { func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string, offset int64) {
defer func() { defer func() {
m.transferMu.Lock() m.transferMu.Lock()
delete(m.outbound, xid) delete(m.outbound, xid)
@@ -177,6 +246,15 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
} }
defer f.Close() defer f.Close()
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
log.Printf("transfer: seek to %d xid=%s: %v", offset, xid[:8], err)
dc.Close()
return
}
log.Printf("transfer: resuming send from byte %d xid=%s", offset, xid[:8])
}
// Backpressure: block when the DC buffer is full. // Backpressure: block when the DC buffer is full.
resume := make(chan struct{}, 1) resume := make(chan struct{}, 1)
dc.SetBufferedAmountLowThreshold(fileBufferLowWater) dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
@@ -188,7 +266,7 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
}) })
buf := make([]byte, fileChunkSize) buf := make([]byte, fileChunkSize)
var sent int64 sent := offset // start progress reporting from where the receiver left off
for { for {
n, readErr := f.Read(buf) n, readErr := f.Read(buf)
@@ -229,8 +307,9 @@ func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid s
} }
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel // HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the // arrives on a PeerConnection. It writes chunks to a temp file (or resumes an
// SHA-256 on close, and emits EvtFileComplete. // existing partial), verifies the SHA-256 on close, and emits EvtFileComplete.
// Interrupted transfers keep their .tmp and .meta files for future resume.
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) { func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
m.transferMu.Lock() m.transferMu.Lock()
t, ok := m.inbound[xid] t, ok := m.inbound[xid]
@@ -247,16 +326,43 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err) log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
return return
} }
t.mu.Lock()
defer t.mu.Unlock()
if t.resumePath != "" {
// Resume: open existing .tmp in read-write mode, seek to end for appending.
f, err := os.OpenFile(t.resumePath, os.O_RDWR, 0o644)
if err != nil {
log.Printf("transfer: open resume file %s: %v", t.resumePath, err)
return
}
// Restore the hasher state by re-hashing the bytes already on disk.
h := sha256.New()
if _, err := f.Seek(0, io.SeekStart); err == nil {
io.Copy(h, f) //nolint:errcheck
}
if _, err := f.Seek(0, io.SeekEnd); err != nil {
log.Printf("transfer: seek to end xid=%s: %v", xid[:8], err)
f.Close()
return
}
t.tmp = f
t.hasher = h
log.Printf("transfer: resuming inbound %s at byte %d xid=%s", t.name, t.resumeOffset, xid[:8])
} else {
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp") tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
if err != nil { if err != nil {
log.Printf("transfer: create temp file: %v", err) log.Printf("transfer: create temp file: %v", err)
return return
} }
t.mu.Lock()
t.tmp = tmp t.tmp = tmp
t.hasher = sha256.New() t.hasher = sha256.New()
t.mu.Unlock() t.metaPath = tmp.Name() + ".meta"
writePartialMeta(t.metaPath, t)
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8]) log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
}
// Signal sender that we are ready — it will not start streaming until // Signal sender that we are ready — it will not start streaming until
// it receives this, guaranteeing our OnMessage/OnClose are registered first. // it receives this, guaranteeing our OnMessage/OnClose are registered first.
dc.SendText("ok") //nolint:errcheck dc.SendText("ok") //nolint:errcheck
@@ -298,11 +404,12 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
dc.OnClose(func() { dc.OnClose(func() {
t.mu.Lock() t.mu.Lock()
tmp := t.tmp tmp := t.tmp
metaPath := t.metaPath
actualSha := "" actualSha := ""
if t.hasher != nil { if t.hasher != nil {
actualSha = hex.EncodeToString(t.hasher.Sum(nil)) actualSha = hex.EncodeToString(t.hasher.Sum(nil))
} }
name, expectedSha := t.name, t.sha256 name, expectedSha, size, written := t.name, t.sha256, t.size, t.written
t.mu.Unlock() t.mu.Unlock()
m.transferMu.Lock() m.transferMu.Lock()
@@ -312,10 +419,21 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
if tmp == nil { if tmp == nil {
return return
} }
tmpName := tmp.Name()
tmp.Close() tmp.Close()
// Incomplete — keep .tmp and .meta for future resume.
if written < size {
log.Printf("transfer: interrupted %s at %d/%d bytes xid=%s (resumable)", name, written, size, xid[:8])
return
}
// Full receive — verify integrity.
if actualSha != expectedSha { if actualSha != expectedSha {
os.Remove(tmp.Name()) os.Remove(tmpName)
if metaPath != "" {
os.Remove(metaPath)
}
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8]) log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
m.Emit(proto.IpcMessage{ m.Emit(proto.IpcMessage{
Type: proto.EvtError, Type: proto.EvtError,
@@ -325,13 +443,17 @@ func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from prot
return return
} }
// Success — remove sidecar and move to final path.
if metaPath != "" {
os.Remove(metaPath)
}
final := filepath.Join(m.DownloadDir, name) final := filepath.Join(m.DownloadDir, name)
if _, err := os.Stat(final); err == nil { if _, err := os.Stat(final); err == nil {
ext := filepath.Ext(name) ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext) base := strings.TrimSuffix(name, ext)
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext) final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
} }
if err := os.Rename(tmp.Name(), final); err != nil { if err := os.Rename(tmpName, final); err != nil {
log.Printf("transfer: rename xid=%s: %v", xid[:8], err) log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
return return
} }

View File

@@ -6,17 +6,26 @@ package netmgr
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath" "path/filepath"
"strconv"
"sync" "sync"
"time"
"github.com/pion/webrtc/v3"
"github.com/waste-go/internal/anchor" "github.com/waste-go/internal/anchor"
"github.com/waste-go/internal/crypto" "github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/mesh" "github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
"github.com/waste-go/internal/shares"
"github.com/waste-go/internal/store" "github.com/waste-go/internal/store"
) )
@@ -24,8 +33,11 @@ import (
type Config struct { type Config struct {
MasterIdentity *crypto.Identity MasterIdentity *crypto.Identity
StoreDir string // base directory for per-network SQLite files StoreDir string // base directory for per-network SQLite files
DownloadDir string // base directory for received files; defaults to StoreDir if empty
AnchorURL string // WebSocket anchor URL used for all networks AnchorURL string // WebSocket anchor URL used for all networks
ShareDir string // default share directory; overridden per network via Join or SetShareDir ShareDir string // default share directory; overridden per network via Join or SetShareDir
TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478"
TurnSecret string // shared secret for coturn use-auth-secret HMAC credential
} }
// Network is a single joined network context. // Network is a single joined network context.
@@ -54,13 +66,22 @@ type Manager struct {
subsMu sync.Mutex subsMu sync.Mutex
subs []chan proto.IpcMessage subs []chan proto.IpcMessage
Shares *shares.Store // persistent multi-share configuration
} }
// New creates a Manager from the given config. // New creates a Manager from the given config.
// Loads shares.json from StoreDir if it exists.
func New(cfg Config) *Manager { func New(cfg Config) *Manager {
sh, err := shares.Load(cfg.StoreDir)
if err != nil {
log.Printf("netmgr: loading shares.json: %v (starting empty)", err)
sh, _ = shares.Load("") // fallback to empty
}
return &Manager{ return &Manager{
networks: make(map[string]*Network), networks: make(map[string]*Network),
cfg: cfg, cfg: cfg,
Shares: sh,
} }
} }
@@ -99,7 +120,12 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
} else if mgr.cfg.ShareDir != "" { } else if mgr.cfg.ShareDir != "" {
m.ShareDir = mgr.cfg.ShareDir m.ShareDir = mgr.cfg.ShareDir
} }
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full) m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID_full)
capturedNetID := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
if ice := mgr.turnICEServers(); ice != nil {
m.ICEServers = ice
}
// Forward all mesh events to the Manager's fan-out, tagging with network_id. // Forward all mesh events to the Manager's fan-out, tagging with network_id.
meshEvents := m.Subscribe() meshEvents := m.Subscribe()
@@ -182,7 +208,12 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
} else if mgr.cfg.ShareDir != "" { } else if mgr.cfg.ShareDir != "" {
m.ShareDir = mgr.cfg.ShareDir m.ShareDir = mgr.cfg.ShareDir
} }
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID) m.DownloadDir = filepath.Join(mgr.downloadBase(), "downloads-"+netID)
capturedNetID2 := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
if ice := mgr.turnICEServers(); ice != nil {
m.ICEServers = ice
}
meshEvents := m.Subscribe() meshEvents := m.Subscribe()
go func() { go func() {
@@ -263,6 +294,28 @@ func (mgr *Manager) SetShareDir(netID, path string) bool {
return true return true
} }
// downloadBase returns the configured download base directory, falling back to StoreDir.
func (mgr *Manager) downloadBase() string {
if mgr.cfg.DownloadDir != "" {
return mgr.cfg.DownloadDir
}
return mgr.cfg.StoreDir
}
// SetDownloadDir updates the download directory for an already-joined network.
// Changes take effect for the next incoming file transfer on that network.
func (mgr *Manager) SetDownloadDir(netID, path string) bool {
mgr.mu.RLock()
net, ok := mgr.networks[netID]
mgr.mu.RUnlock()
if !ok {
return false
}
net.Mesh.DownloadDir = path
log.Printf("netmgr: download dir for %q set to %q", net.Name, path)
return true
}
// LeaveAll leaves every joined network. // LeaveAll leaves every joined network.
func (mgr *Manager) LeaveAll() { func (mgr *Manager) LeaveAll() {
mgr.mu.RLock() mgr.mu.RLock()
@@ -313,6 +366,50 @@ func (mgr *Manager) All() []*Network {
return out return out
} }
// ScanAllShares returns file entries from all share roots visible to networkID,
// combining the network's legacy ShareDir with entries from shares.json.
func (mgr *Manager) ScanAllShares(netID string) []proto.FileEntry {
var files []proto.FileEntry
// Legacy single-dir share from the network mesh.
if n := mgr.Resolve(netID); n != nil {
files = append(files, n.Mesh.ScanShareDir()...)
}
// Additional shares from shares.json.
if mgr.Shares != nil {
for _, sh := range mgr.Shares.ForNetwork(netID) {
files = append(files, scanDir(sh.Path)...)
}
}
return files
}
// scanDir recursively walks a directory and returns FileEntry for each file.
func scanDir(root string) []proto.FileEntry {
var files []proto.FileEntry
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = d.Name()
}
files = append(files, proto.FileEntry{
Name: d.Name(),
SizeBytes: info.Size(),
Path: rel,
})
return nil
})
return files
}
// AnchorURL returns the configured anchor URL. // AnchorURL returns the configured anchor URL.
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL } func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
@@ -352,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 ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func hashNetName(name string) string { func hashNetName(name string) string {

View File

@@ -77,6 +77,7 @@ type PeerMessage struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
SHA256 string `json:"sha256,omitempty"` SHA256 string `json:"sha256,omitempty"`
ResumeOffset int64 `json:"resume_offset,omitempty"` // waste-go ext: non-zero in file-accept when resuming
// file-done / file-cancel / file-accept just need xid (already above) // file-done / file-cancel / file-accept just need xid (already above)
Reason string `json:"reason,omitempty"` // file-cancel Reason string `json:"reason,omitempty"` // file-cancel
@@ -111,6 +112,13 @@ type GossipEntry struct {
type FileEntry struct { type FileEntry struct {
Name string `json:"name"` Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
Path string `json:"path,omitempty"` // relative path including filename
}
// ShareEntry describes one persistent share root.
type ShareEntry struct {
Path string `json:"path"`
Networks []string `json:"networks"` // ["*"] = global
} }
// FileListResp is the payload for MsgFileListResp. // FileListResp is the payload for MsgFileListResp.
@@ -132,12 +140,16 @@ type FileOffer struct {
// HelloMessage is the first message sent on the "yaw" DataChannel. // HelloMessage is the first message sent on the "yaw" DataChannel.
// The signature binds this identity to the specific DTLS session. // The signature binds this identity to the specific DTLS session.
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
// when RequireInvite is enabled on a network, peers that omit or present
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
type HelloMessage struct { type HelloMessage struct {
Type string `json:"type"` // always "hello" Type string `json:"type"` // always "hello"
ID string `json:"id"` // hex pubkey ID string `json:"id"` // hex pubkey
Nick string `json:"nick"` // alias Nick string `json:"nick"` // alias
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
} }
// HelloBindString returns the bytes the hello signature covers: // HelloBindString returns the bytes the hello signature covers:
@@ -222,12 +234,18 @@ const (
CmdGetFileList IpcMsgType = "get_file_list" CmdGetFileList IpcMsgType = "get_file_list"
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
CmdCreateRoom IpcMsgType = "create_room" // field: room (name)
CmdSetDownloadDir IpcMsgType = "set_download_dir" // set per-network download directory at runtime; fields: network_id, path
// Events (daemon → UI) // Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received" EvtMessageReceived IpcMsgType = "message_received"
EvtPeerConnected IpcMsgType = "peer_connected" EvtPeerConnected IpcMsgType = "peer_connected"
EvtPeerDisconnected IpcMsgType = "peer_disconnected" EvtPeerDisconnected IpcMsgType = "peer_disconnected"
EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified
EvtPeerStatus IpcMsgType = "peer_status" // ICE connection state + candidate type
EvtIncomingFile IpcMsgType = "incoming_file" EvtIncomingFile IpcMsgType = "incoming_file"
EvtFileProgress IpcMsgType = "file_progress" EvtFileProgress IpcMsgType = "file_progress"
EvtStateSnapshot IpcMsgType = "state_snapshot" EvtStateSnapshot IpcMsgType = "state_snapshot"
@@ -239,6 +257,8 @@ const (
EvtNetworkLeft IpcMsgType = "network_left" EvtNetworkLeft IpcMsgType = "network_left"
EvtIdentityExported IpcMsgType = "identity_exported" EvtIdentityExported IpcMsgType = "identity_exported"
EvtIdentityImported IpcMsgType = "identity_imported" 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. // NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -267,6 +287,8 @@ type IpcMessage struct {
NetworkName string `json:"network_name,omitempty"` NetworkName string `json:"network_name,omitempty"`
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
// send_file / set_share_dir / file_complete path // send_file / set_share_dir / file_complete path
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
@@ -289,9 +311,15 @@ type IpcMessage struct {
// multi-network: all joined networks (additive) // multi-network: all joined networks (additive)
Networks []NetworkInfo `json:"networks,omitempty"` Networks []NetworkInfo `json:"networks,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
InviteString string `json:"invite,omitempty"` InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"` Files []FileEntry `json:"files,omitempty"`
Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// export_identity / import_identity // export_identity / import_identity
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
Backup string `json:"backup,omitempty"` // JSON backup blob Backup string `json:"backup,omitempty"` // JSON backup blob
// peer_status — ICE connection quality
ConnState string `json:"conn_state,omitempty"` // pion PeerConnectionState string
CandidateType string `json:"candidate_type,omitempty"` // host | srflx | relay | unknown
RemoteAddress string `json:"remote_address,omitempty"` // remote IP:port of active candidate pair
} }

99
internal/shares/shares.go Normal file
View File

@@ -0,0 +1,99 @@
// Package shares manages the persistent multi-share configuration stored in shares.json.
// Shares are additive to the existing single-dir ShareDir mechanism.
package shares
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// Share describes one shared directory entry.
type Share struct {
Path string `json:"path"`
Networks []string `json:"networks"` // ["*"] = global (all networks); otherwise list of network IDs
}
// Store manages the shares.json file.
type Store struct {
mu sync.RWMutex
path string
shares []Share
}
// Load reads shares.json from dataDir. Returns an empty store if the file doesn't exist.
func Load(dataDir string) (*Store, error) {
s := &Store{path: filepath.Join(dataDir, "shares.json")}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &s.shares); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) save() error {
data, err := json.MarshalIndent(s.shares, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// All returns a copy of all shares.
func (s *Store) All() []Share {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Share, len(s.shares))
copy(out, s.shares)
return out
}
// ForNetwork returns all shares visible on networkID (global + network-specific).
func (s *Store) ForNetwork(networkID string) []Share {
s.mu.RLock()
defer s.mu.RUnlock()
var out []Share
for _, sh := range s.shares {
for _, n := range sh.Networks {
if n == "*" || n == networkID {
out = append(out, sh)
break
}
}
}
return out
}
// Add adds a share (no-op if path already present). Persists immediately.
func (s *Store) Add(sh Share) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, existing := range s.shares {
if existing.Path == sh.Path {
s.shares[i].Networks = sh.Networks
return s.save()
}
}
s.shares = append(s.shares, sh)
return s.save()
}
// Remove removes the share with the given path. Persists immediately.
func (s *Store) Remove(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, sh := range s.shares {
if sh.Path == path {
s.shares = append(s.shares[:i], s.shares[i+1:]...)
return s.save()
}
}
return nil
}

View File

@@ -28,6 +28,11 @@ CREATE TABLE IF NOT EXISTS peers (
alias TEXT NOT NULL, alias TEXT NOT NULL,
last_seen DATETIME NOT NULL last_seen DATETIME NOT NULL
); );
CREATE TABLE IF NOT EXISTS rooms (
name TEXT PRIMARY KEY,
created_at DATETIME NOT NULL
);
` `
// Store is a local SQLite-backed message and peer store. // 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() return msgs, rows.Err()
} }
// SaveRoom persists a room name. Duplicate names are silently ignored.
func (s *Store) SaveRoom(name string) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO rooms (name, created_at) VALUES (?, ?)`,
name, time.Now().UTC(),
)
return err
}
// Rooms returns all persisted room names, ordered by creation time.
func (s *Store) Rooms() ([]string, error) {
rows, err := s.db.Query(`SELECT name FROM rooms ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
// KnownPeers returns all peers seen since this daemon started storing data. // KnownPeers returns all peers seen since this daemon started storing data.
func (s *Store) KnownPeers() (map[proto.PeerID]string, error) { func (s *Store) KnownPeers() (map[proto.PeerID]string, error) {
rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`) rows, err := s.db.Query(`SELECT peer_id, alias FROM peers`)

View File

@@ -3,11 +3,20 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>web</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- Optional runtime config (anchor host puts config.js here; silently absent in dev) -->
<script src="/config.js" onerror="void 0"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

16
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -1883,6 +1884,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libsodium": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz",
"integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz",
"integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.8.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -7,9 +7,12 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"wails:dev": "wails dev",
"wails:build": "wails build"
}, },
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"

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

@@ -7,8 +7,7 @@
--accent: #7c6af7; --accent: #7c6af7;
--text: #e0e0e8; --text: #e0e0e8;
--muted: #6b6b80; --muted: #6b6b80;
--sidebar-w: 200px; --sidebar-w: 220px;
--peerlist-w: 180px;
} }
html, body, #root { height: 100%; } html, body, #root { height: 100%; }
@@ -16,7 +15,7 @@ body { background: var(--bg); color: var(--text); font-family: system-ui, sans-s
button { cursor: pointer; background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 13px; } button { cursor: pointer; background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 13px; }
button:disabled { opacity: 0.4; cursor: default; } button:disabled { opacity: 0.4; cursor: default; }
input { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 10px; font-size: 13px; outline: none; } input { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 10px; font-size: 13px; outline: none; width: 100%; }
input:focus { border-color: var(--accent); } input:focus { border-color: var(--accent); }
/* ── onboarding ── */ /* ── onboarding ── */
@@ -33,52 +32,129 @@ input:focus { border-color: var(--accent); }
.onboarding-code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; font-size: 12px; font-family: monospace; color: var(--text); width: 100%; text-align: left; } .onboarding-code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; font-size: 12px; font-family: monospace; color: var(--text); width: 100%; text-align: left; }
.onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; } .onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; }
.join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; } .join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.join-form input { width: 100%; }
.join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } .join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; } button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; }
.toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; } .toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; }
.toggle-link:hover { color: var(--text); } .toggle-link:hover { color: var(--text); }
.backup-panel { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .backup-panel { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.backup-form { display: flex; flex-direction: column; gap: 6px; } .backup-form { display: flex; flex-direction: column; gap: 6px; }
.backup-form textarea { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; font-size: 11px; resize: vertical; } .backup-form textarea { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; font-size: 11px; resize: vertical; width: 100%; }
.export-result { display: flex; flex-direction: column; gap: 6px; } .export-result { display: flex; flex-direction: column; gap: 6px; }
.export-result textarea { font-size: 10px; } .export-result textarea { font-size: 10px; }
.mono { font-family: monospace; } .mono { font-family: monospace; }
details summary { color: var(--muted); font-size: 12px; cursor: pointer; } details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
/* ── chat layout ── */ /* ── chat layout ── */
.chat-layout { display: grid; grid-template-columns: var(--sidebar-w) 1fr var(--peerlist-w); height: 100%; } .chat-layout { display: grid; grid-template-columns: var(--sidebar-w) 1fr; height: 100%; }
/* ── sidebar ── */ /* ── sidebar ── */
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; padding: 12px 8px; overflow-y: auto; } .sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-identity { padding: 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; } .sidebar-identity { padding: 12px 12px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; }
.sidebar-identity .alias { display: block; font-weight: 600; } .sidebar-identity .alias { display: block; font-weight: 600; font-size: 14px; }
.sidebar-identity .peer-id { display: block; color: var(--muted); font-size: 11px; font-family: monospace; } .sidebar-identity .peer-id { display: block; color: var(--muted); font-size: 10px; font-family: monospace; margin-top: 2px; }
.sidebar-section { display: flex; flex-direction: column; gap: 2px; margin-bottom: 16px; } .sidebar-logout { background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; flex-shrink: 0; margin-left: auto; }
.sidebar-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 4px 8px; } .sidebar-logout:hover { color: #e06060; background: none; }
.sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 8px; border-radius: 4px; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sidebar-section { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid var(--border); }
.sidebar-item:hover { background: var(--border); } .sidebar-section:last-child { border-bottom: none; flex: 1; }
.sidebar-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 4px 12px 2px; }
.sidebar-label-row { display: flex; align-items: center; justify-content: space-between; padding-right: 8px; }
.sidebar-label-row .sidebar-label { padding-right: 0; }
.sidebar-add { background: none; color: var(--muted); font-size: 14px; padding: 0 4px; line-height: 1; }
.sidebar-add:hover { color: var(--accent); background: none; }
.sidebar-new-room { padding: 2px 8px 4px; }
.sidebar-new-room input { font-size: 12px; padding: 4px 8px; }
.sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.sidebar-item:hover { background: rgba(255,255,255,0.04); }
.sidebar-item.active { background: var(--accent); color: #fff; } .sidebar-item.active { background: var(--accent); color: #fff; }
.sidebar-empty { font-size: 11px; color: var(--muted); padding: 4px 12px; }
/* peer rows in sidebar */
.sidebar-peers { gap: 0; }
.peer-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.peer-row { display: flex; align-items: center; gap: 6px; padding: 4px 12px; }
.peer-row-self { opacity: 0.7; }
.peer-row-you { font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; margin-left: 2px; flex-shrink: 0; }
.peer-row:hover { background: rgba(255,255,255,0.04); }
.peer-row:hover .peer-row-actions { opacity: 1; }
.peer-row-alias { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.peer-row-id { font-size: 10px; color: var(--muted); font-family: monospace; flex: 1; }
.peer-row-actions { opacity: 0; display: flex; gap: 2px; transition: opacity 0.1s; margin-left: auto; }
.peer-action { background: none; color: var(--muted); font-size: 14px; padding: 1px 3px; line-height: 1; }
.peer-action:hover { color: var(--accent); background: none; }
/* ── message pane ── */ /* ── message pane ── */
.message-pane { display: flex; flex-direction: column; height: 100%; } .message-pane { display: flex; flex-direction: column; height: 100%; min-width: 0; }
.message-pane-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; color: var(--muted); } .message-pane-header { padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; color: var(--text); }
.messages { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 6px; } .messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
.message { display: flex; gap: 8px; align-items: baseline; } .message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
.message:hover { background: rgba(255,255,255,0.02); }
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
.message.mine .message-alias { color: var(--accent); } .message.mine .message-alias { color: var(--accent); }
.message-alias { font-weight: 600; min-width: 80px; color: var(--muted); font-size: 12px; } .message-text { word-break: break-word; font-size: 14px; color: var(--text); }
.message-text { flex: 1; word-break: break-word; } .compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; } .compose input { flex: 1; width: auto; }
.compose { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); } .compose button { flex-shrink: 0; }
.compose input { flex: 1; }
/* ── peer list ── */ /* ── transfers panel (in sidebar) ── */
.peer-list { background: var(--surface); border-left: 1px solid var(--border); padding: 12px 8px; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; } .transfer-row { display: flex; flex-direction: column; gap: 3px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
.peer-entry { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px; border-radius: 4px; } .transfer-row:last-child { border-bottom: none; }
.peer-entry:hover { background: var(--border); } .transfer-name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.peer-entry-info .alias { display: block; font-weight: 600; font-size: 13px; } .transfer-meta { font-size: 10px; color: var(--muted); }
.peer-entry-info .peer-id { display: block; color: var(--muted); font-size: 11px; font-family: monospace; } .transfer-actions { display: flex; gap: 4px; margin-top: 2px; }
.peer-entry-actions { display: flex; gap: 4px; } .transfer-btn { font-size: 11px; padding: 2px 8px; }
.peer-entry-actions button { font-size: 11px; padding: 2px 6px; background: var(--border); color: var(--text); } .transfer-btn.accept { background: #3a7a3a; }
.peer-entry-actions button:hover { background: var(--accent); color: #fff; } .transfer-btn.reject { background: #7a3a3a; }
.empty { color: var(--muted); font-size: 12px; padding: 8px; } .transfer-btn:hover { opacity: 0.85; }
.transfer-progress { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 2px; }
.transfer-progress-bar { height: 100%; background: var(--accent); transition: width 0.1s; }
/* ── file browser panel ── */
.chat-layout.has-file-browser { grid-template-columns: var(--sidebar-w) 1fr 280px; }
.file-browser { display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--border); }
.file-browser-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.file-browser-title { font-size: 13px; font-weight: 600; }
.file-browser-close { background: none; color: var(--muted); font-size: 14px; padding: 2px 6px; }
.file-browser-close:hover { color: var(--text); background: none; }
.file-browser-empty { padding: 16px 12px; color: var(--muted); font-size: 13px; }
.file-list { list-style: none; padding: 4px 0; overflow-y: auto; flex: 1; }
.file-entry { display: flex; align-items: center; gap: 6px; padding: 5px 12px; }
.file-entry:hover { background: rgba(255,255,255,0.04); }
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
.file-entry-dl:hover { background: rgba(124,106,247,0.15); }
/* ── share manager ── */
.share-manager { width: 100%; display: flex; flex-direction: column; gap: 4px; }
.share-list { list-style: none; display: flex; flex-direction: column; gap: 2px; margin-bottom: 2px; }
.share-item { display: flex; align-items: center; gap: 4px; font-size: 12px; padding: 2px 0; }
.share-icon { flex-shrink: 0; font-size: 11px; }
.share-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.share-scope { font-size: 10px; color: var(--muted); flex-shrink: 0; }
.share-repick { background: none; color: var(--muted); font-size: 12px; padding: 0 3px; flex-shrink: 0; }
.share-repick:hover { color: var(--accent); background: none; }
.share-remove { background: none; color: var(--muted); font-size: 11px; padding: 0 3px; flex-shrink: 0; }
.share-remove:hover { color: #e06060; background: none; }
/* ── folder picker ── */
.folder-picker { width: 100%; display: flex; flex-direction: column; gap: 4px; }
.folder-picker-btn { background: var(--surface); color: var(--muted); border: 1px solid var(--border); font-size: 12px; padding: 5px 10px; border-radius: 4px; width: 100%; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-picker-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface); }
.folder-picker-subfolders { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); cursor: pointer; }
.folder-picker-subfolders input { width: auto; cursor: pointer; }
/* ── file browser toolbar / breadcrumb / sort ── */
.file-browser-toolbar { padding: 6px 8px 0; }
.file-browser-search { font-size: 12px; padding: 4px 8px; }
.file-browser-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; padding: 4px 8px; gap: 0; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--border); }
.breadcrumb-seg { background: none; color: var(--muted); font-size: 11px; padding: 0 2px; }
.breadcrumb-seg:hover { color: var(--accent); background: none; }
.breadcrumb-sep { color: var(--border); padding: 0 1px; }
.file-browser-sortbar { display: flex; gap: 4px; padding: 4px 8px; border-bottom: 1px solid var(--border); }
.sort-btn { background: none; color: var(--muted); font-size: 11px; padding: 1px 6px; border-radius: 3px; }
.sort-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }
.sort-btn.active { color: var(--accent); background: none; }
.file-entry-dir { cursor: pointer; }
.file-entry-dir:hover { background: rgba(255,255,255,0.04); }
.file-entry-icon { font-size: 12px; flex-shrink: 0; }

View File

@@ -4,15 +4,53 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat' import { Chat } from './pages/Chat'
import './App.css' import './App.css'
// Default to local daemon WS IPC. Override with VITE_DAEMON_WS env var. const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338' const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
// Use browser mode if a signal URL is configured, even on localhost
const useBrowser = !isLocal || !!cfg?.signalURL
// Wails runtime is injected at /wails/runtime.js when running inside the desktop app.
// EventsOn/EventsOff are no-ops in plain browser mode.
type WailsRuntime = { EventsOn: (event: string, cb: (data: unknown) => void) => void }
const wails = (window as unknown as { runtime?: WailsRuntime }).runtime
function useWailsNotifications() {
useEffect(() => {
if (!wails) return
const handler = (data: unknown) => {
const { title, body } = data as { title: string; body: string }
if (Notification.permission === 'granted') {
new Notification(title, { body })
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => {
if (p === 'granted') new Notification(title, { body })
})
}
}
wails.EventsOn('notify', handler)
return () => {
// EventsOff added in Wails v2.4; guard in case of older runtime.
;(window as unknown as { runtime?: { EventsOff?: (...e: string[]) => void } })
.runtime?.EventsOff?.('notify')
}
}, [])
}
export default function App() { export default function App() {
const { connect, daemonStatus, localPeer } = useWaste() const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => { useEffect(() => {
if (useBrowser) {
connectBrowser()
} else {
connect(DAEMON_WS) connect(DAEMON_WS)
}, [connect]) }
}, [connect, connectBrowser])
useWailsNotifications()
if (daemonStatus !== 'connected' || !localPeer) { if (daemonStatus !== 'connected' || !localPeer) {
return <Onboarding status={daemonStatus} /> return <Onboarding status={daemonStatus} />

972
web/src/adapter/browser.ts Normal file
View File

@@ -0,0 +1,972 @@
// BrowserAdapter — standalone in-browser waste client.
// Speaks the yaw/2.1 signaling + WebRTC protocol; emits IpcMessage events
// identical to DaemonAdapter so the store and UI need no changes.
//
// No daemon required. Identity is Ed25519, stored as hex seed in localStorage.
// Crypto via libsodium-wrappers.
import sodium from 'libsodium-wrappers'
import type { IpcMessage, PeerInfo } from '../types'
type Listener = (msg: IpcMessage) => void
type Status = 'disconnected' | 'connecting' | 'connected'
const BIND_PREFIX = 'yaw/2 bind'
const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302'
async function turnCredential(secret: string, username: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-1' },
false, ['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(username))
return btoa(String.fromCharCode(...new Uint8Array(sig)))
}
async function iceServers(): Promise<RTCIceServer[]> {
const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnSecret?: string } }).WASTE_CONFIG
const servers: RTCIceServer[] = [{ urls: STUN }]
if (cfg?.turnURL && cfg?.turnSecret) {
const user = Math.floor(Date.now() / 1000) + 3600 + ':waste'
const credential = await turnCredential(cfg.turnSecret, user)
servers.push({ urls: cfg.turnURL, username: user, credential })
}
return servers
}
const enc = (s: string) => new TextEncoder().encode(s)
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
for (const a of arrs) { out.set(a, o); o += a.length }
return out
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
async function netHash(name: string): Promise<string> {
const input = enc('yaw2-net:' + name)
// Use native SHA-256 for protocol-compatible network IDs.
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
return toHex(new Uint8Array(digest))
}
// Fallback for environments where subtle crypto is unavailable.
const sha256 = (sodium as unknown as { crypto_hash_sha256?: (m: Uint8Array) => Uint8Array }).crypto_hash_sha256
if (sha256) return sodium.to_hex(sha256(input))
throw new Error('SHA-256 not available in this browser runtime')
}
// ── Identity ─────────────────────────────────────────────────────────────────
class Identity {
pub: Uint8Array
priv: Uint8Array
id: string
curvePriv: Uint8Array
nick: string
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
this.pub = kp.publicKey
this.priv = kp.privateKey
this.id = sodium.to_hex(this.pub)
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
this.nick = localStorage.getItem('waste_nick') || ''
}
static load(): Identity {
const seedHex = localStorage.getItem('waste_seed')
let kp
if (seedHex) {
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
} else {
kp = sodium.crypto_sign_keypair()
localStorage.setItem('waste_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
}
return new Identity(kp)
}
setNick(nick: string) {
this.nick = nick.trim().slice(0, 40)
localStorage.setItem('waste_nick', this.nick)
}
sign(data: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(data, this.priv)
}
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
catch { return false }
}
seal(recipIdHex: string, plaintext: Uint8Array): string {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
}
open(senderIdHex: string, boxB64: string): Uint8Array | null {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
catch { return null }
}
exportBackup(passphrase: string): object {
const BK_OPS = 2, BK_MEM = 67108864
const b64 = (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL)
const seed = this.priv.slice(0, 32)
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, salt,
BK_OPS, BK_MEM, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const ct = sodium.crypto_secretbox_easy(seed, nonce, key)
return {
yaw: 'yaw-key-backup-1', id: this.id, alg: 'argon2id-secretbox',
ops: BK_OPS, mem: BK_MEM, salt: b64(salt), nonce: b64(nonce), ct: b64(ct)
}
}
static importBackup(b: Record<string, unknown>, passphrase: string): Identity {
if (!b || b['yaw'] !== 'yaw-key-backup-1') throw new Error('not a yaw key backup')
const ub = (s: unknown) => sodium.from_base64(s as string, sodium.base64_variants.ORIGINAL)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, ub(b['salt']),
b['ops'] as number | 0, b['mem'] as number | 0, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const seed = sodium.crypto_secretbox_open_easy(ub(b['ct']), ub(b['nonce']), key)
localStorage.setItem('waste_seed', sodium.to_hex(seed))
return new Identity(sodium.crypto_sign_seed_keypair(seed))
}
get short(): string {
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
}
toPeerInfo(networkId = ''): PeerInfo {
return {
id: this.id, alias: this.nick || this.short, public_key: this.id,
created_at: new Date().toISOString(), network_id: networkId
} as PeerInfo & { network_id?: string }
}
}
// ── Signaling ─────────────────────────────────────────────────────────────────
class Signaling {
private ws: WebSocket | null = null
private _closed = false
private _backoff = 1000
private _cbs: {
onFrom?: (from: string, box: string) => void
onJoin?: (id: string) => void
onLeave?: (id: string) => void
onReconnect?: (peers: string[]) => void
} = {}
private url: string
private identity: Identity
private net: string
constructor(url: string, identity: Identity, net: string) {
this.url = url
this.identity = identity
this.net = net
}
connect(
onFrom: (from: string, box: string) => void,
onJoin: (id: string) => void,
onLeave: (id: string) => void,
onReconnect: (peers: string[]) => void,
): Promise<string[]> {
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
return this._open(true)
}
private _open(initial: boolean): Promise<string[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.url)
this.ws = ws
let joined = false
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
ws.onmessage = (ev) => {
let m: Record<string, unknown>
try { m = JSON.parse(ev.data) } catch { return }
if (m['type'] === 'challenge') {
const nonce = sodium.from_hex(m['nonce'] as string)
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
} else if (m['type'] === 'joined') {
joined = true; this._backoff = 1000
const peers = (m['peers'] as string[]) || []
if (initial) resolve(peers)
else this._cbs.onReconnect?.(peers)
} else if (m['type'] === 'from') {
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
} else if (m['type'] === 'peer-join') {
this._cbs.onJoin?.(m['id'] as string)
} else if (m['type'] === 'peer-leave') {
this._cbs.onLeave?.(m['id'] as string)
}
}
})
}
private _scheduleReconnect() {
if (this._closed) return
const delay = this._backoff
this._backoff = Math.min(this._backoff * 2, 30000)
setTimeout(() => { if (!this._closed) this._open(false).catch(() => { }) }, delay)
}
sendTo(toId: string, box: string) {
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { }
}
close() { this._closed = true; this.ws?.close() }
}
// ── Peer connection (yaw/2.1) ─────────────────────────────────────────────────
type PeerCallback = (event: string, data: Record<string, unknown>) => void
class PeerConn {
pc: RTCPeerConnection
dc: RTCDataChannel | null = null
verified = false
peerAuthed = false
created = Date.now()
private _esk: Uint8Array | null
private _epk: Uint8Array | null
peer_epk: Uint8Array | null = null
private _ekeySent = false
private _offerPending = false
private _offered = false
private identity: Identity
private sig: Signaling
public peerId: string
private on: PeerCallback
private nick: string
share: Map<string, File>
// in-flight receives: xid → state
private _recv: Record<string, { name: string; size: number; sha: string; buf: ArrayBuffer[]; have: number; done: boolean; cancelled?: boolean }> = {}
// open receive DataChannels for cancellation
private _recvChannels: Record<string, RTCDataChannel> = {}
// in-flight sends: xid → marker
private _send: Record<string, Uint8Array> = {}
// push-initiated files waiting for file-accept: xid → File
private _pushQueue: Map<string, File> = new Map()
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string, share: Map<string, File>, ice: RTCIceServer[]) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.on = on
this.nick = nick
this.share = share
this.pc = new RTCPeerConnection({ iceServers: ice })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
this._epk = kp.publicKey
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
this.pc.onconnectionstatechange = () => {
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
if (this.pc.connectionState === 'connected') this.reportStats()
}
}
private _seal(obj: object, preferEph: boolean): [string, boolean] {
const data = enc(JSON.stringify(obj))
if (preferEph && this.peer_epk && this._esk) {
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
return [sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL), true]
}
return [this.identity.seal(this.peerId, data), false]
}
private _open(box: string): [Uint8Array | null, boolean] {
if (this.peer_epk && this._esk) {
try {
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
} catch { }
}
return [this.identity.open(this.peerId, box), false]
}
private _sendEkey() {
if (this._ekeySent || !this._epk) return
this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id),
sodium.from_hex(this.peerId), this._epk)
const msg = {
kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk),
sig: sodium.to_hex(this.identity.sign(signed))
}
const [box] = this._seal(msg, false)
this.sig.sendTo(this.peerId, box)
}
private async _onEkey(obj: Record<string, unknown>) {
if (this.peer_epk) return
try {
const epkRaw = sodium.from_hex(obj['epk'] as string)
const sig = sodium.from_hex(obj['sig'] as string)
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId),
sodium.from_hex(this.identity.id), epkRaw)
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sig)) return
this.peer_epk = epkRaw
} catch { return }
this._sendEkey()
if (this._offerPending) await this._doOffer()
}
async startOffer() {
this._sendEkey()
this._offerPending = true
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
if (this.peer_epk) await this._doOffer()
}
private async _doOffer() {
if (this._offered) return
this._offered = true; this._offerPending = false
this.dc = this.pc.createDataChannel('yaw')
this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc)
const [box] = this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)
this.sig.sendTo(this.peerId, box)
}
async onBox(box: string) {
const [plain, usedEph] = this._open(box)
if (!plain) return
this.peerAuthed = true
let obj: Record<string, unknown>
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
if (obj['kind'] === 'ekey') {
await this._onEkey(obj)
} else if (obj['kind'] === 'offer') {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc)
const [box2] = this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)
this.sig.sendTo(this.peerId, box2)
} else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
}
}
private _wire(channel: RTCDataChannel) {
if (channel.label === 'yaw') {
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
return
}
if (channel.label.startsWith('f:')) {
const xid = channel.label.slice(2)
const rx = this._recv[xid]
if (!rx) return
channel.binaryType = 'arraybuffer'
channel.onmessage = (ev) => {
if (!(ev.data instanceof ArrayBuffer)) return
rx.buf.push(ev.data)
rx.have += ev.data.byteLength
this.on('file_progress', { peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
}
channel.onclose = async () => {
if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.on('file_recv', { peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
}
this._recvChannels[xid] = channel
}
}
private _sendHello() {
const bind = enc(BIND_PREFIX)
const caps: string[] = []
this._dc({
type: 'hello', id: this.identity.id, nick: this.nick, caps,
sig: sodium.to_hex(this.identity.sign(bind))
})
}
private _onControl(data: string) {
let m: Record<string, unknown>
try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') {
let reason = ''
if (m['id'] !== this.peerId) reason = 'id mismatch'
else if (!this.peerAuthed) reason = 'unauthenticated signaling'
this.verified = !reason
this.on('connected', {
peer: this.peerId, verified: this.verified,
nick: m['nick'] as string || '', caps: m['caps'] || []
})
} else if (m['type'] === 'chat') {
this.on('chat', {
peer: this.peerId, room: m['room'] as string || 'general',
text: m['text'] as string, ts: m['ts'] as number || Date.now()
})
} else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
} else if (m['type'] === 'browse') {
const files = Array.from(this.share.entries()).map(([path, f]) => ({
name: f.name, path, size: f.size, mime: f.type
}))
this._dc({ type: 'files', files })
} else if (m['type'] === 'files') {
const files = m['files'] as Array<{ name: string; path?: string; size: number; mime?: string }>
this.on('files', { peer: this.peerId, files })
} else if (m['type'] === 'get') {
const path = m['path'] as string
const file = this.share.get(path)
if (!file) return
this._dc({ type: 'file-offer', name: file.name, path, size: file.size, xid: path })
} else if (m['type'] === 'file-offer') {
const name = m['name'] as string
const size = m['size'] as number
const xid = m['xid'] as string
this.on('file_offer', { peer: this.peerId, name, size, xid })
} else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string
// xid is either a push UUID or a share path
if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid)
} else if (m['type'] === 'file-done') {
const xid = m['xid'] as string
if (this._recv[xid]) this._recv[xid].done = true
} else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string
if (this._recv[xid]) {
this._recv[xid].cancelled = true
this._recvChannels[xid]?.close()
delete this._recvChannels[xid]
this.on('file_cancelled', { peer: this.peerId, xid })
}
}
}
requestBrowse() {
this._dc({ type: 'browse', path: '/' })
}
requestGet(path: string) {
this._dc({ type: 'get', path })
}
acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, sha: '', buf: [], have: 0, done: false }
this._dc({ type: 'file-accept', xid })
}
rejectOffer(xid: string) {
this._dc({ type: 'file-cancel', xid })
}
cancelRecv(xid: string) {
if (this._recv[xid]) this._recv[xid].cancelled = true
this._recvChannels[xid]?.close()
delete this._recvChannels[xid]
this._dc({ type: 'file-cancel', xid })
}
cancelSend(xid: string) {
this._pushQueue.delete(xid)
delete this._send[xid]
this._dc({ type: 'file-cancel', xid })
}
sendFilePush(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
}
private async _stream(xid: string) {
const file = this._pushQueue.get(xid) ?? this.share.get(xid)
if (!file) return
this._pushQueue.delete(xid)
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
this._send[xid] = new Uint8Array(0) // marker
await new Promise<void>(res => { dc.onopen = () => res() })
const CHUNK = 64 * 1024
const buf = await file.arrayBuffer()
let offset = 0
while (offset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) {
await new Promise(r => setTimeout(r, 10))
}
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
}
dc.close()
delete this._send[xid]
this._dc({ type: 'file-done', xid })
}
async reportStats() {
try {
const stats = await this.pc.getStats()
let candidateType: string = 'unknown'
let remoteAddress = ''
stats.forEach((s) => {
if (s.type === 'candidate-pair' && (s as RTCIceCandidatePairStats).state === 'succeeded') {
const pair = s as RTCIceCandidatePairStats & { nominated?: boolean }
if (pair.nominated) {
stats.forEach((c) => {
if (c.type === 'remote-candidate' && c.id === (pair as unknown as Record<string,string>)['remoteCandidateId']) {
candidateType = (c as unknown as Record<string,string>)['candidateType'] || 'unknown'
remoteAddress = (c as unknown as Record<string,string>)['address'] || ''
}
})
}
}
})
this.on('ice_stats', { peer: this.peerId, candidateType, remoteAddress })
} catch { /* ignore */ }
}
sendChat(room: string, text: string) {
this._dc({ type: 'chat', room, text, ts: Date.now() })
}
sendPm(text: string) {
this._dc({ type: 'pm', text, ts: Date.now() })
}
private _dc(obj: object) {
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
}
close() { try { this.pc.close() } catch { } }
}
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve()
return new Promise((res) => {
const check = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', check)
res()
}
}
pc.addEventListener('icegatheringstatechange', check)
setTimeout(res, 6000)
})
}
// ── BrowserAdapter ────────────────────────────────────────────────────────────
export class BrowserAdapter {
private listeners: Listener[] = []
private identity: Identity | null = null
private sig: Signaling | null = null
private peers: Map<string, PeerConn> = new Map()
private present: Set<string> = new Set()
private networkId = ''
private networkName = ''
sharedFiles: Map<string, File> = new Map()
status: Status = 'disconnected'
onStatusChange?: (s: Status) => void
private setStatus(s: Status) {
this.status = s
this.onStatusChange?.(s)
}
private emit(msg: IpcMessage) {
this.listeners.forEach(l => l(msg))
}
on(listener: Listener) {
this.listeners.push(listener)
return () => { this.listeners = this.listeners.filter(l => l !== listener) }
}
// Call after connect() to set/update the nick shown to peers
setNick(nick: string) {
this.identity?.setNick(nick)
if (this.identity) {
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: this.networkId ? [{
network_id: this.networkId,
network_name: this.networkName,
local_peer: this.identity.toPeerInfo(this.networkId),
}] : [],
})
}
}
async connect() {
await sodium.ready
this.identity = Identity.load()
this.setStatus('connecting')
// Emit identity immediately — UI can show alias before network join
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: [],
})
// Mark connected — onboarding shows join form
this.setStatus('connected')
}
disconnect() {
this.sig?.close()
this.sig = null
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
this.networkId = ''
this.networkName = ''
this.setStatus('disconnected')
}
async joinNetwork(anchorUrl: string, nameOrHash: string, isHash = false) {
if (!this.identity) return
const hash = isHash ? nameOrHash : await netHash(nameOrHash)
this.networkId = hash.slice(0, 16)
this.networkName = isHash ? hash.slice(0, 16) : nameOrHash
this.sig?.close()
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
const sig = new Signaling(anchorUrl, this.identity, hash)
this.sig = sig
try {
const present = await sig.connect(
(from, box) => this._onFrom(from, box),
(pid) => { this.present.add(pid); this._tryConnect(pid) },
(pid) => {
this.present.delete(pid)
this.peers.get(pid)?.close()
this.peers.delete(pid)
this.emit({ type: 'peer_disconnected', peer_id: pid as unknown as import('../types').PeerID })
},
(peers) => {
this.present = new Set(peers)
peers.forEach(pid => this._tryConnect(pid))
},
)
for (const pid of present) {
this.present.add(pid)
await this._tryConnect(pid)
}
const localPeer = this.identity.toPeerInfo(this.networkId)
this.emit({
type: 'network_joined',
network_id: this.networkId,
network_name: this.networkName,
local_peer: localPeer,
networks: [{ network_id: this.networkId, network_name: this.networkName, local_peer: localPeer }],
})
setInterval(() => { this.present.forEach(pid => this._tryConnect(pid)) }, 4000)
} catch (err) {
this.emit({ type: 'error', error_message: `signaling: ${err}` })
}
}
private async _tryConnect(pid: string) {
if (!this.identity || pid === this.identity.id) return
if (this.identity.id >= pid) return // lower ID answers; higher ID offers
const existing = this.peers.get(pid)
if (existing) {
const open = existing.dc?.readyState === 'open'
const state = existing.pc.connectionState
const alive = state !== 'failed' && state !== 'closed'
const fresh = Date.now() - existing.created < 12000
if (existing.verified || open || (alive && fresh)) return
}
const ice = await iceServers()
const peer = new PeerConn(this.identity, this.sig!, pid,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
this.peers.set(pid, peer)
this._emitDiscovered(pid)
await peer.startOffer()
}
private async _onFrom(from: string, box: string) {
if (!this.identity || from === this.identity.id) return
let peer = this.peers.get(from)
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
const ice = await iceServers()
peer = new PeerConn(this.identity, this.sig!, from,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
this._emitDiscovered(from)
this.peers.set(from, peer)
}
await peer.onBox(box)
}
private _onPeerEvent(event: string, data: Record<string, unknown>) {
if (event === 'connected') {
const nick = (data['nick'] as string) || (data['peer'] as string).slice(0, 16)
this.emit({
type: 'peer_connected',
peer: {
id: data['peer'] as string, alias: nick || (data['peer'] as string).slice(0, 8),
public_key: data['peer'] as string, created_at: new Date().toISOString()
},
})
} else if (event === 'chat') {
const ts = (data['ts'] as number) || Date.now()
const mid = `${data['peer']}-${ts}`
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid,
from: data['peer'] as unknown as import('../types').PeerID,
room: (data['room'] as string) || 'general',
text: data['text'] as string,
ts,
},
})
} else if (event === 'pm') {
const ts = (data['ts'] as number) || Date.now()
const from = data['peer'] as string
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid: `${from}-${ts}`,
from: from as unknown as import('../types').PeerID,
room: `dm:${from.slice(0, 8)}`,
text: data['text'] as string,
ts,
},
})
} else if (event === 'status') {
this.emit({
type: 'peer_status',
peer_id: data['peer'] as unknown as import('../types').PeerID,
conn_state: data['state'] as import('../types').PeerConnState,
})
} else if (event === 'ice_stats') {
this.emit({
type: 'peer_status',
peer_id: data['peer'] as unknown as import('../types').PeerID,
candidate_type: data['candidateType'] as import('../types').CandidateType,
remote_address: data['remoteAddress'] as string,
})
} else if (event === 'file_offer') {
this.emit({
type: 'incoming_file',
peer_id: data['peer'] as string,
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
})
} else if (event === 'file_progress') {
this.emit({
type: 'file_progress',
peer_id: data['peer'] as string,
transfer_id: data['xid'] as string,
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['total'] as number, sha256: '' },
bytes_received: data['received'] as number,
total_bytes: data['total'] as number,
})
} else if (event === 'file_cancelled') {
this.emit({
type: 'error',
peer_id: data['peer'] as string,
error_message: `transfer ${(data['xid'] as string).slice(0, 8)} cancelled`,
})
} else if (event === 'files') {
const raw = data['files'] as Array<{ name: string; path?: string; size: number; mime?: string }>
this.emit({
type: 'file_list',
peer_id: data['peer'] as string,
files: raw.map(f => ({ name: f.name, path: f.path ?? f.name, size_bytes: f.size })),
})
} else if (event === 'file_recv') {
this.emit({
type: 'file_complete',
peer_id: data['peer'] as string,
path: data['url'] as string,
offer: { xid: data['name'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
})
}
}
private _emitDiscovered(pid: string) {
this.emit({
type: 'peer_connected',
peer: { id: pid, alias: pid.slice(0, 8), public_key: pid, created_at: new Date().toISOString() },
})
this.emit({
type: 'peer_status',
peer_id: pid as unknown as import('../types').PeerID,
conn_state: 'connecting',
})
}
setSharedFiles(files: Map<string, File>) {
this.sharedFiles = files
this.peers.forEach(p => { p.share = files })
}
requestBrowse(peerId: string) {
this.peers.get(peerId)?.requestBrowse()
}
requestGet(peerId: string, path: string) {
this.peers.get(peerId)?.requestGet(path)
}
sendFileTo(peerId: string, file: File) {
this.peers.get(peerId)?.sendFilePush(file)
}
acceptOffer(peerId: string, xid: string, name: string, size: number) {
this.peers.get(peerId)?.acceptOffer(xid, name, size)
}
rejectOffer(peerId: string, xid: string) {
this.peers.get(peerId)?.rejectOffer(xid)
}
cancelTransfer(peerId: string, xid: string, direction: 'recv' | 'send') {
if (direction === 'recv') this.peers.get(peerId)?.cancelRecv(xid)
else this.peers.get(peerId)?.cancelSend(xid)
}
send(msg: IpcMessage) {
if (!this.identity) return
if (msg.type === 'join_network') {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
const anchorUrl = cfg?.signalURL
|| localStorage.getItem('waste_anchor_url')
|| ''
const isHash = !msg.network_name && !!msg.network_hash
const nameOrHash = msg.network_name || msg.network_hash || ''
if (nameOrHash) this.joinNetwork(anchorUrl, nameOrHash, isHash)
return
}
if (msg.type === 'send_message') {
const room = msg.room || 'general'
const text = msg.body || ''
if (!text) return
const ts = Date.now()
const mid = `local-${ts}`
if (msg.to) {
// DM
const pid = msg.to as string
this.peers.get(pid)?.sendPm(text)
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts
},
})
} else {
// Broadcast
this.peers.forEach(p => p.sendChat(room, text))
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
room, text, ts
},
})
}
return
}
if (msg.type === 'export_identity') {
if (!msg.passphrase) return
try {
const backup = this.identity.exportBackup(msg.passphrase)
this.emit({ type: 'identity_exported', backup: JSON.stringify(backup, null, 2) })
} catch (err) {
this.emit({ type: 'error', error_message: `export_identity: ${err}` })
}
return
}
if (msg.type === 'import_identity') {
if (!msg.passphrase || !msg.backup) return
try {
const parsed = JSON.parse(msg.backup)
const newId = Identity.importBackup(parsed, msg.passphrase)
this.identity = newId
this.emit({ type: 'identity_imported' })
this.emit({
type: 'state_snapshot',
master_alias: newId.nick || newId.short,
master_id: newId.id,
rooms: ['general'],
networks: [],
})
} catch (err) {
this.emit({ type: 'error', error_message: `import_identity: ${err}` })
}
return
}
if (msg.type === 'generate_invite') {
const anchor = localStorage.getItem('waste_anchor_url') || 'wss://waste.dev.xplwd.com/ws'
const name = this.networkName
if (!name || name === this.networkId) {
this.emit({ type: 'error', error_message: 'generate_invite: joined by hash — no network name available' })
return
}
const h = netHash(name)
const payload = btoa(JSON.stringify({ anchor, network: name, net: h }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
this.emit({ type: 'invite_generated', invite: `waste:${payload}`, network_id: this.networkId })
return
}
}
}

View File

@@ -0,0 +1,192 @@
import { useState, useMemo } from 'react'
import { useWaste } from '../store'
import { BrowserAdapter } from '../adapter/browser'
import type { FileEntry } from '../types'
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
type SortKey = 'name' | 'size'
type SortDir = 'asc' | 'desc'
interface DirEntry {
kind: 'dir'
name: string
path: string
count: number
}
interface FileRow {
kind: 'file'
name: string
path: string
size: number
}
type Row = DirEntry | FileRow
function buildRows(files: FileEntry[], cwd: string, search: string, sort: SortKey, sortDir: SortDir): Row[] {
// Filter to entries under cwd
const prefix = cwd ? cwd + '/' : ''
const inDir = files.filter(f => {
const p = f.path ?? f.name
return p.startsWith(prefix)
})
if (search.trim()) {
// Flat search across all files under cwd
const q = search.toLowerCase()
return inDir
.filter(f => f.name.toLowerCase().includes(q))
.map(f => ({ kind: 'file' as const, name: f.name, path: f.path ?? f.name, size: f.size_bytes }))
.sort((a, b) => {
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
return sortDir === 'asc' ? cmp : -cmp
})
}
// Build immediate children: dirs and files at this level
const dirs = new Map<string, number>() // dirName → file count
const fileRows: FileRow[] = []
for (const f of inDir) {
const rel = (f.path ?? f.name).slice(prefix.length)
const slash = rel.indexOf('/')
if (slash === -1) {
fileRows.push({ kind: 'file', name: f.name, path: f.path ?? f.name, size: f.size_bytes })
} else {
const dirName = rel.slice(0, slash)
dirs.set(dirName, (dirs.get(dirName) ?? 0) + 1)
}
}
const dirRows: DirEntry[] = Array.from(dirs.entries()).map(([name, count]) => ({
kind: 'dir', name, path: prefix + name, count,
}))
// Sort each group separately, then dirs first
const cmpFiles = (a: FileRow, b: FileRow) => {
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
return sortDir === 'asc' ? cmp : -cmp
}
dirRows.sort((a, b) => a.name.localeCompare(b.name))
fileRows.sort(cmpFiles)
return [...dirRows, ...fileRows]
}
export function FileBrowser() {
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
const [cwd, setCwd] = useState('')
const [search, setSearch] = useState('')
const [sort, setSort] = useState<SortKey>('name')
const [sortDir, setSortDir] = useState<SortDir>('asc')
if (!activeFilePeer) return null
const peer = connectedPeers.find(p => p.id === activeFilePeer)
const files = fileLists[activeFilePeer] ?? null
function download(path: string) {
if (adapter instanceof BrowserAdapter) {
adapter.requestGet(activeFilePeer!, path)
}
}
function toggleSort(key: SortKey) {
if (sort === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSort(key); setSortDir('asc') }
}
function navigateUp() {
const parts = cwd.split('/')
parts.pop()
setCwd(parts.join('/'))
}
const rows = useMemo(() => {
if (!files) return []
return buildRows(files, cwd, search, sort, sortDir)
}, [files, cwd, search, sort, sortDir])
const cwdParts = cwd ? cwd.split('/') : []
return (
<div className="file-browser">
<div className="file-browser-header">
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}></button>
</div>
{files === null ? (
<div className="file-browser-empty">Loading</div>
) : files.length === 0 ? (
<div className="file-browser-empty">No files shared</div>
) : (
<>
<div className="file-browser-toolbar">
<input
className="file-browser-search"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="search…"
/>
</div>
{/* Breadcrumb */}
<div className="file-browser-breadcrumb">
<button onClick={() => setCwd('')} className="breadcrumb-seg">~</button>
{cwdParts.map((seg, i) => (
<span key={i}>
<span className="breadcrumb-sep">/</span>
<button
className="breadcrumb-seg"
onClick={() => setCwd(cwdParts.slice(0, i + 1).join('/'))}
>{seg}</button>
</span>
))}
</div>
{/* Sort bar */}
<div className="file-browser-sortbar">
<button className={`sort-btn ${sort === 'name' ? 'active' : ''}`} onClick={() => toggleSort('name')}>
name {sort === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</button>
<button className={`sort-btn ${sort === 'size' ? 'active' : ''}`} onClick={() => toggleSort('size')}>
size {sort === 'size' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</button>
</div>
<ul className="file-list">
{cwd && !search && (
<li className="file-entry file-entry-dir" onClick={navigateUp}>
<span className="file-entry-icon">📁</span>
<span className="file-entry-name">..</span>
</li>
)}
{rows.length === 0 && (
<li className="file-browser-empty">no results</li>
)}
{rows.map(row => row.kind === 'dir' ? (
<li key={row.path} className="file-entry file-entry-dir" onClick={() => { setCwd(row.path); setSearch('') }}>
<span className="file-entry-icon">📁</span>
<span className="file-entry-name">{row.name}</span>
<span className="file-entry-size">{row.count} file{row.count !== 1 ? 's' : ''}</span>
</li>
) : (
<li key={row.path} className="file-entry">
<span className="file-entry-icon">📄</span>
<span className="file-entry-name">{row.name}</span>
<span className="file-entry-size">{fmt(row.size)}</span>
<button className="file-entry-dl" onClick={() => download(row.path)}></button>
</li>
))}
</ul>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useRef, useState } from 'react'
import { useWaste } from '../store'
export function FolderPicker() {
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
const inputRef = useRef<HTMLInputElement>(null)
const [includeSubfolders, setIncludeSubfolders] = useState(true)
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
const label = current && current.size > 0
? `${current.size} file${current.size !== 1 ? 's' : ''} shared`
: null
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const fileList = e.target.files
if (!fileList || fileList.length === 0) return
const map = new Map<string, File>()
for (let i = 0; i < fileList.length; i++) {
const f = fileList[i]
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath
// Strip the root folder name prefix (first path segment) so paths are relative to the picked folder
const path = rel ? rel.split('/').slice(1).join('/') : f.name
if (!includeSubfolders && path.includes('/')) continue
map.set(path, f)
}
setSharedFiles(map)
e.target.value = ''
}
return (
<div className="folder-picker">
<input
ref={inputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard
webkitdirectory=""
multiple
style={{ display: 'none' }}
onChange={onChange}
/>
<button
className="folder-picker-btn"
onClick={() => inputRef.current?.click()}
title="Share a folder with peers on this network"
>
{label ?? '+ Share folder'}
</button>
<label className="folder-picker-subfolders">
<input
type="checkbox"
checked={includeSubfolders}
onChange={e => setIncludeSubfolders(e.target.checked)}
/>
include subfolders
</label>
</div>
)
}

View File

@@ -17,7 +17,6 @@ export function MessagePane() {
if (!text || !activeNetworkId) return if (!text || !activeNetworkId) return
if (activeRoom.startsWith('dm:')) { if (activeRoom.startsWith('dm:')) {
// Find peer ID from room name — stored as full ID in room key after "dm:"
const toPeerID = activeRoom.slice(3) const toPeerID = activeRoom.slice(3)
const peer = connectedPeers.find(p => p.id.startsWith(toPeerID) || p.id === toPeerID) const peer = connectedPeers.find(p => p.id.startsWith(toPeerID) || p.id === toPeerID)
if (peer) { if (peer) {
@@ -34,20 +33,24 @@ export function MessagePane() {
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8) return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
} }
const roomLabel = activeRoom.startsWith('dm:')
? `@ ${activeRoom.slice(3, 11)}`
: `# ${activeRoom}`
return ( return (
<main className="message-pane"> <main className="message-pane">
<div className="message-pane-header"> <div className="message-pane-header">{roomLabel}</div>
{activeRoom.startsWith('dm:') ? `@ ${activeRoom.slice(3, 11)}` : `# ${activeRoom}`}
</div>
<div className="messages"> <div className="messages">
{roomMessages.map((msg, i) => { {roomMessages.map((msg, i) => {
const mine = msg.from === localPeer?.id const mine = msg.from === localPeer?.id
const alias = aliasFor(msg.from)
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
return ( return (
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}> <div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
<span className="message-alias">{aliasFor(msg.from)}</span> <span className="message-ts">{time}</span>
<span className="message-alias">{alias}</span>
<span className="message-text">{msg.text}</span> <span className="message-text">{msg.text}</span>
<span className="message-ts">{new Date(msg.ts).toLocaleTimeString()}</span>
</div> </div>
) )
})} })}
@@ -58,7 +61,7 @@ export function MessagePane() {
<input <input
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => setDraft(e.target.value)}
placeholder={`Message ${activeRoom}`} placeholder={`Message ${roomLabel}`}
autoComplete="off" autoComplete="off"
/> />
<button type="submit" disabled={!draft.trim()}>Send</button> <button type="submit" disabled={!draft.trim()}>Send</button>

View File

@@ -0,0 +1,118 @@
import { useState, useRef } from 'react'
import { useWaste } from '../store'
interface ShareRecord {
name: string // display name (folder name picked by user)
global: boolean // true = all networks
networkId?: string
}
const STORAGE_KEY = 'waste_shares'
function loadShares(): ShareRecord[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
} catch {
return []
}
}
function saveShares(shares: ShareRecord[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(shares))
}
export function ShareManager() {
const { activeNetworkId, setSharedFiles, sharedFilesByNetwork } = useWaste()
const [shares, setShares] = useState<ShareRecord[]>(loadShares)
const [includeSubfolders, setIncludeSubfolders] = useState(true)
const inputRef = useRef<HTMLInputElement>(null)
// Count files currently shared on active network
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
const fileCount = current?.size ?? 0
function persist(next: ShareRecord[]) {
setShares(next)
saveShares(next)
}
function addShare(files: FileList, folderName: string) {
const map = new Map<string, File>()
for (let i = 0; i < files.length; i++) {
const f = files[i]
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath
const path = rel ? rel.split('/').slice(1).join('/') : f.name
if (!includeSubfolders && path.includes('/')) continue
map.set(path, f)
}
setSharedFiles(map)
const record: ShareRecord = {
name: folderName,
global: true,
networkId: activeNetworkId ?? undefined,
}
persist([...shares.filter(s => s.name !== folderName), record])
}
function removeShare(name: string) {
persist(shares.filter(s => s.name !== name))
// Clear the in-memory share if it matches
setSharedFiles(new Map())
}
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const fileList = e.target.files
if (!fileList || fileList.length === 0) return
// Get folder name from first file's path
const first = fileList[0] as File & { webkitRelativePath?: string }
const folderName = first.webkitRelativePath?.split('/')[0] ?? 'folder'
addShare(fileList, folderName)
e.target.value = ''
}
return (
<div className="share-manager">
<input
ref={inputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard
webkitdirectory=""
multiple
style={{ display: 'none' }}
onChange={onChange}
/>
{shares.length > 0 && (
<ul className="share-list">
{shares.map(s => (
<li key={s.name} className="share-item">
<span className="share-icon">📁</span>
<span className="share-name" title={s.name}>{s.name}</span>
<span className="share-scope">{s.global ? 'all nets' : 'this net'}</span>
<button className="share-repick" onClick={() => inputRef.current?.click()} title="Re-pick folder"></button>
<button className="share-remove" onClick={() => removeShare(s.name)} title="Remove share"></button>
</li>
))}
</ul>
)}
<button
className="folder-picker-btn"
onClick={() => inputRef.current?.click()}
title="Share a folder with peers on this network"
>
{fileCount > 0 ? `${fileCount} file${fileCount !== 1 ? 's' : ''} shared` : '+ Share folder'}
</button>
<label className="folder-picker-subfolders">
<input
type="checkbox"
checked={includeSubfolders}
onChange={e => setIncludeSubfolders(e.target.checked)}
/>
include subfolders
</label>
</div>
)
}

View File

@@ -1,38 +1,104 @@
import { useState } from 'react'
import { useWaste } from '../store' import { useWaste } from '../store'
import type { PeerStatus } from '../store'
import { ShareManager } from './ShareManager'
import { Transfers } from './Transfers'
function makeYawCard(id: string, alias: string): string { function makeYawCard(id: string, alias: string): string {
const nick = encodeURIComponent(alias.trim().slice(0, 40)) const nick = encodeURIComponent(alias.trim().slice(0, 40))
return nick ? `yaw:${id}?n=${nick}` : `yaw:${id}` return nick ? `yaw:${id}?n=${nick}` : `yaw:${id}`
} }
export function Sidebar() { function connDot(status: PeerStatus | undefined): { color: string; label: string } {
const { localPeer, masterId, masterAlias, networks, activeNetworkId, activeRoom, setActiveRoom, setActiveNetwork, messages } = useWaste() const ct = status?.candidateType
const cs = status?.connState
if (cs === 'failed' || cs === 'closed') return { color: '#e06060', label: 'failed' }
if (cs === 'connecting' || cs === 'new') return { color: '#c8a020', label: 'connecting…' }
if (ct === 'relay') return { color: '#c8a020', label: 'relayed (TURN)' }
if (ct === 'srflx') return { color: '#60c060', label: 'NAT punched' }
if (ct === 'host') return { color: '#60c060', label: 'direct (LAN)' }
if (cs === 'connected') return { color: '#60c060', label: 'connected' }
return { color: 'var(--muted)', label: 'unknown' }
}
// Rooms = general + any DM threads that have messages function formatTs(ts: number | undefined): string {
const rooms = ['general'] if (!ts) return '—'
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function Sidebar() {
const {
localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom,
connectedPeers, peerStatus,
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
customRooms, createRoom, logout,
} = useWaste()
const [addingRoom, setAddingRoom] = useState(false)
const [newRoomName, setNewRoomName] = useState('')
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
const rooms = ['general', ...netCustomRooms]
Object.keys(messages).forEach(r => { Object.keys(messages).forEach(r => {
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r) if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
}) })
function submitNewRoom(e: React.FormEvent) {
e.preventDefault()
if (newRoomName.trim()) createRoom(newRoomName)
setNewRoomName('')
setAddingRoom(false)
}
const displayAlias = localPeer?.alias ?? masterAlias ?? '' const displayAlias = localPeer?.alias ?? masterAlias ?? ''
const displayId = localPeer?.id ?? masterId ?? '' const displayId = localPeer?.id ?? masterId ?? ''
const card = displayId ? makeYawCard(displayId, displayAlias) : null const card = displayId ? makeYawCard(displayId, displayAlias) : null
function copyHangLink() {
const net = networks.find(n => n.network_id === activeNetworkId)
if (!net) return
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
const anchor = cfg?.signalURL ?? ''
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
.replace(/\+/g, '-').replace(/\//g, '_')
const url = `${window.location.origin}/#waste:${payload}`
navigator.clipboard?.writeText(url)
}
function handleLogout() {
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
logout(clearId)
}
function openDM(peerId: string) {
setActiveRoom(`dm:${peerId}`)
}
function requestFiles(peerId: string) {
browseFiles(peerId)
}
return ( return (
<nav className="sidebar"> <nav className="sidebar">
<div className="sidebar-identity">
<div <div
className="sidebar-identity" style={{ flex: 1, cursor: card ? 'pointer' : undefined }}
title={card ?? undefined} title={card ?? undefined}
onClick={() => card && navigator.clipboard?.writeText(card)} onClick={() => card && navigator.clipboard?.writeText(card)}
style={{ cursor: card ? 'pointer' : undefined }}
> >
<span className="alias">{displayAlias || '…'}</span> <span className="alias">{displayAlias || '…'}</span>
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span> <span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
{card && <span className="peer-id" style={{ fontSize: '9px', opacity: 0.5 }}>click to copy card</span>} </div>
<button className="sidebar-logout" onClick={handleLogout} title="Leave network"></button>
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-label-row">
<span className="sidebar-label">Networks</span> <span className="sidebar-label">Networks</span>
{activeNetworkId && (
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
)}
</div>
{networks.map(n => ( {networks.map(n => (
<button <button
key={n.network_id} key={n.network_id}
@@ -45,7 +111,10 @@ export function Sidebar() {
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<div className="sidebar-label-row">
<span className="sidebar-label">Rooms</span> <span className="sidebar-label">Rooms</span>
<button className="sidebar-add" onClick={() => setAddingRoom(v => !v)} title="New room">+</button>
</div>
{rooms.map(r => ( {rooms.map(r => (
<button <button
key={r} key={r}
@@ -55,6 +124,71 @@ export function Sidebar() {
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`} {r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`}
</button> </button>
))} ))}
{addingRoom && (
<form className="sidebar-new-room" onSubmit={submitNewRoom}>
<input
autoFocus
value={newRoomName}
onChange={e => setNewRoomName(e.target.value)}
placeholder="room-name"
onKeyDown={e => e.key === 'Escape' && (setAddingRoom(false), setNewRoomName(''))}
/>
</form>
)}
</div>
{adapterMode === 'browser' && (
<div className="sidebar-section">
<span className="sidebar-label">Sharing</span>
<div style={{ padding: '4px 12px' }}><ShareManager /></div>
</div>
)}
<Transfers />
<div className="sidebar-section sidebar-peers">
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
{/* Self */}
<div className="peer-row peer-row-self">
<span className="peer-dot" style={{ background: 'var(--accent)' }} />
<span className="peer-row-alias">{displayAlias || '…'}</span>
<span className="peer-row-you">you</span>
</div>
{connectedPeers.length === 0 && (
<span className="sidebar-empty">no peers connected</span>
)}
{connectedPeers.map(p => {
const st = peerStatus[p.id]
const dot = connDot(st)
const tooltip = [
p.alias,
p.id,
`connection: ${dot.label}`,
st?.remoteAddress ? `remote: ${st.remoteAddress}` : null,
st?.lastSeen ? `last seen: ${formatTs(st.lastSeen)}` : null,
].filter(Boolean).join('\n')
return (
<div key={p.id} className="peer-row" title={tooltip}>
<span className="peer-dot" style={{ background: dot.color }} />
<span className="peer-row-alias">{p.alias}</span>
<span className="peer-row-id">{p.id.slice(0, 8)}</span>
<span className="peer-row-actions">
<button className="peer-action" onClick={() => openDM(p.id)} title="DM"></button>
<button className="peer-action" onClick={() => requestFiles(p.id)} title="Browse files"></button>
<label className="peer-action" title="Send file" style={{ cursor: 'pointer' }}>
📎
<input type="file" style={{ display: 'none' }} onChange={e => {
const f = e.target.files?.[0]
if (f) sendFileTo(p.id, f)
e.target.value = ''
}} />
</label>
</span>
</div>
)
})}
</div> </div>
</nav> </nav>
) )

View File

@@ -0,0 +1,51 @@
import { useWaste } from '../store'
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function Transfers() {
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
const hasPending = Object.keys(pendingOffers).length > 0
const hasActive = Object.keys(fileProgress).length > 0
if (!hasPending && !hasActive) return null
function alias(peerId: string) {
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
}
return (
<div className="sidebar-section">
<span className="sidebar-label">Transfers</span>
{Object.entries(pendingOffers).map(([xid, offer]) => (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={offer.name}>{offer.name}</span>
<span className="transfer-meta">{fmt(offer.size)} from {alias(offer.peerId)}</span>
<div className="transfer-actions">
<button className="transfer-btn accept" onClick={() => acceptOffer(offer.peerId, xid, offer.name, offer.size)}>Accept</button>
<button className="transfer-btn reject" onClick={() => rejectOffer(offer.peerId, xid)}>Reject</button>
</div>
</div>
))}
{Object.entries(fileProgress).map(([xid, p]) => {
const pct = p.total > 0 ? Math.round((p.received / p.total) * 100) : 0
return (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={p.name}>{p.name}</span>
<span className="transfer-meta">{fmt(p.received)} / {fmt(p.total)} · {alias(p.peerId)}</span>
<div className="transfer-progress">
<div className="transfer-progress-bar" style={{ width: `${pct}%` }} />
</div>
<button className="transfer-btn reject" onClick={() => cancelTransfer(p.peerId, xid, 'recv')}>Cancel</button>
</div>
)
})}
</div>
)
}

View File

@@ -1,13 +1,15 @@
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import { MessagePane } from '../components/MessagePane' import { MessagePane } from '../components/MessagePane'
import { PeerList } from '../components/PeerList' import { FileBrowser } from '../components/FileBrowser'
import { useWaste } from '../store'
export function Chat() { export function Chat() {
const { activeFilePeer } = useWaste()
return ( return (
<div className="chat-layout"> <div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
<Sidebar /> <Sidebar />
<MessagePane /> <MessagePane />
<PeerList /> {activeFilePeer && <FileBrowser />}
</div> </div>
) )
} }

View File

@@ -10,18 +10,23 @@ interface Props {
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash) // ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
// ?n=<name> network name shorthand // ?n=<name> network name shorthand
// ?network=<name> network name // ?network=<name> network name
// ?net=<64hex> yaw2-style full network hash (joined without plaintext name) // ?net=<64hex> yaw2-style full network hash
// ?a=<url> anchor URL hint (informational — daemon controls its anchor) // ?a=<url> anchor URL hint
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } { function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
const p = new URLSearchParams(window.location.search) const p = new URLSearchParams(window.location.search)
const inviteString = p.get('invite') ?? '' let inviteString = p.get('invite') ?? ''
let network = p.get('n') ?? p.get('network') ?? '' let network = p.get('n') ?? p.get('network') ?? ''
let netHash = p.get('net') ?? '' let netHash = p.get('net') ?? ''
let anchor = p.get('a') ?? p.get('anchor') ?? '' let anchor = p.get('a') ?? p.get('anchor') ?? ''
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
const hash = window.location.hash.slice(1) // strip leading #
if (!inviteString && hash.startsWith('waste:')) {
inviteString = hash
}
if (inviteString.startsWith('waste:')) { if (inviteString.startsWith('waste:')) {
try { try {
// URL-safe base64 → standard base64
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
network = network || json.network || '' network = network || json.network || ''
netHash = netHash || json.net || '' netHash = netHash || json.net || ''
@@ -29,43 +34,64 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
} catch { /* ignore bad invite */ } } catch { /* ignore bad invite */ }
} }
// URL path: /<16hex>/ — short network ID from anchor-served URL
const pathMatch = window.location.pathname.match(/\/([0-9a-f]{16})\/?$/i)
if (pathMatch && !netHash && !network) {
// Only the short ID — can't join by short ID alone (need full hash for HKDF).
// Store it as a hint; the user must supply the network name OR a full 64-char hash.
}
return { network, netHash, anchor, inviteString } return { network, netHash, anchor, inviteString }
} }
const DEFAULT_ANCHOR = (() => {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
return cfg?.signalURL ?? localStorage.getItem('waste_anchor_url') ?? ''
})()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
export function Onboarding({ status }: Props) { export function Onboarding({ status }: Props) {
const { send, masterAlias, masterId, exportedBackup } = useWaste() const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
const [network, setNetwork] = useState('') const [network, setNetwork] = useState('')
const [netHash, setNetHash] = useState('') const [netHash, setNetHash] = useState('')
const [inviteString, setInviteString] = useState('') const [inviteString, setInviteString] = useState('')
const [anchorHint, setAnchorHint] = useState('') const [anchorUrl, setAnchorUrl] = useState(DEFAULT_ANCHOR)
const [nick, setNick] = useState(localStorage.getItem('waste_nick') || '')
const [exportPass, setExportPass] = useState('') const [exportPass, setExportPass] = useState('')
const [importJson, setImportJson] = useState('') const [importJson, setImportJson] = useState('')
const [importPass, setImportPass] = useState('') const [importPass, setImportPass] = useState('')
const [importStatus, setImportStatus] = useState('') const [importStatus, setImportStatus] = useState('')
const [showBackup, setShowBackup] = useState(false) const [showBackup, setShowBackup] = useState(false)
const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338')
useEffect(() => { useEffect(() => {
const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams() const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams()
if (n) setNetwork(n) if (n) setNetwork(n)
if (nh) setNetHash(nh) if (nh) setNetHash(nh)
if (a) setAnchorHint(a) if (a) setAnchorUrl(a)
if (inv) setInviteString(inv) if (inv) setInviteString(inv)
}, []) }, [])
// Auto-rejoin when adapter is ready and we have saved session state
useEffect(() => {
if (adapterMode !== 'browser' || status !== 'connected') return
const { network: n, netHash: nh } = parseInviteParams()
if (n || nh) return // explicit invite — don't auto-join, show form
const savedNetwork = localStorage.getItem('waste_last_network')
if (savedNetwork) doJoin(savedNetwork, '')
}, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps
function joinNetwork(e: React.FormEvent) { function joinNetwork(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const name = network.trim() doJoin(network.trim(), netHash.trim())
const hash = netHash.trim() }
function doJoin(name: string, hash: string) {
if (!name && !hash) return if (!name && !hash) return
if (adapterMode === 'browser') {
localStorage.setItem('waste_anchor_url', anchorUrl)
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
if (name) localStorage.setItem('waste_last_network', name)
else localStorage.removeItem('waste_last_network')
}
if (hash.length === 64 && !name) { if (hash.length === 64 && !name) {
// yaw2-style: join by full network hash without plaintext name
send({ type: 'join_network', network_hash: hash }) send({ type: 'join_network', network_hash: hash })
} else { } else {
send({ type: 'join_network', network_name: name }) send({ type: 'join_network', network_name: name })
@@ -75,7 +101,6 @@ export function Onboarding({ status }: Props) {
function exportIdentity(e: React.FormEvent) { function exportIdentity(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!exportPass.trim()) return if (!exportPass.trim()) return
setExportBlob('')
send({ type: 'export_identity', passphrase: exportPass }) send({ type: 'export_identity', passphrase: exportPass })
} }
@@ -86,6 +111,15 @@ export function Onboarding({ status }: Props) {
send({ type: 'import_identity', backup: importJson, passphrase: importPass }) send({ type: 'import_identity', backup: importJson, passphrase: importPass })
} }
function switchToDaemon() {
localStorage.setItem('waste_daemon_ws', daemonUrl)
connect(daemonUrl)
}
function switchToBrowser() {
connectBrowser()
}
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
// ── disconnected / connecting ──────────────────────────────────────────────── // ── disconnected / connecting ────────────────────────────────────────────────
@@ -94,18 +128,16 @@ export function Onboarding({ status }: Props) {
<div className="onboarding"> <div className="onboarding">
<h1>waste</h1> <h1>waste</h1>
{status === 'connecting' ? ( {status === 'connecting' ? (
<p className="status connecting">Connecting to daemon</p> <p className="status connecting">
{adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'}
</p>
) : ( ) : (
<> <>
<p className="status disconnected">Daemon not running</p> <p className="status disconnected">Daemon not running</p>
<p className="onboarding-hint"> <p className="onboarding-hint">Start the daemon to use the web UI:</p>
Start the daemon to use the web UI:
</p>
<pre className="onboarding-code">./launch-web.sh</pre> <pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted"> <p className="onboarding-hint muted">Or use browser mode (no install required):</p>
Or pass a custom alias and network: <button className="primary" onClick={switchToBrowser}>Use browser mode</button>
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
</> </>
)} )}
</div> </div>
@@ -121,14 +153,25 @@ export function Onboarding({ status }: Props) {
<div className="onboarding-identity"> <div className="onboarding-identity">
<span className="alias">{masterAlias}</span> <span className="alias">{masterAlias}</span>
{shortId && <span className="peer-id mono">{shortId}</span>} {shortId && <span className="peer-id mono">{shortId}</span>}
<span className="peer-id" style={{ opacity: 0.4, fontSize: '9px' }}>
{adapterMode === 'browser' ? 'browser mode' : 'daemon mode'}
</span>
</div> </div>
)} )}
<form onSubmit={joinNetwork} className="join-form"> <form onSubmit={joinNetwork} className="join-form">
<label className="join-label">Join a network</label> <label className="join-label">Join a network</label>
{anchorHint && (
<p className="onboarding-hint muted">via {anchorHint}</p> {adapterMode === 'browser' && (
<input
value={nick}
onChange={e => setNick(e.target.value)}
placeholder="your name (optional)"
autoComplete="nickname"
style={{ marginBottom: '0.4rem' }}
/>
)} )}
{netHash && !network ? ( {netHash && !network ? (
<input <input
value={netHash} value={netHash}
@@ -148,6 +191,18 @@ export function Onboarding({ status }: Props) {
autoFocus autoFocus
/> />
)} )}
{adapterMode === 'browser' && (
<input
value={anchorUrl}
onChange={e => setAnchorUrl(e.target.value)}
placeholder="signal server (wss://…)"
autoComplete="url"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
)}
{inviteString && ( {inviteString && (
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}> <p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
{inviteString.slice(0, 48)} {inviteString.slice(0, 48)}
@@ -158,6 +213,31 @@ export function Onboarding({ status }: Props) {
</button> </button>
</form> </form>
{/* Mode switcher — only shown when on localhost */}
{isLocal && adapterMode !== null && (
<div className="onboarding-section">
{adapterMode === 'daemon' ? (
<button className="toggle-link" onClick={switchToBrowser}>
Switch to browser mode
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<button className="toggle-link" onClick={() => {}}>
Switch to daemon mode
</button>
<input
value={daemonUrl}
onChange={e => setDaemonUrl(e.target.value)}
placeholder="ws://127.0.0.1:17338"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
<button className="primary" onClick={switchToDaemon}>Connect</button>
</div>
)}
</div>
)}
<div className="onboarding-section"> <div className="onboarding-section">
<button <button
className="toggle-link" className="toggle-link"
@@ -170,7 +250,7 @@ export function Onboarding({ status }: Props) {
<div className="backup-panel"> <div className="backup-panel">
<p className="onboarding-hint"> <p className="onboarding-hint">
Export your identity as an encrypted file. You can import it on Export your identity as an encrypted file. You can import it on
any machine or in the TUI with <code>--import-identity</code>. any device browser or TUI.
</p> </p>
<form onSubmit={exportIdentity} className="backup-form"> <form onSubmit={exportIdentity} className="backup-form">

View File

@@ -1,10 +1,21 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types' import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage, PeerConnState, CandidateType } from '../types'
export interface PeerStatus {
connState?: PeerConnState
candidateType?: CandidateType
remoteAddress?: string
lastSeen?: number
}
import { DaemonAdapter } from '../adapter/daemon' import { DaemonAdapter } from '../adapter/daemon'
import { BrowserAdapter } from '../adapter/browser'
type AnyAdapter = DaemonAdapter | BrowserAdapter
interface WasteState { interface WasteState {
// connection // connection
adapter: DaemonAdapter | null adapter: AnyAdapter | null
adapterMode: 'daemon' | 'browser' | null
daemonStatus: 'disconnected' | 'connecting' | 'connected' daemonStatus: 'disconnected' | 'connecting' | 'connected'
// identity // identity
@@ -22,24 +33,49 @@ interface WasteState {
// chat — keyed by room // chat — keyed by room
messages: Record<string, ChatMessage[]> messages: Record<string, ChatMessage[]>
activeRoom: string activeRoom: string
// user-created rooms, keyed by networkId
customRooms: Record<string, string[]>
// file listings — keyed by peer id // file listings — keyed by peer id
fileLists: Record<string, FileEntry[]> fileLists: Record<string, FileEntry[]>
// per-peer connection status (browser mode) and last-seen timestamps
peerStatus: Record<string, PeerStatus>
// identity backup export result (cleared when consumed) // identity backup export result (cleared when consumed)
exportedBackup: string | null exportedBackup: string | null
// file browser state
activeFilePeer: string | null
// shared files keyed by networkId
sharedFilesByNetwork: Record<string, Map<string, File>>
// incoming offers awaiting accept/reject: xid → offer info
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// actions // actions
connect: (url: string) => void connect: (url: string) => void
connectBrowser: () => void
disconnect: () => void disconnect: () => void
send: (msg: IpcMessage) => void send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void setActiveNetwork: (id: string) => void
setActiveRoom: (room: string) => void setActiveRoom: (room: string) => void
setActiveFilePeer: (peerId: string | null) => void
setSharedFiles: (files: Map<string, File>, networkId?: string) => void
browseFiles: (peerId: string) => void
sendFileTo: (peerId: string, file: File) => void
acceptOffer: (peerId: string, xid: string, name: string, size: number) => void
rejectOffer: (peerId: string, xid: string) => void
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
createRoom: (name: string) => void
logout: (clearIdentity: boolean) => void
handleEvent: (msg: IpcMessage) => void handleEvent: (msg: IpcMessage) => void
} }
export const useWaste = create<WasteState>((set, get) => ({ export const useWaste = create<WasteState>((set, get) => ({
adapter: null, adapter: null,
adapterMode: null,
daemonStatus: 'disconnected', daemonStatus: 'disconnected',
masterAlias: null, masterAlias: null,
masterId: null, masterId: null,
@@ -49,20 +85,36 @@ export const useWaste = create<WasteState>((set, get) => ({
connectedPeers: [], connectedPeers: [],
messages: {}, messages: {},
activeRoom: 'general', activeRoom: 'general',
customRooms: {},
fileLists: {}, fileLists: {},
exportedBackup: null, exportedBackup: null,
peerStatus: {},
activeFilePeer: null,
sharedFilesByNetwork: {},
pendingOffers: {},
fileProgress: {},
connect(url: string) { connect(url: string) {
const adapter = new DaemonAdapter(url) const adapter = new DaemonAdapter(url)
adapter.onStatusChange = (s) => set({ daemonStatus: s }) adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg)) adapter.on((msg) => get().handleEvent(msg))
adapter.connect() adapter.connect()
set({ adapter }) set({ adapter, adapterMode: 'daemon' })
},
connectBrowser() {
const adapter = new BrowserAdapter()
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter, adapterMode: 'browser' })
}, },
disconnect() { disconnect() {
get().adapter?.disconnect() const a = get().adapter
set({ adapter: null, daemonStatus: 'disconnected' }) if (a instanceof DaemonAdapter) a.disconnect()
else if (a instanceof BrowserAdapter) a.disconnect()
set({ adapter: null, adapterMode: null, daemonStatus: 'disconnected' })
}, },
send(msg) { send(msg) {
@@ -77,6 +129,82 @@ export const useWaste = create<WasteState>((set, get) => ({
set({ activeRoom: room }) set({ activeRoom: room })
}, },
setActiveFilePeer(peerId) {
set({ activeFilePeer: peerId })
},
setSharedFiles(files, networkId) {
const netId = networkId ?? get().activeNetworkId ?? ''
if (!netId) return
set(s => ({ sharedFilesByNetwork: { ...s.sharedFilesByNetwork, [netId]: files } }))
const a = get().adapter
if (a instanceof BrowserAdapter) a.setSharedFiles(files)
},
sendFileTo(peerId, file) {
const a = get().adapter
if (a instanceof BrowserAdapter) a.sendFileTo(peerId, file)
},
acceptOffer(peerId, xid, name, size) {
set(s => {
const p = { ...s.pendingOffers }; delete p[xid]
return { pendingOffers: p, fileProgress: { ...s.fileProgress, [xid]: { peerId, name, received: 0, total: size } } }
})
const a = get().adapter
if (a instanceof BrowserAdapter) a.acceptOffer(peerId, xid, name, size)
},
rejectOffer(peerId, xid) {
set(s => { const p = { ...s.pendingOffers }; delete p[xid]; return { pendingOffers: p } })
const a = get().adapter
if (a instanceof BrowserAdapter) a.rejectOffer(peerId, xid)
},
cancelTransfer(peerId, xid, direction) {
set(s => {
const fp = { ...s.fileProgress }; delete fp[xid]
return { fileProgress: fp }
})
const a = get().adapter
if (a instanceof BrowserAdapter) a.cancelTransfer(peerId, xid, direction)
},
logout(clearIdentity) {
const a = get().adapter
if (a instanceof DaemonAdapter) a.disconnect()
else if (a instanceof BrowserAdapter) a.disconnect()
localStorage.removeItem('waste_last_network')
localStorage.removeItem('waste_nick')
localStorage.removeItem('waste_anchor_url')
if (clearIdentity) localStorage.removeItem('waste_seed')
window.location.reload()
},
createRoom(name) {
const netId = get().activeNetworkId
if (!netId || !name.trim()) return
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
set(s => {
const existing = s.customRooms[netId] ?? []
if (existing.includes(key)) return s
return {
customRooms: { ...s.customRooms, [netId]: [...existing, key] },
activeRoom: key,
}
})
},
browseFiles(peerId) {
const a = get().adapter
if (a instanceof BrowserAdapter) {
a.requestBrowse(peerId)
} else {
get().send({ type: 'get_file_list', peer_id: peerId as unknown as import('../types').PeerID })
}
set({ activeFilePeer: peerId })
},
handleEvent(msg) { handleEvent(msg) {
switch (msg.type) { switch (msg.type) {
case 'state_snapshot': { case 'state_snapshot': {
@@ -93,11 +221,13 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
case 'network_joined': { case 'network_joined': {
set(s => ({ set(s => ({
localPeer: s.localPeer ?? msg.local_peer ?? null,
networks: s.networks.some(n => n.network_id === msg.network_id) networks: s.networks.some(n => n.network_id === msg.network_id)
? s.networks ? s.networks
: [...s.networks, { : [...s.networks, {
network_id: msg.network_id!, network_id: msg.network_id!,
network_name: msg.network_name!, network_name: msg.network_name!,
local_peer: msg.local_peer,
share_dir: msg.share_dir, share_dir: msg.share_dir,
}], }],
activeNetworkId: s.activeNetworkId ?? msg.network_id!, activeNetworkId: s.activeNetworkId ?? msg.network_id!,
@@ -117,7 +247,7 @@ export const useWaste = create<WasteState>((set, get) => ({
if (msg.peer) { if (msg.peer) {
set(s => ({ set(s => ({
connectedPeers: s.connectedPeers.some(p => p.id === msg.peer!.id) connectedPeers: s.connectedPeers.some(p => p.id === msg.peer!.id)
? s.connectedPeers ? s.connectedPeers.map(p => p.id === msg.peer!.id ? { ...p, ...msg.peer } : p)
: [...s.connectedPeers, msg.peer!], : [...s.connectedPeers, msg.peer!],
})) }))
} }
@@ -143,18 +273,65 @@ export const useWaste = create<WasteState>((set, get) => ({
if (msg.message) { if (msg.message) {
const m = msg.message const m = msg.message
const room = m.room const room = m.room
const fromId = String(m.from)
set(s => { set(s => {
const existing = s.messages[room] ?? [] const existing = s.messages[room] ?? []
if (m.mid && existing.some(e => e.mid === m.mid)) return s if (m.mid && existing.some(e => e.mid === m.mid)) return s
return { messages: { ...s.messages, [room]: [...existing, m] } } const prev = s.peerStatus[fromId] ?? {}
return {
messages: { ...s.messages, [room]: [...existing, m] },
peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } },
}
}) })
} }
break break
} }
case 'peer_status': {
const pid = String(msg.peer_id)
set(s => {
const prev = s.peerStatus[pid] ?? {}
return {
peerStatus: {
...s.peerStatus,
[pid]: {
...prev,
...(msg.conn_state ? { connState: msg.conn_state } : {}),
...(msg.candidate_type ? { candidateType: msg.candidate_type } : {}),
...(msg.remote_address !== undefined ? { remoteAddress: msg.remote_address } : {}),
},
},
}
})
break
}
case 'identity_exported': { case 'identity_exported': {
if (msg.backup) set({ exportedBackup: msg.backup }) if (msg.backup) set({ exportedBackup: msg.backup })
break break
} }
case 'incoming_file': {
if (msg.peer_id && msg.offer) {
const { xid, name, size } = msg.offer
set(s => ({ pendingOffers: { ...s.pendingOffers, [xid]: { peerId: String(msg.peer_id), name, size } } }))
}
break
}
case 'file_progress': {
if (msg.transfer_id && msg.peer_id) {
const xid = msg.transfer_id
set(s => ({
fileProgress: {
...s.fileProgress,
[xid]: {
peerId: String(msg.peer_id),
name: msg.offer?.name ?? xid,
received: msg.bytes_received ?? 0,
total: msg.total_bytes ?? 0,
},
},
}))
}
break
}
case 'file_list': { case 'file_list': {
if (msg.peer_id && msg.files) { if (msg.peer_id && msg.files) {
set(s => ({ set(s => ({
@@ -163,6 +340,18 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
break break
} }
case 'file_complete': {
if (msg.path && msg.offer?.name) {
// clear progress entry
const xid = msg.offer.xid
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
const a = document.createElement('a')
a.href = msg.path
a.download = msg.offer.name
a.click()
}
break
}
} }
}, },
})) }))

View File

@@ -29,6 +29,12 @@ export interface ChatMessage {
export interface FileEntry { export interface FileEntry {
name: string name: string
size_bytes: number size_bytes: number
path?: string // relative path including filename, e.g. "docs/report.pdf"
}
export interface ShareEntry {
path: string
networks: string[] // ["*"] = global
} }
export interface FileOffer { export interface FileOffer {
@@ -40,6 +46,9 @@ export interface FileOffer {
// ── IPC message types ───────────────────────────────────────────────────────── // ── IPC message types ─────────────────────────────────────────────────────────
export type CandidateType = 'host' | 'srflx' | 'relay' | 'unknown'
export type PeerConnState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'
export type IpcMsgType = export type IpcMsgType =
// commands // commands
| 'send_message' | 'send_message'
@@ -52,6 +61,9 @@ export type IpcMsgType =
| 'get_file_list' | 'get_file_list'
| 'export_identity' | 'export_identity'
| 'import_identity' | 'import_identity'
| 'add_share'
| 'remove_share'
| 'list_shares'
// events // events
| 'state_snapshot' | 'state_snapshot'
| 'message_received' | 'message_received'
@@ -67,6 +79,8 @@ export type IpcMsgType =
| 'invite_generated' | 'invite_generated'
| 'identity_exported' | 'identity_exported'
| 'identity_imported' | 'identity_imported'
| 'shares_list'
| 'peer_status'
| 'error' | 'error'
export interface IpcMessage { export interface IpcMessage {
@@ -104,4 +118,10 @@ export interface IpcMessage {
error_message?: string error_message?: string
invite?: string invite?: string
files?: FileEntry[] files?: FileEntry[]
shares?: ShareEntry[]
networks_filter?: string[] // for add_share
// peer_status
conn_state?: PeerConnState
candidate_type?: CandidateType
remote_address?: string
} }