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:
Fredrik Johansson
2026-06-26 20:47:11 +02:00
parent 0f54f3bbad
commit e0704f210c
10 changed files with 366 additions and 4 deletions

View File

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