19 Commits

Author SHA1 Message Date
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
22 changed files with 1390 additions and 73 deletions

View File

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

View File

@@ -26,6 +26,11 @@ Target: a web frontend (React or similar) wrapped in a native binary using a Tau
#### 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.
#### 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
@@ -33,41 +38,62 @@ A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bu
### 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.
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, browser mode)
Browser mode now supports TURN relay. `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. Mobile/CGNAT peers that fail STUN hole-punching automatically fall back to TURN relay.
The peer dot in the sidebar turns yellow for relayed connections (`candidate_type: relay`).
**Daemon mode:** not yet wired. Add `-turn-url` / `-turn-secret` flags to `cmd/daemon/main.go` and pass them into the `ICEServers` slice in `internal/anchor/client.go`.
### 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)
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)
`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)
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
### Per-Network Share Directories
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.
### Session Persistence ✅ (shipped)
Browser mode now auto-rejoins on reload. The last-used network name, alias, and anchor URL are saved to `localStorage` on join and restored on load. A ⏻ logout button in the sidebar clears session state (optionally including the identity keypair) and reloads the page.
### Peer Gossip (§8.4)
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.
### Per-Network Share Directories ✅ (shipped)
Share state is tracked per `network_id` in the store (`sharedFilesByNetwork`). Switching networks switches the active share.
### File Transfer UX
- Manual accept/reject (currently auto-accept)
- Transfer cancellation via `file-cancel`
- Progress display in TUI
- Resume after disconnection
### Persistent Multi-Share Configuration ✅ (shipped)
Multiple share roots per network, with global (all networks) or scoped visibility.
- **Daemon:** `shares.json` next to `identity.json` in the data dir. `add_share`/`remove_share`/`list_shares` IPC commands. File listing recursively walks all share roots, returning relative paths. Backward compatible with the existing `set_share_dir` single-dir mechanism.
- **Browser:** `waste_shares` in `localStorage` stores named share records (folder name, global flag). The `ShareManager` sidebar component shows the list with re-pick (↺) and remove (✕) buttons. Actual `File` objects live in memory — the record persists across reloads so the user can restore with one click.
### Additional Channels / Rooms ✅ (shipped, web UI)
The `+` button in the Rooms sidebar section creates custom rooms, stored in `customRooms` keyed by `network_id`. Room names are slugified strings — any peer that sends to a room name causes it to appear on the recipient automatically. DM rooms (`dm:<peerId>`) appear automatically when messages arrive.
**Not yet done:** TUI room creation, daemon-side SQLite persistence of room lists across restarts.
### 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:** resume after disconnection, daemon-side download directory.
### TURN Relay for Daemon Mode
The daemon doesn't yet support TURN. Add `-turn-url` and `-turn-secret` flags to `cmd/daemon/main.go` and wire them into the ICE server list in `internal/anchor/client.go`. The credential generation is the same HMAC-SHA1 scheme already implemented in browser mode.
### Native UI
Web frontend (React) + Tauri/native packaging. The IPC protocol is already the full boundary — the UI is a pure consumer.
### 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.
Web frontend (React, already built) + Tauri shell for native packaging. The IPC protocol is the full boundary — the UI is already a pure consumer. Main work: Tauri setup, system tray, OS notifications.
---
@@ -77,19 +103,28 @@ For symmetric-NAT pairs. Opt-in, off by default. Operates blind (relay sees only
|---|---|
| ✅ shipped | Daemon + anchor server |
| ✅ 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 | Message persistence (SQLite, per-network) |
| ✅ shipped | TUI (`cmd/tui`, Bubble Tea) |
| ✅ shipped | Invite system (`waste:` URI, `--join` flag) |
| ✅ shipped | Multi-network support (HKDF derived identities) |
| ✅ shipped | File transfer (dedicated binary DataChannels) |
| ✅ shipped | File transfer (binary DataChannels, pull + push) |
| ✅ shipped | Forward-secret signaling (YAW/2.1 ephemeral X25519) |
| next | Per-network share directories |
| next | Peer gossip (anchor-free reconnection) |
| next | File transfer UX (manual accept, cancel, TUI progress) |
| ✅ shipped | Peer gossip (anchor-free mesh reconnection) |
| ✅ shipped | Web UI — browser mode + daemon mode |
| ✅ shipped | Per-peer NAT/ICE status, identity backup/restore |
| ✅ shipped | Per-network share directories |
| ✅ shipped | Additional channels/rooms per network |
| ✅ shipped | TURN relay (browser mode, coturn `use-auth-secret`) |
| ✅ shipped | File transfer UX (progress, cancel, manual accept) |
| ✅ shipped | Session persistence + logout (browser mode) |
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
| ✅ shipped | Subfolder support + directory browser UI in file browser |
| next | TURN relay for daemon mode |
| next | TUI room creation + daemon-side room persistence |
| next | File transfer resume after disconnection |
| future | Native UI (React + Tauri) |
| future | TURN relay (opt-in, for symmetric-NAT pairs) |
---

121
README.md
View File

@@ -41,6 +41,21 @@ On the VPS, run the anchor and keep it alive (systemd, screen, whatever you use)
./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
@@ -53,7 +68,13 @@ npm run build
# Produces web/dist/
# Copy to VPS
rsync -az web/dist/ user@your-vps:/var/www/waste-web/
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):
@@ -81,11 +102,73 @@ Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS en
- 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 (e.g. `npx serve -s /var/www/waste-web -l 3000`) and proxy `/` to `127.0.0.1:3000`. The key requirements:
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).
---
## How it works: daemon vs browser mode
@@ -94,17 +177,43 @@ 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 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.
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.
**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`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
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.
> 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.
---
@@ -207,6 +316,10 @@ Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
{"type":"get_file_list"}
{"type":"get_file_list","peer_id":"<64-hex>"}
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
{"type":"add_share","path":"/home/alice/Music"} // global share
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // network-scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}
{"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."}
```

