feat: persistent multi-share configuration (shares.json + localStorage)
Daemon side: - internal/shares: new package reads/writes shares.json next to identity - proto: add ShareEntry type, add_share/remove_share/list_shares IPC commands, shares_list event, path field on FileEntry - netmgr: load shares.json on startup; ScanAllShares combines legacy ShareDir with shares.json entries (recursive walk with relative paths) - mesh: ScanFiles callback lets manager inject multi-share scanning without mesh knowing about shares.json - ipc: handle add_share, remove_share, list_shares commands Browser side: - ShareManager component replaces FolderPicker: shows list of named shares with remove/re-pick buttons; persists share records to waste_shares in localStorage (name, global flag, networkId) - FolderPicker retained for internal use; Sidebar now uses ShareManager Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/waste-go/internal/invite"
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/netmgr"
|
"github.com/waste-go/internal/netmgr"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
|
"github.com/waste-go/internal/shares"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort.
|
// RunWS starts a WebSocket IPC server on 127.0.0.1:wsPort.
|
||||||
@@ -246,7 +247,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
Type: proto.EvtFileList,
|
Type: proto.EvtFileList,
|
||||||
NetworkID: n.ID,
|
NetworkID: n.ID,
|
||||||
PeerID: ptr(n.Identity.PeerID()),
|
PeerID: ptr(n.Identity.PeerID()),
|
||||||
Files: n.Mesh.ScanShareDir(),
|
Files: mgr.ScanAllShares(n.ID),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
|
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
|
||||||
@@ -258,6 +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:
|
case proto.CmdGenerateInvite:
|
||||||
n := mgr.Resolve(cmd.NetworkID)
|
n := mgr.Resolve(cmd.NetworkID)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -389,6 +419,18 @@ func errMsg(s string) proto.IpcMessage {
|
|||||||
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sharesListMsg(mgr *netmgr.Manager) proto.IpcMessage {
|
||||||
|
all := mgr.Shares.All()
|
||||||
|
entries := make([]proto.ShareEntry, len(all))
|
||||||
|
for i, sh := range all {
|
||||||
|
entries[i] = proto.ShareEntry{Path: sh.Path, Networks: sh.Networks}
|
||||||
|
}
|
||||||
|
return proto.IpcMessage{Type: proto.EvtSharesList, Shares: entries}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure shares import is used
|
||||||
|
var _ = shares.Share{}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
func randomHex(n int) string {
|
func randomHex(n int) string {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type Mesh struct {
|
|||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
DownloadDir string // directory where received files are saved
|
DownloadDir string // directory where received files are saved
|
||||||
|
// 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
|
mu sync.RWMutex
|
||||||
peers map[proto.PeerID]*PeerConn
|
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})
|
m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: chat})
|
||||||
|
|
||||||
case proto.MsgFileListReq:
|
case proto.MsgFileListReq:
|
||||||
files := m.ScanShareDir()
|
var files []proto.FileEntry
|
||||||
|
if m.ScanFiles != nil {
|
||||||
|
files = m.ScanFiles()
|
||||||
|
} else {
|
||||||
|
files = m.ScanShareDir()
|
||||||
|
}
|
||||||
resp, err := json.Marshal(proto.PeerMessage{
|
resp, err := json.Marshal(proto.PeerMessage{
|
||||||
Type: proto.MsgFileListResp,
|
Type: proto.MsgFileListResp,
|
||||||
FileListResp: &proto.FileListResp{Files: files},
|
FileListResp: &proto.FileListResp{Files: files},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
"github.com/waste-go/internal/mesh"
|
"github.com/waste-go/internal/mesh"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
|
"github.com/waste-go/internal/shares"
|
||||||
"github.com/waste-go/internal/store"
|
"github.com/waste-go/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,13 +56,22 @@ type Manager struct {
|
|||||||
|
|
||||||
subsMu sync.Mutex
|
subsMu sync.Mutex
|
||||||
subs []chan proto.IpcMessage
|
subs []chan proto.IpcMessage
|
||||||
|
|
||||||
|
Shares *shares.Store // persistent multi-share configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Manager from the given config.
|
// New creates a Manager from the given config.
|
||||||
|
// Loads shares.json from StoreDir if it exists.
|
||||||
func New(cfg Config) *Manager {
|
func New(cfg Config) *Manager {
|
||||||
|
sh, err := shares.Load(cfg.StoreDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("netmgr: loading shares.json: %v (starting empty)", err)
|
||||||
|
sh, _ = shares.Load("") // fallback to empty
|
||||||
|
}
|
||||||
return &Manager{
|
return &Manager{
|
||||||
networks: make(map[string]*Network),
|
networks: make(map[string]*Network),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
Shares: sh,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +111,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
|
|||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
|
m.DownloadDir = filepath.Join(mgr.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.
|
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
@@ -183,6 +196,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
|
|||||||
m.ShareDir = mgr.cfg.ShareDir
|
m.ShareDir = mgr.cfg.ShareDir
|
||||||
}
|
}
|
||||||
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
|
||||||
|
capturedNetID2 := netID
|
||||||
|
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
|
||||||
|
|
||||||
meshEvents := m.Subscribe()
|
meshEvents := m.Subscribe()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -313,6 +328,50 @@ func (mgr *Manager) All() []*Network {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanAllShares returns file entries from all share roots visible to networkID,
|
||||||
|
// combining the network's legacy ShareDir with entries from shares.json.
|
||||||
|
func (mgr *Manager) ScanAllShares(netID string) []proto.FileEntry {
|
||||||
|
var files []proto.FileEntry
|
||||||
|
|
||||||
|
// Legacy single-dir share from the network mesh.
|
||||||
|
if n := mgr.Resolve(netID); n != nil {
|
||||||
|
files = append(files, n.Mesh.ScanShareDir()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional shares from shares.json.
|
||||||
|
if mgr.Shares != nil {
|
||||||
|
for _, sh := range mgr.Shares.ForNetwork(netID) {
|
||||||
|
files = append(files, scanDir(sh.Path)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDir recursively walks a directory and returns FileEntry for each file.
|
||||||
|
func scanDir(root string) []proto.FileEntry {
|
||||||
|
var files []proto.FileEntry
|
||||||
|
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
rel = d.Name()
|
||||||
|
}
|
||||||
|
files = append(files, proto.FileEntry{
|
||||||
|
Name: d.Name(),
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
Path: rel,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
// AnchorURL returns the configured anchor URL.
|
// AnchorURL returns the configured anchor URL.
|
||||||
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
|
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ type GossipEntry struct {
|
|||||||
type FileEntry struct {
|
type FileEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Path string `json:"path,omitempty"` // relative path including filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareEntry describes one persistent share root.
|
||||||
|
type ShareEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Networks []string `json:"networks"` // ["*"] = global
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileListResp is the payload for MsgFileListResp.
|
// FileListResp is the payload for MsgFileListResp.
|
||||||
@@ -222,6 +229,9 @@ const (
|
|||||||
CmdGetFileList IpcMsgType = "get_file_list"
|
CmdGetFileList IpcMsgType = "get_file_list"
|
||||||
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
||||||
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
||||||
|
CmdAddShare IpcMsgType = "add_share" // add a share root; fields: path, networks
|
||||||
|
CmdRemoveShare IpcMsgType = "remove_share" // remove a share root; field: path
|
||||||
|
CmdListShares IpcMsgType = "list_shares" // returns shares_list event
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -240,6 +250,7 @@ const (
|
|||||||
EvtNetworkLeft IpcMsgType = "network_left"
|
EvtNetworkLeft IpcMsgType = "network_left"
|
||||||
EvtIdentityExported IpcMsgType = "identity_exported"
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
EvtIdentityImported IpcMsgType = "identity_imported"
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
|
EvtSharesList IpcMsgType = "shares_list"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// 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"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteString string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
|
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
||||||
// export_identity / import_identity
|
// export_identity / import_identity
|
||||||
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||||
Backup string `json:"backup,omitempty"` // JSON backup blob
|
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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 { 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); }
|
.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 ── */
|
||||||
.folder-picker { width: 100%; display: flex; flex-direction: column; gap: 4px; }
|
.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 { 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; }
|
||||||
|
|||||||
118
web/src/components/ShareManager.tsx
Normal file
118
web/src/components/ShareManager.tsx
Normal file
@@ -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<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,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
import type { PeerStatus } from '../store'
|
import type { PeerStatus } from '../store'
|
||||||
import { FolderPicker } from './FolderPicker'
|
import { ShareManager } from './ShareManager'
|
||||||
import { Transfers } from './Transfers'
|
import { Transfers } from './Transfers'
|
||||||
|
|
||||||
function makeYawCard(id: string, alias: string): string {
|
function makeYawCard(id: string, alias: string): string {
|
||||||
@@ -124,7 +124,7 @@ export function Sidebar() {
|
|||||||
{adapterMode === 'browser' && (
|
{adapterMode === 'browser' && (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<span className="sidebar-label">Sharing</span>
|
<span className="sidebar-label">Sharing</span>
|
||||||
<div style={{ padding: '4px 12px' }}><FolderPicker /></div>
|
<div style={{ padding: '4px 12px' }}><ShareManager /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface FileEntry {
|
|||||||
path?: string // relative path including filename, e.g. "docs/report.pdf"
|
path?: string // relative path including filename, e.g. "docs/report.pdf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareEntry {
|
||||||
|
path: string
|
||||||
|
networks: string[] // ["*"] = global
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileOffer {
|
export interface FileOffer {
|
||||||
xid: string
|
xid: string
|
||||||
name: string
|
name: string
|
||||||
@@ -56,6 +61,9 @@ export type IpcMsgType =
|
|||||||
| 'get_file_list'
|
| 'get_file_list'
|
||||||
| 'export_identity'
|
| 'export_identity'
|
||||||
| 'import_identity'
|
| 'import_identity'
|
||||||
|
| 'add_share'
|
||||||
|
| 'remove_share'
|
||||||
|
| 'list_shares'
|
||||||
// events
|
// events
|
||||||
| 'state_snapshot'
|
| 'state_snapshot'
|
||||||
| 'message_received'
|
| 'message_received'
|
||||||
@@ -71,6 +79,7 @@ export type IpcMsgType =
|
|||||||
| 'invite_generated'
|
| 'invite_generated'
|
||||||
| 'identity_exported'
|
| 'identity_exported'
|
||||||
| 'identity_imported'
|
| 'identity_imported'
|
||||||
|
| 'shares_list'
|
||||||
| 'peer_status'
|
| 'peer_status'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
|
||||||
@@ -109,6 +118,8 @@ export interface IpcMessage {
|
|||||||
error_message?: string
|
error_message?: string
|
||||||
invite?: string
|
invite?: string
|
||||||
files?: FileEntry[]
|
files?: FileEntry[]
|
||||||
|
shares?: ShareEntry[]
|
||||||
|
networks_filter?: string[] // for add_share
|
||||||
// peer_status
|
// peer_status
|
||||||
conn_state?: PeerConnState
|
conn_state?: PeerConnState
|
||||||
candidate_type?: CandidateType
|
candidate_type?: CandidateType
|
||||||
|
|||||||
Reference in New Issue
Block a user