package live import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "sync" ) // Preset maps one PipeWire capture source to a set of Sendspin clients. // Members are sendspin `client_id` values, not friendly names. type Preset struct { ID string `json:"id"` Name string `json:"name"` Source string `json:"source"` Members []string `json:"members"` } var idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func (p *Preset) validate() error { if p.ID == "" { return fmt.Errorf("id is required") } if !idRe.MatchString(p.ID) { return fmt.Errorf("id must be lowercase kebab-case") } if p.Name == "" { return fmt.Errorf("name is required") } if p.Source == "" { return fmt.Errorf("source is required") } return nil } // Store is an in-memory, file-backed preset collection. Writes are // serialized; the file is rewritten atomically on every change. type Store struct { mu sync.RWMutex path string presets map[string]Preset onWrite func() } func NewStore(path string) (*Store, error) { s := &Store{ path: path, presets: make(map[string]Preset), } if err := s.load(); err != nil { return nil, err } return s, nil } // OnWrite registers a callback that runs after every successful write. // Used by the manager to refresh active state when the active preset // changes underneath it. func (s *Store) OnWrite(fn func()) { s.mu.Lock() s.onWrite = fn s.mu.Unlock() } func (s *Store) load() error { data, err := os.ReadFile(s.path) if err != nil { if os.IsNotExist(err) { return nil } return err } if len(data) == 0 { return nil } var list []Preset if err := json.Unmarshal(data, &list); err != nil { return fmt.Errorf("parse %s: %w", s.path, err) } for _, p := range list { s.presets[p.ID] = p } return nil } func (s *Store) writeLocked() error { if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } list := make([]Preset, 0, len(s.presets)) for _, p := range s.presets { list = append(list, p) } sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } if err := os.Rename(tmp, s.path); err != nil { return err } if s.onWrite != nil { go s.onWrite() } return nil } func (s *Store) List() []Preset { s.mu.RLock() defer s.mu.RUnlock() out := make([]Preset, 0, len(s.presets)) for _, p := range s.presets { out = append(out, p) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func (s *Store) Get(id string) (Preset, bool) { s.mu.RLock() defer s.mu.RUnlock() p, ok := s.presets[id] return p, ok } func (s *Store) Put(p Preset) error { if err := p.validate(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() s.presets[p.ID] = p return s.writeLocked() } // Create adds a preset, failing if the ID already exists. func (s *Store) Create(p Preset) error { if err := p.validate(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.presets[p.ID]; exists { return fmt.Errorf("preset %q already exists", p.ID) } s.presets[p.ID] = p return s.writeLocked() } func (s *Store) Delete(id string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.presets[id]; !ok { return false, nil } delete(s.presets, id) return true, s.writeLocked() }