feat: per-network share directories + isolation test

Each network now carries its own share dir, set at join_network time via
optional share_dir field or updated live with set_share_dir. The global
-share-dir daemon flag becomes a fallback default.

- proto: add ShareDir/DownloadDir to NetworkInfo and IpcMessage
- netmgr: Join accepts shareDir override; SetShareDir updates live
- ipc: wire join_network share_dir and set_share_dir command
- daemon: remove -share-dir from auto-join path (pass "" for default)
- test-network.sh: per-network join with share_dir; isolation verification
  section confirms alice/friends and alice/work share dirs are independent
- test-tui.sh: join_network with share_dir; peer IDs resolved after join

All tests pass: YAW/2.1 FS, share isolation, file transfer, persistence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 15:13:26 +02:00
parent f437fe94f4
commit d02e18e212
7 changed files with 277 additions and 42 deletions

View File

@@ -117,12 +117,12 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg("join_network: network_name is required"))
continue
}
netID, err := mgr.Join(cmd.NetworkName)
netID, err := mgr.Join(cmd.NetworkName, cmd.ShareDir)
if err != nil {
send(errMsg(fmt.Sprintf("join_network: %v", err)))
continue
}
// network_joined event is emitted by Manager.Join; nothing extra needed.
// network_joined event (with share_dir) is emitted by Manager.Join.
_ = netID
case proto.CmdLeaveNetwork:
@@ -246,6 +246,17 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
InviteString: inv,
})
case proto.CmdSetShareDir:
n := mgr.Resolve(cmd.NetworkID)
if n == nil {
send(errMsg("set_share_dir: not joined to any network"))
continue
}
if !mgr.SetShareDir(n.ID, cmd.Path) {
send(errMsg("set_share_dir: network not found"))
continue
}
case proto.CmdSendFile:
n := mgr.Resolve(cmd.NetworkID)
if n == nil {
@@ -292,6 +303,8 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
NetworkID: n.ID,
NetworkName: n.Name,
LocalPeer: &pi,
ShareDir: n.Mesh.ShareDir,
DownloadDir: n.Mesh.DownloadDir,
})
}
msg.Networks = netInfos
@@ -322,7 +335,7 @@ func randomHex(n int) string {
// It joins the network before the IPC listener starts accepting clients.
func AutoJoin(ctx context.Context, mgr *netmgr.Manager, networkName string) {
_ = ctx // Manager owns the context internally
if _, err := mgr.Join(networkName); err != nil {
if _, err := mgr.Join(networkName, ""); err != nil {
log.Printf("ipc: auto-join %q failed: %v", networkName, err)
}
}

View File

@@ -25,7 +25,7 @@ type Config struct {
MasterIdentity *crypto.Identity
StoreDir string // base directory for per-network SQLite files
AnchorURL string // WebSocket anchor URL used for all networks
ShareDir string // shared file directory (global for now; per-network in future)
ShareDir string // default share directory; overridden per network via Join or SetShareDir
}
// Network is a single joined network context.
@@ -41,6 +41,9 @@ type Network struct {
cancel context.CancelFunc
}
// ShareDir returns the share directory for this network (from the mesh).
func (n *Network) ShareDir() string { return n.Mesh.ShareDir }
// Manager owns all joined networks and fans out their events to IPC subscribers.
type Manager struct {
cfg Config
@@ -63,7 +66,8 @@ func New(cfg Config) *Manager {
// Join creates or rejoins a named network. Returns the network ID.
// If the network is already joined the existing ID is returned immediately.
func (mgr *Manager) Join(name string) (string, error) {
// shareDir overrides the global default for this network; pass "" to use the default.
func (mgr *Manager) Join(name, shareDir string) (string, error) {
netHash := hashNetName(name)
netID := netHash[:8]
@@ -89,7 +93,10 @@ func (mgr *Manager) Join(name string) (string, error) {
}
m := mesh.New(derived, st)
if mgr.cfg.ShareDir != "" {
// Per-network share dir takes precedence over the global default.
if shareDir != "" {
m.ShareDir = shareDir
} else if mgr.cfg.ShareDir != "" {
m.ShareDir = mgr.cfg.ShareDir
}
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
@@ -130,10 +137,11 @@ func (mgr *Manager) Join(name string) (string, error) {
}
mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined,
NetworkID: netID,
Type: proto.EvtNetworkJoined,
NetworkID: netID,
NetworkName: name,
LocalPeer: peerPtr(derived.PeerInfo()),
LocalPeer: peerPtr(derived.PeerInfo()),
ShareDir: m.ShareDir,
})
return netID, nil
@@ -159,6 +167,20 @@ func (mgr *Manager) Leave(netID string) {
mgr.emit(proto.IpcMessage{Type: proto.EvtNetworkLeft, NetworkID: netID})
}
// SetShareDir updates the share directory for an already-joined network.
// Changes take effect immediately for subsequent file-list requests and offers.
func (mgr *Manager) SetShareDir(netID, path string) bool {
mgr.mu.RLock()
net, ok := mgr.networks[netID]
mgr.mu.RUnlock()
if !ok {
return false
}
net.Mesh.ShareDir = path
log.Printf("netmgr: share dir for %q set to %q", net.Name, path)
return true
}
// LeaveAll leaves every joined network.
func (mgr *Manager) LeaveAll() {
mgr.mu.RLock()

View File

@@ -217,6 +217,7 @@ const (
CmdLeaveNetwork IpcMsgType = "leave_network"
CmdGetState IpcMsgType = "get_state"
CmdSendFile IpcMsgType = "send_file"
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
CmdGenerateInvite IpcMsgType = "generate_invite"
CmdGetFileList IpcMsgType = "get_file_list"
@@ -241,6 +242,8 @@ type NetworkInfo struct {
NetworkID string `json:"network_id"`
NetworkName string `json:"network_name"`
LocalPeer *PeerInfo `json:"local_peer,omitempty"`
ShareDir string `json:"share_dir,omitempty"` // absolute path; empty = not sharing
DownloadDir string `json:"download_dir,omitempty"` // absolute path for received files
}
// IpcMessage covers both commands and events.
@@ -258,8 +261,9 @@ type IpcMessage struct {
// join_network / leave_network
NetworkName string `json:"network_name,omitempty"`
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
// send_file
// send_file / set_share_dir / file_complete path
Path string `json:"path,omitempty"`
// events