View File

@@ -23,6 +23,7 @@ import (
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/netmgr"
"github.com/waste-go/internal/proto"
"github.com/waste-go/internal/shares"
)
// RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort.
@@ -246,7 +247,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
Type: proto.EvtFileList,
NetworkID: n.ID,
PeerID: ptr(n.Identity.PeerID()),
Files: n.Mesh.ScanShareDir(),
Files: mgr.ScanAllShares(n.ID),
})
} else {
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
@@ -258,6 +259,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:
n := mgr.Resolve(cmd.NetworkID)
if n == nil {
@@ -389,6 +419,18 @@ func errMsg(s string) proto.IpcMessage {
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
}
func sharesListMsg(mgr *netmgr.Manager) proto.IpcMessage {
all := mgr.Shares.All()
entries := make([]proto.ShareEntry, len(all))
for i, sh := range all {
entries[i] = proto.ShareEntry{Path: sh.Path, Networks: sh.Networks}
}
return proto.IpcMessage{Type: proto.EvtSharesList, Shares: entries}
}
// ensure shares import is used
var _ = shares.Share{}
func ptr[T any](v T) *T { return &v }
func randomHex(n int) string {

View File

@@ -29,6 +29,9 @@ type Mesh struct {
Store *store.Store // may be nil if persistence is disabled
ShareDir string // directory whose contents are shared with peers; "" = no sharing
DownloadDir string // directory where received files are saved
// 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
mu sync.RWMutex
peers map[proto.PeerID]*PeerConn

View File

@@ -201,7 +201,12 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
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{
Type: proto.MsgFileListResp,
FileListResp: &proto.FileListResp{Files: files},

View File

@@ -10,6 +10,7 @@ import (
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"sync"
@@ -17,6 +18,7 @@ import (
"github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto"
"github.com/waste-go/internal/shares"
"github.com/waste-go/internal/store"
)
@@ -54,13 +56,22 @@ type Manager struct {
subsMu sync.Mutex
subs []chan proto.IpcMessage
Shares *shares.Store // persistent multi-share configuration
}
// New creates a Manager from the given config.
// Loads shares.json from StoreDir if it exists.
func New(cfg Config) *Manager {
sh, err := shares.Load(cfg.StoreDir)
if err != nil {
log.Printf("netmgr: loading shares.json: %v (starting empty)", err)
sh, _ = shares.Load("") // fallback to empty
}
return &Manager{
networks: make(map[string]*Network),
cfg: cfg,
Shares: sh,
}
}
@@ -100,6 +111,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
m.ShareDir = mgr.cfg.ShareDir
}
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
capturedNetID := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) }
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
meshEvents := m.Subscribe()
@@ -183,6 +196,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
m.ShareDir = mgr.cfg.ShareDir
}
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
capturedNetID2 := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
meshEvents := m.Subscribe()
go func() {
@@ -313,6 +328,50 @@ func (mgr *Manager) All() []*Network {
return out
}
// ScanAllShares returns file entries from all share roots visible to networkID,
// combining the network's legacy ShareDir with entries from shares.json.
func (mgr *Manager) ScanAllShares(netID string) []proto.FileEntry {
var files []proto.FileEntry
// Legacy single-dir share from the network mesh.
if n := mgr.Resolve(netID); n != nil {
files = append(files, n.Mesh.ScanShareDir()...)
}
// Additional shares from shares.json.
if mgr.Shares != nil {
for _, sh := range mgr.Shares.ForNetwork(netID) {
files = append(files, scanDir(sh.Path)...)
}
}
return files
}
// scanDir recursively walks a directory and returns FileEntry for each file.
func scanDir(root string) []proto.FileEntry {
var files []proto.FileEntry
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = d.Name()
}
files = append(files, proto.FileEntry{
Name: d.Name(),
SizeBytes: info.Size(),
Path: rel,
})
return nil
})
return files
}
// AnchorURL returns the configured anchor URL.
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }

