From 8d3ca9d3311a7e7564c475787a2690ffd0250816 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 21 Jun 2026 19:07:11 +0200 Subject: [PATCH] Add file listing: share-dir flag, file_list_req/resp DataChannel messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proto: FileEntry, FileListResp types; MsgFileListReq/Resp msg types; CmdGetFileList + EvtFileList IPC types; Files field on IpcMessage - mesh: ShareDir field + ScanShareDir(); on DataChannel open, auto-send MsgFileListReq to new peer; handle MsgFileListReq (scan + reply) and MsgFileListResp (emit EvtFileList to IPC subscribers) - ipc: get_file_list command — own list returned immediately; remote peer list requested via DataChannel (response arrives as EvtFileList event) - daemon: -share-dir flag wired to mesh.ShareDir - test scripts: pass -share-dir /home/frejoh/Downloads/{alice,bob,charlie}; test-network.sh verifies each peer's own file list via get_file_list - FUTURE.md: document per-network share directories and multi-network design Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 7 +++++++ FUTURE.md | 9 +++++++++ cmd/daemon/main.go | 13 ++++++++---- internal/ipc/ipc.go | 20 +++++++++++++++++++ internal/mesh/mesh.go | 27 +++++++++++++++++++++++++ internal/mesh/peer.go | 27 +++++++++++++++++++++++++ internal/proto/proto.go | 44 +++++++++++++++++++++++++++++------------ test-network.sh | 24 ++++++++++++++++++++++ test-tui.sh | 9 ++++++--- 9 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..540eac8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(go build *)" + ] + } +} diff --git a/FUTURE.md b/FUTURE.md index 6dc1c18..e54d664 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -59,6 +59,15 @@ No DHT needed at small group scale (10–50 nodes). Keep it simple: - Public key = stable identity, not a mutable nickname - No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases +### Per-Network Share Directories + +The current `-share-dir` flag is a single global directory. Eventually, each network should have its own independent share set: +- Alice shares `/home/alice/Downloads/work-files` on the "work" network +- Alice shares `/home/alice/Music` on the "friends" network +- Peers on "work" never see Alice's music, and vice versa + +When multi-network support lands (see below), the `ShareDir` field on `networkCtx` replaces the current global `Mesh.ShareDir`. The IPC `get_file_list` and daemon `-share-dir` flag should move to be per-network configuration — either via a config file or via an IPC command `set_share_dir` scoped to a `network_id`. + ### Multi-Network Support A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person. diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 1694b77..85c58d7 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -19,10 +19,11 @@ import ( ) func main() { - dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") - alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") - ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") - anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") + dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") + alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") + ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") + anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") + shareDir := flag.String("share-dir", "", "directory to share with peers on the network") joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") flag.Parse() @@ -52,6 +53,10 @@ func main() { defer st.Close() m := mesh.New(id, st) + if *shareDir != "" { + m.ShareDir = expandHome(*shareDir) + log.Printf("daemon: sharing %s", m.ShareDir) + } // joinFn is passed to the IPC layer; it's called when the UI sends join_network. joinFn := func(ctx context.Context, networkName string) { diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index f1413e2..6f41443 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -198,6 +198,26 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork Rooms: []string{"general"}, }) + case proto.CmdGetFileList: + if cmd.PeerID == nil || *cmd.PeerID == m.Identity.PeerID() { + // Local list — scan immediately. + send(proto.IpcMessage{ + Type: proto.EvtFileList, + PeerID: ptr(m.Identity.PeerID()), + Files: m.ScanShareDir(), + }) + } else { + // Remote list — send a request over the DataChannel. + // The response arrives as EvtFileList via the mesh event bus. + req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq}) + if err != nil { + continue + } + if !m.SendTo(*cmd.PeerID, req) { + send(errMsg(fmt.Sprintf("get_file_list: peer %s not connected", (*cmd.PeerID).Short()))) + } + } + case proto.CmdGenerateInvite: net := currentNetwork() if net == "" { diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index fa95a35..c6e8796 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -3,6 +3,7 @@ package mesh import ( "log" + "os" "sync" "github.com/waste-go/internal/crypto" @@ -22,6 +23,7 @@ type PeerConn struct { type Mesh struct { Identity *crypto.Identity Store *store.Store // may be nil if persistence is disabled + ShareDir string // directory whose contents are shared with peers; "" = no sharing mu sync.RWMutex peers map[proto.PeerID]*PeerConn @@ -41,6 +43,31 @@ func New(id *crypto.Identity, st *store.Store) *Mesh { } } +// ScanShareDir returns the list of files in the local share directory. +// Returns an empty slice if ShareDir is unset or the directory is empty. +func (m *Mesh) ScanShareDir() []proto.FileEntry { + if m.ShareDir == "" { + return nil + } + entries, err := os.ReadDir(m.ShareDir) + if err != nil { + log.Printf("mesh: scan share dir %s: %v", m.ShareDir, err) + return nil + } + var files []proto.FileEntry + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, proto.FileEntry{Name: e.Name(), SizeBytes: info.Size()}) + } + return files +} + // ── Peer management ─────────────────────────────────────────────────────────── // AddPeer registers a connected peer and notifies subscribers. diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index a01f289..8d3a2d7 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -56,6 +56,11 @@ func WireDataChannel( } m.AddPeer(peerConn) + // Request the peer's file list immediately after connect. + if req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq}); err == nil { + sendCh <- req + } + go func() { for payload := range sendCh { if err := dc.SendText(string(payload)); err != nil { @@ -147,6 +152,28 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) { switch msg.Type { + case proto.MsgFileListReq: + // Peer wants our file list — reply directly on their send channel. + files := m.ScanShareDir() + resp, err := json.Marshal(proto.PeerMessage{ + Type: proto.MsgFileListResp, + FileListResp: &proto.FileListResp{Files: files}, + }) + if err != nil { + return + } + m.SendTo(from, resp) + + case proto.MsgFileListResp: + // Received a remote peer's file list — forward to IPC subscribers. + if msg.FileListResp != nil { + m.Emit(proto.IpcMessage{ + Type: proto.EvtFileList, + PeerID: peerIDPtr(from), + Files: msg.FileListResp.Files, + }) + } + case proto.MsgChat: if msg.Chat != nil { m.SaveMessage(msg.Chat) diff --git a/internal/proto/proto.go b/internal/proto/proto.go index a5f6c38..71bd553 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -34,13 +34,15 @@ type PeerInfo struct { type MsgType string const ( - MsgChat MsgType = "chat" - MsgPeerGossip MsgType = "peer_gossip" - MsgFileOffer MsgType = "file_offer" - MsgFileResp MsgType = "file_response" - MsgFileDone MsgType = "file_done" - MsgPing MsgType = "ping" - MsgPong MsgType = "pong" + MsgChat MsgType = "chat" + MsgPeerGossip MsgType = "peer_gossip" + MsgFileListReq MsgType = "file_list_req" + MsgFileListResp MsgType = "file_list_resp" + MsgFileOffer MsgType = "file_offer" + MsgFileResp MsgType = "file_response" + MsgFileDone MsgType = "file_done" + MsgPing MsgType = "ping" + MsgPong MsgType = "pong" ) // PeerMessage is the top-level container sent over the "yaw" DataChannel. @@ -49,12 +51,13 @@ type PeerMessage struct { Type MsgType `json:"type"` // Only one of these will be set, depending on Type. - Chat *ChatMessage `json:"chat,omitempty"` - Gossip *PeerGossip `json:"gossip,omitempty"` - FileOffer *FileOffer `json:"file_offer,omitempty"` - FileResp *FileResponse `json:"file_response,omitempty"` - FileDone *FileDone `json:"file_done,omitempty"` - Seq *uint64 `json:"seq,omitempty"` // for ping/pong + Chat *ChatMessage `json:"chat,omitempty"` + Gossip *PeerGossip `json:"gossip,omitempty"` + FileListResp *FileListResp `json:"file_list_resp,omitempty"` + FileOffer *FileOffer `json:"file_offer,omitempty"` + FileResp *FileResponse `json:"file_response,omitempty"` + FileDone *FileDone `json:"file_done,omitempty"` + Seq *uint64 `json:"seq,omitempty"` // for ping/pong } // ChatMessage is a message to a room or a DM. @@ -80,6 +83,18 @@ type GossipEntry struct { LastSeen time.Time `json:"last_seen"` } +// FileEntry describes a single file in a peer's shared directory. +type FileEntry struct { + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` +} + +// FileListResp is the payload for MsgFileListResp. +// MsgFileListReq carries no payload — it is a zero-field request. +type FileListResp struct { + Files []FileEntry `json:"files"` +} + // FileOffer initiates a file transfer. type FileOffer struct { Mid string `json:"mid"` // dedup id @@ -187,6 +202,7 @@ const ( CmdGetState IpcMsgType = "get_state" CmdSendFile IpcMsgType = "send_file" CmdGenerateInvite IpcMsgType = "generate_invite" + CmdGetFileList IpcMsgType = "get_file_list" // Events (daemon → UI) EvtMessageReceived IpcMsgType = "message_received" @@ -198,6 +214,7 @@ const ( EvtStateSnapshot IpcMsgType = "state_snapshot" EvtError IpcMsgType = "error" EvtInviteGenerated IpcMsgType = "invite_generated" + EvtFileList IpcMsgType = "file_list" ) // IpcMessage covers both commands and events. @@ -229,4 +246,5 @@ type IpcMessage struct { Rooms []string `json:"rooms,omitempty"` ErrorMessage string `json:"error_message,omitempty"` InviteString string `json:"invite,omitempty"` + Files []FileEntry `json:"files,omitempty"` } diff --git a/test-network.sh b/test-network.sh index ce902c2..d004b0d 100755 --- a/test-network.sh +++ b/test-network.sh @@ -192,6 +192,7 @@ log "$ALICE_COLOR" "alice" "starting daemon (ipc :${ALICE_IPC})" -data-dir "$DATA_ROOT/alice" \ -ipc-port "$ALICE_IPC" \ -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/alice" \ 2> >(while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) & PIDS+=($!) @@ -201,6 +202,7 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})" -data-dir "$DATA_ROOT/bob" \ -ipc-port "$BOB_IPC" \ -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/bob" \ 2> >(while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) & PIDS+=($!) @@ -210,6 +212,7 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})" -data-dir "$DATA_ROOT/charlie" \ -ipc-port "$CHARLIE_IPC" \ -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/charlie" \ 2> >(while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) & PIDS+=($!) @@ -309,6 +312,27 @@ ipc "$ALICE_IPC" "$(jq -cn \ '{"type":"send_message","room":$room,"body":$body,"to":$to}')" sleep 0.4 +# ── file list check ─────────────────────────────────────────────────────────── +echo "" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" +echo -e "file listing" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" + +for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do + peer_name=$([ "$peer_ipc" = "$ALICE_IPC" ] && echo "alice" || ([ "$peer_ipc" = "$BOB_IPC" ] && echo "bob" || echo "charlie")) + result=$(echo '{"type":"get_file_list"}' \ + | timeout 2 nc 127.0.0.1 "$peer_ipc" 2>/dev/null \ + | grep '"type":"file_list"' | head -1) + if [ -n "$result" ]; then + count=$(echo "$result" | jq '.files | length' 2>/dev/null || echo "?") + files=$(echo "$result" | jq -r '.files[].name' 2>/dev/null | tr '\n' ' ') + echo -e " ${BOLD}${peer_name}${RESET}: ${count} file(s) — ${files}" + else + echo -e " ${RED}${peer_name}: no file_list response${RESET}" + fi +done +sleep 0.5 + # ── leave ───────────────────────────────────────────────────────────────────── echo "" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" diff --git a/test-tui.sh b/test-tui.sh index 200537c..8ada116 100755 --- a/test-tui.sh +++ b/test-tui.sh @@ -77,15 +77,18 @@ ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws" # Daemons "$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \ - -ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null & + -ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/alice" 2>/dev/null & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \ - -ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" 2>/dev/null & + -ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/bob" 2>/dev/null & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \ - -ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null & + -ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \ + -share-dir "/home/frejoh/Downloads/charlie" 2>/dev/null & PIDS+=($!) wait_port "$ALICE_IPC"