Compare commits
19 Commits
478a0a32af
...
0e8ddbf4f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8ddbf4f4 | ||
|
|
f326ff2605 | ||
|
|
e0704f210c | ||
|
|
0f54f3bbad | ||
|
|
80e05b81ac | ||
|
|
2c71d9c5c6 | ||
|
|
dfbdd34aaa | ||
|
|
295851f966 | ||
|
|
02eb83b63a | ||
|
|
ea1eb767f1 | ||
|
|
c4032417ae | ||
|
|
fb14ca82af | ||
|
|
5421352e62 | ||
|
|
068e7e6566 | ||
|
|
91b7406d01 | ||
|
|
ea66e2eb58 | ||
|
|
076f9641c2 | ||
|
|
1d38766006 | ||
|
|
f5a11cb22b |
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go build *)"
|
||||
"Bash(go build *)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
83
FUTURE.md
83
FUTURE.md
@@ -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
121
README.md
@@ -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":"..."}
|
||||
```
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
99
internal/shares/shares.go
Normal 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
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
192
web/src/components/FileBrowser.tsx
Normal file
192
web/src/components/FileBrowser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
web/src/components/FolderPicker.tsx
Normal file
58
web/src/components/FolderPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
118
web/src/components/ShareManager.tsx
Normal file
118
web/src/components/ShareManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
51
web/src/components/Transfers.tsx
Normal file
51
web/src/components/Transfers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user