From e0704f210c45d714d5e4876ea10e0310173135fe Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Fri, 26 Jun 2026 20:47:11 +0200 Subject: [PATCH] feat: persistent multi-share configuration (shares.json + localStorage) Daemon side: - internal/shares: new package reads/writes shares.json next to identity - proto: add ShareEntry type, add_share/remove_share/list_shares IPC commands, shares_list event, path field on FileEntry - netmgr: load shares.json on startup; ScanAllShares combines legacy ShareDir with shares.json entries (recursive walk with relative paths) - mesh: ScanFiles callback lets manager inject multi-share scanning without mesh knowing about shares.json - ipc: handle add_share, remove_share, list_shares commands Browser side: - ShareManager component replaces FolderPicker: shows list of named shares with remove/re-pick buttons; persists share records to waste_shares in localStorage (name, global flag, networkId) - FolderPicker retained for internal use; Sidebar now uses ShareManager Co-Authored-By: Claude Sonnet 4.6 --- internal/ipc/ipc.go | 44 ++++++++++- internal/mesh/mesh.go | 3 + internal/mesh/peer.go | 7 +- internal/netmgr/manager.go | 59 ++++++++++++++ internal/proto/proto.go | 13 +++ internal/shares/shares.go | 99 +++++++++++++++++++++++ web/src/App.css | 12 +++ web/src/components/ShareManager.tsx | 118 ++++++++++++++++++++++++++++ web/src/components/Sidebar.tsx | 4 +- web/src/types.ts | 11 +++ 10 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 internal/shares/shares.go create mode 100644 web/src/components/ShareManager.tsx diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 71d1681..5e22874 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -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 { diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index e1489e8..5720e90 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -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 diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index a5888df..5eaa479 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -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}, diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index e89e2ef..fc83f01 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -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 } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index b56b263..56493ab 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -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 diff --git a/internal/shares/shares.go b/internal/shares/shares.go new file mode 100644 index 0000000..e954e7c --- /dev/null +++ b/internal/shares/shares.go @@ -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 +} diff --git a/web/src/App.css b/web/src/App.css index 8471edf..3c284c1 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -125,6 +125,18 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; } .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; } diff --git a/web/src/components/ShareManager.tsx b/web/src/components/ShareManager.tsx new file mode 100644 index 0000000..2845a36 --- /dev/null +++ b/web/src/components/ShareManager.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect, 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(loadShares) + const [includeSubfolders, setIncludeSubfolders] = useState(true) + const inputRef = useRef(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() + 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) { + 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 ( +
+ + + {shares.length > 0 && ( +
    + {shares.map(s => ( +
  • + 📁 + {s.name} + {s.global ? 'all nets' : 'this net'} + + +
  • + ))} +
+ )} + + + + +
+ ) +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index a0140d0..f500967 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useWaste } from '../store' import type { PeerStatus } from '../store' -import { FolderPicker } from './FolderPicker' +import { ShareManager } from './ShareManager' import { Transfers } from './Transfers' function makeYawCard(id: string, alias: string): string { @@ -124,7 +124,7 @@ export function Sidebar() { {adapterMode === 'browser' && (
Sharing -
+
)} diff --git a/web/src/types.ts b/web/src/types.ts index da6b5ba..a612267 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -32,6 +32,11 @@ export interface FileEntry { path?: string // relative path including filename, e.g. "docs/report.pdf" } +export interface ShareEntry { + path: string + networks: string[] // ["*"] = global +} + export interface FileOffer { xid: string name: string @@ -56,6 +61,9 @@ export type IpcMsgType = | 'get_file_list' | 'export_identity' | 'import_identity' + | 'add_share' + | 'remove_share' + | 'list_shares' // events | 'state_snapshot' | 'message_received' @@ -71,6 +79,7 @@ export type IpcMsgType = | 'invite_generated' | 'identity_exported' | 'identity_imported' + | 'shares_list' | 'peer_status' | 'error' @@ -109,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