// Package switcher is the one place a preset actually gets applied. // // It exists because there are now two front ends that can switch a preset — // the tray, which confirms with a native MessageBox, and the web editor, which // confirms in the browser — and the sequence between "user said yes" and "the // adapter changed" must not be duplicated. Confirmation is the caller's job; // everything after it is here. package switcher import ( "errors" "fmt" "log" "sync" "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" "gitea.apointless.space/bsncubed/ipswap/internal/preset" ) // ErrNotFound is returned when the id does not name a preset. var ErrNotFound = errors.New("no such preset") // Switcher reads adapter state and applies presets. type Switcher struct { store *preset.Store mgr netcfg.Manager // applying serialises applies. Two concurrent netsh sequences against one // adapter would interleave their deletes and sets and leave the adapter in // a state matching neither preset, and the tray and the browser can now // both start one. applying sync.Mutex } // New builds a Switcher. func New(store *preset.Store, mgr netcfg.Manager) *Switcher { return &Switcher{store: store, mgr: mgr} } // Preview returns a preset alongside the live configuration of its adapter, // read at call time. This is what both confirmation prompts are built from, so // it must never serve a cached view — a stale "CURRENT" block would undermine // the only safety net before a destructive change. func (s *Switcher) Preview(id string) (preset.Preset, netcfg.LiveConfig, error) { p, ok := s.store.Get(id) if !ok { return preset.Preset{}, netcfg.LiveConfig{}, ErrNotFound } live, err := s.mgr.Current(p.Adapter) if err != nil { return p, netcfg.LiveConfig{}, fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err) } return p, live, nil } // Apply switches the adapter to the preset. The caller is responsible for // having confirmed with the user first. // // The live configuration is read again here rather than taken from a preceding // Preview: the plan's delete step is built from the addresses actually on the // adapter, and between the prompt appearing and the user clicking Yes, a DHCP // lease or another tool may have changed them. func (s *Switcher) Apply(id string) error { s.applying.Lock() defer s.applying.Unlock() p, ok := s.store.Get(id) if !ok { return ErrNotFound } live, err := s.mgr.Current(p.Adapter) if err != nil { return fmt.Errorf("reading the current configuration of adapter %q: %w", p.Adapter, err) } log.Printf("applying preset %q to %q", p.Name, p.Adapter) if err := s.mgr.Apply(netcfg.Plan(p, live)); err != nil { log.Printf("apply of %q failed: %v", p.Name, err) return err } log.Printf("applied preset %q", p.Name) return nil } // State is a snapshot of which preset is live. type State struct { // ActiveID is the preset exactly matching its adapter, empty if none does. ActiveID string `json:"active_id"` ActiveName string `json:"active_name"` Adapters map[string]netcfg.LiveConfig `json:"adapters"` } // Active works out which preset, if any, is currently applied. // // One read per distinct adapter rather than one per preset: fifty presets // spread across three adapters is three reads, not fifty. This runs every 30 // seconds, so the difference matters. func (s *Switcher) Active() State { presets := s.store.All() st := State{Adapters: make(map[string]netcfg.LiveConfig)} for _, p := range presets { if _, done := st.Adapters[p.Adapter]; done { continue } cfg, err := s.mgr.Current(p.Adapter) if err != nil { continue } st.Adapters[p.Adapter] = cfg } for _, p := range presets { if cfg, ok := st.Adapters[p.Adapter]; ok && netcfg.Matches(p, cfg) { st.ActiveID, st.ActiveName = p.ID, p.Name break } } return st } // ConfirmText renders the before/after text for a preset, for callers that // want the prompt body without applying anything. func (s *Switcher) ConfirmText(p preset.Preset, live netcfg.LiveConfig) string { return netcfg.ConfirmText(p, live) }