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

@@ -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 {

View File

@@ -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

View File

@@ -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},

View File

@@ -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 }

View File

@@ -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
View 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
}

View File

@@ -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; }

View 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>
)
}

View File

@@ -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>
)} )}

View File

@@ -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