package main import ( "encoding/json" "fmt" "os" "strings" "sync" "sync/atomic" ) // SourceConfig is the serialisable description of one data source (for file save/load). type SourceConfig struct { Label string `json:"label"` Addr string `json:"addr"` } // managedSource is the SourceManager's view of one running source. type managedSource struct { id string label string addr string client *UDPClient } // SourceManager owns the lifecycle of all active data sources. // It is the only place where UDPClient instances are created and stopped. type SourceManager struct { mu sync.RWMutex sources map[string]*managedSource hub *Hub filePath string // empty = save not configured nextID atomic.Int32 } // NewSourceManager creates a SourceManager bound to the given hub. // filePath is the default path for Save(); may be empty. func NewSourceManager(hub *Hub, filePath string) *SourceManager { return &SourceManager{ sources: make(map[string]*managedSource), hub: hub, filePath: filePath, } } func (sm *SourceManager) genID() string { return fmt.Sprintf("s%d", sm.nextID.Add(1)) } // Add creates a new source, registers it in the hub, and starts connecting. // Returns the source ID. func (sm *SourceManager) Add(label, addr string) string { if label == "" { label = addr } id := sm.genID() c := NewUDPClient(addr, id, sm.hub) ms := &managedSource{id: id, label: label, addr: addr, client: c} sm.mu.Lock() sm.sources[id] = ms sm.mu.Unlock() // Register in hub (creates hub-side state, broadcasts sources list). sm.hub.AddSource(id, label, addr) go c.Run() return id } // Remove stops the source's client and removes it from the hub. func (sm *SourceManager) Remove(id string) { sm.mu.Lock() ms, ok := sm.sources[id] if ok { delete(sm.sources, id) } sm.mu.Unlock() if ok { ms.client.Stop() sm.hub.RemoveSource(id) } } // Save writes the current source list to filePath. func (sm *SourceManager) Save() error { if sm.filePath == "" { return fmt.Errorf("no sources-file configured (start with --sources-file path)") } sm.mu.RLock() cfgs := make([]SourceConfig, 0, len(sm.sources)) for _, ms := range sm.sources { cfgs = append(cfgs, SourceConfig{Label: ms.label, Addr: ms.addr}) } sm.mu.RUnlock() data, err := json.MarshalIndent(cfgs, "", " ") if err != nil { return err } return os.WriteFile(sm.filePath, data, 0644) } // Load reads sources from path and adds them. Sets filePath for future saves. func (sm *SourceManager) Load(path string) error { data, err := os.ReadFile(path) if err != nil { return err } var cfgs []SourceConfig if err := json.Unmarshal(data, &cfgs); err != nil { return err } sm.filePath = path for _, cfg := range cfgs { sm.Add(cfg.Label, cfg.Addr) } return nil } // ParseSourceArg parses "label@host:port" or "host:port". func ParseSourceArg(s string) (label, addr string) { s = strings.TrimSpace(s) if idx := strings.Index(s, "@"); idx >= 0 { return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]) } return "", s }