View File

@@ -111,6 +111,13 @@ type GossipEntry struct {
type FileEntry struct {
Name string `json:"name"`
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.
@@ -222,6 +229,9 @@ const (
CmdGetFileList IpcMsgType = "get_file_list"
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
// Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received"
@@ -240,6 +250,7 @@ const (
EvtNetworkLeft IpcMsgType = "network_left"
EvtIdentityExported IpcMsgType = "identity_exported"
EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list"
)
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -292,6 +303,8 @@ type IpcMessage struct {
ErrorMessage string `json:"error_message,omitempty"`
InviteString string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"`
Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
// export_identity / import_identity
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
Backup string `json:"backup,omitempty"` // JSON backup blob

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

@@ -49,12 +49,20 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
/* ── sidebar ── */
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-identity { padding: 12px 12px 10px; border-bottom: 1px solid var(--border); }
.sidebar-identity { padding: 12px 12px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; }
.sidebar-identity .alias { display: block; font-weight: 600; font-size: 14px; }
.sidebar-identity .peer-id { display: block; color: var(--muted); font-size: 10px; font-family: monospace; margin-top: 2px; }
.sidebar-logout { background: none; color: var(--muted); font-size: 14px; padding: 2px 4px; flex-shrink: 0; margin-left: auto; }
.sidebar-logout:hover { color: #e06060; background: none; }
.sidebar-section { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid var(--border); }
.sidebar-section:last-child { border-bottom: none; flex: 1; }
.sidebar-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 4px 12px 2px; }
.sidebar-label-row { display: flex; align-items: center; justify-content: space-between; padding-right: 8px; }
.sidebar-label-row .sidebar-label { padding-right: 0; }
.sidebar-add { background: none; color: var(--muted); font-size: 14px; padding: 0 4px; line-height: 1; }
.sidebar-add:hover { color: var(--accent); background: none; }
.sidebar-new-room { padding: 2px 8px 4px; }
.sidebar-new-room input { font-size: 12px; padding: 4px 8px; }
.sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.sidebar-item:hover { background: rgba(255,255,255,0.04); }
.sidebar-item.active { background: var(--accent); color: #fff; }
@@ -64,11 +72,13 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.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; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.peer-row-id { font-size: 10px; color: var(--muted); font-family: monospace; }
.peer-row-actions { opacity: 0; display: flex; gap: 2px; transition: opacity 0.1s; }
.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; }
@@ -78,10 +88,73 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
.message:hover { background: rgba(255,255,255,0.02); }
.message-ts { color: var(--muted); font-size: 11px; min-width: 42px; flex-shrink: 0; }
.message-alias { font-weight: 600; font-size: 13px; min-width: 100px; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 10px 0 8px; flex-shrink: 0; }
.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-text { flex: 1; word-break: break-word; font-size: 14px; color: var(--text); }
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
.compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
.compose input { flex: 1; width: auto; }
.compose button { flex-shrink: 0; }
/* ── transfers panel (in sidebar) ── */
.transfer-row { display: flex; flex-direction: column; gap: 3px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
.transfer-row:last-child { border-bottom: none; }
.transfer-name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.transfer-meta { font-size: 10px; color: var(--muted); }
.transfer-actions { display: flex; gap: 4px; margin-top: 2px; }
.transfer-btn { font-size: 11px; padding: 2px 8px; }
.transfer-btn.accept { background: #3a7a3a; }
.transfer-btn.reject { background: #7a3a3a; }
.transfer-btn:hover { opacity: 0.85; }
.transfer-progress { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 2px; }
.transfer-progress-bar { height: 100%; background: var(--accent); transition: width 0.1s; }
/* ── file browser panel ── */
.chat-layout.has-file-browser { grid-template-columns: var(--sidebar-w) 1fr 280px; }
.file-browser { display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--border); }
.file-browser-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.file-browser-title { font-size: 13px; font-weight: 600; }
.file-browser-close { background: none; color: var(--muted); font-size: 14px; padding: 2px 6px; }
.file-browser-close:hover { color: var(--text); background: none; }
.file-browser-empty { padding: 16px 12px; color: var(--muted); font-size: 13px; }
.file-list { list-style: none; padding: 4px 0; overflow-y: auto; flex: 1; }
.file-entry { display: flex; align-items: center; gap: 6px; padding: 5px 12px; }
.file-entry:hover { background: rgba(255,255,255,0.04); }
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
.file-entry-dl:hover { background: 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,19 +4,20 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat'
import './App.css'
// When served from the anchor (non-localhost), default to browser mode.
// When running locally, try the daemon first.
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
// Use browser mode if a signal URL is configured, even on localhost
const useBrowser = !isLocal || !!cfg?.signalURL
export default function App() {
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => {
if (isLocal) {
connect(DAEMON_WS)
} else {
if (useBrowser) {
connectBrowser()
} else {
connect(DAEMON_WS)
}
}, [connect, connectBrowser])

View File

@@ -16,6 +16,27 @@ 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 {
@@ -245,14 +266,25 @@ class PeerConn {
public peerId: string
private on: PeerCallback
private nick: string
share: Map<string, File>
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
// 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.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
this.share = share
this.pc = new RTCPeerConnection({ iceServers: ice })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
this._epk = kp.publicKey
@@ -349,11 +381,33 @@ class PeerConn {
}
private _wire(channel: RTCDataChannel) {
if (channel.label !== 'yaw') return
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
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() {
@@ -385,9 +439,101 @@ class PeerConn {
})
} 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()
@@ -449,6 +595,7 @@ export class BrowserAdapter {
private present: Set<string> = new Set()
private networkId = ''
private networkName = ''
sharedFiles: Map<string, File> = new Map()
status: Status = 'disconnected'
onStatusChange?: (s: Status) => void
@@ -577,9 +724,11 @@ export class BrowserAdapter {
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)
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
this.peers.set(pid, peer)
this._emitDiscovered(pid)
await peer.startOffer()
}
@@ -587,8 +736,10 @@ export class BrowserAdapter {
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)
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles, ice)
this._emitDiscovered(from)
this.peers.set(from, peer)
}
await peer.onBox(box)
@@ -645,9 +796,86 @@ export class BrowserAdapter {
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

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

@@ -45,7 +45,7 @@ export function MessagePane() {
{roomMessages.map((msg, i) => {
const mine = msg.from === localPeer?.id
const alias = aliasFor(msg.from)
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
return (
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{time}</span>

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,5 +1,8 @@
import { useState } from 'react'
import { useWaste } from '../store'
import type { PeerStatus } from '../store'
import { ShareManager } from './ShareManager'
import { Transfers } from './Transfers'
function makeYawCard(id: string, alias: string): string {
const nick = encodeURIComponent(alias.trim().slice(0, 40))
@@ -28,36 +31,54 @@ export function Sidebar() {
localPeer, masterId, masterAlias,
networks, activeNetworkId, activeRoom,
connectedPeers, peerStatus,
setActiveRoom, setActiveNetwork, messages, send,
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
customRooms, createRoom, logout,
} = useWaste()
const [addingRoom, setAddingRoom] = useState(false)
const [newRoomName, setNewRoomName] = useState('')
const rooms = ['general']
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
const rooms = ['general', ...netCustomRooms]
Object.keys(messages).forEach(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 displayId = localPeer?.id ?? masterId ?? ''
const card = displayId ? makeYawCard(displayId, displayAlias) : null
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) {
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId })
browseFiles(peerId)
}
return (
<nav className="sidebar">
<div
className="sidebar-identity"
title={card ?? undefined}
onClick={() => card && navigator.clipboard?.writeText(card)}
style={{ cursor: card ? 'pointer' : undefined }}
>
<span className="alias">{displayAlias || '…'}</span>
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
<div className="sidebar-identity">
<div
style={{ flex: 1, cursor: card ? 'pointer' : undefined }}
title={card ?? undefined}
onClick={() => card && navigator.clipboard?.writeText(card)}
>
<span className="alias">{displayAlias || '…'}</span>
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
</div>
<button className="sidebar-logout" onClick={handleLogout} title="Leave network"></button>
</div>
<div className="sidebar-section">
@@ -74,7 +95,10 @@ export function Sidebar() {
</div>
<div className="sidebar-section">
<span className="sidebar-label">Rooms</span>
<div className="sidebar-label-row">
<span className="sidebar-label">Rooms</span>
<button className="sidebar-add" onClick={() => setAddingRoom(v => !v)} title="New room">+</button>
</div>
{rooms.map(r => (
<button
key={r}
@@ -84,12 +108,40 @@ export function Sidebar() {
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`}
</button>
))}
{addingRoom && (
<form className="sidebar-new-room" onSubmit={submitNewRoom}>
<input
autoFocus
value={newRoomName}
onChange={e => setNewRoomName(e.target.value)}
placeholder="room-name"
onKeyDown={e => e.key === 'Escape' && (setAddingRoom(false), setNewRoomName(''))}
/>
</form>
)}
</div>
{adapterMode === 'browser' && (
<div className="sidebar-section">
<span className="sidebar-label">Sharing</span>
<div style={{ padding: '4px 12px' }}><ShareManager /></div>
</div>
)}
<Transfers />
<div className="sidebar-section sidebar-peers">
<span className="sidebar-label">Peers {connectedPeers.length > 0 && `· ${connectedPeers.length}`}</span>
<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 yet</span>
<span className="sidebar-empty">no peers connected</span>
)}
{connectedPeers.map(p => {
const st = peerStatus[p.id]
@@ -108,7 +160,15 @@ export function Sidebar() {
<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="Files"></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>
)

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

View File

@@ -61,16 +61,28 @@ export function Onboarding({ status }: Props) {
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) {
e.preventDefault()
const name = network.trim()
const hash = netHash.trim()
doJoin(network.trim(), netHash.trim())
}
function doJoin(name: string, hash: string) {
if (!name && !hash) return
// In browser mode: persist anchor URL choice and pass it via localStorage
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) {

View File

@@ -33,6 +33,8 @@ interface WasteState {
// chat — keyed by room
messages: Record<string, ChatMessage[]>
activeRoom: string
// user-created rooms, keyed by networkId
customRooms: Record<string, string[]>
// file listings — keyed by peer id
fileLists: Record<string, FileEntry[]>
@@ -43,6 +45,15 @@ interface WasteState {
// identity backup export result (cleared when consumed)
exportedBackup: string | null
// file browser state
activeFilePeer: string | null
// shared files keyed by networkId
sharedFilesByNetwork: Record<string, Map<string, File>>
// incoming offers awaiting accept/reject: xid → offer info
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// actions
connect: (url: string) => void
connectBrowser: () => void
@@ -50,6 +61,15 @@ interface WasteState {
send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void
setActiveRoom: (room: string) => void
setActiveFilePeer: (peerId: string | null) => void
setSharedFiles: (files: Map<string, File>, networkId?: string) => void
browseFiles: (peerId: string) => void
sendFileTo: (peerId: string, file: File) => void
acceptOffer: (peerId: string, xid: string, name: string, size: number) => void
rejectOffer: (peerId: string, xid: string) => void
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
createRoom: (name: string) => void
logout: (clearIdentity: boolean) => void
handleEvent: (msg: IpcMessage) => void
}
@@ -65,9 +85,14 @@ export const useWaste = create<WasteState>((set, get) => ({
connectedPeers: [],
messages: {},
activeRoom: 'general',
customRooms: {},
fileLists: {},
exportedBackup: null,
peerStatus: {},
activeFilePeer: null,
sharedFilesByNetwork: {},
pendingOffers: {},
fileProgress: {},
connect(url: string) {
const adapter = new DaemonAdapter(url)
@@ -104,6 +129,82 @@ export const useWaste = create<WasteState>((set, get) => ({
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) {
switch (msg.type) {
case 'state_snapshot': {
@@ -146,7 +247,7 @@ export const useWaste = create<WasteState>((set, get) => ({
if (msg.peer) {
set(s => ({
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!],
}))
}
@@ -207,6 +308,30 @@ export const useWaste = create<WasteState>((set, get) => ({
if (msg.backup) set({ exportedBackup: msg.backup })
break
}
case 'incoming_file': {
if (msg.peer_id && msg.offer) {
const { xid, name, size } = msg.offer
set(s => ({ pendingOffers: { ...s.pendingOffers, [xid]: { peerId: String(msg.peer_id), name, size } } }))
}
break
}
case 'file_progress': {
if (msg.transfer_id && msg.peer_id) {
const xid = msg.transfer_id
set(s => ({
fileProgress: {
...s.fileProgress,
[xid]: {
peerId: String(msg.peer_id),
name: msg.offer?.name ?? xid,
received: msg.bytes_received ?? 0,
total: msg.total_bytes ?? 0,
},
},
}))
}
break
}
case 'file_list': {
if (msg.peer_id && msg.files) {
set(s => ({
@@ -215,6 +340,18 @@ export const useWaste = create<WasteState>((set, get) => ({
}
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 {
name: string
size_bytes: number
path?: string // relative path including filename, e.g. "docs/report.pdf"
}
export interface ShareEntry {
path: string
networks: string[] // ["*"] = global
}
export interface FileOffer {
@@ -55,6 +61,9 @@ export type IpcMsgType =
| 'get_file_list'
| 'export_identity'
| 'import_identity'
| 'add_share'
| 'remove_share'
| 'list_shares'
// events
| 'state_snapshot'
| 'message_received'
@@ -70,6 +79,7 @@ export type IpcMsgType =
| 'invite_generated'
| 'identity_exported'
| 'identity_imported'
| 'shares_list'
| 'peer_status'
| 'error'
@@ -108,6 +118,8 @@ export interface IpcMessage {
error_message?: string
invite?: string
files?: FileEntry[]
shares?: ShareEntry[]
networks_filter?: string[] // for add_share
// peer_status
conn_state?: PeerConnState
candidate_type?: CandidateType