package ui import ( "errors" "os" "sync" "time" "git.wxccs.org/iceking2nd/winauth-go/internal/config" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // store wraps the on-disk YAML config plus the in-memory passphrase. // All writes go through an async, coalescing worker: callers Push() and // the worker debounces rapid bursts (e.g. HOTP code clicks) into a single // disk write. // // passphrase is held in memory for the lifetime of the process. We do not // attempt to zero it after use — Go's garbage collector may move strings // around freely, so secure-erase is largely placebo and would only buy a // false sense of security. We instead enforce that it is never logged. type store struct { path string mu sync.Mutex passphrase []byte encrypted bool // Top-level user preferences kept alongside entries so save() can // roundtrip them without requiring the UI to rebuild a full Config. language string theme string autoLockMinutes int minimizeToTray bool // dirty signals a save is pending. The worker reads & resets it. dirty bool snapshotFn func() []config.Entry pendingErr error saveTrigger chan struct{} // onError is called from the save goroutine when a write fails. // The caller is responsible for marshalling back to the UI thread. onError func(error) } // Preferences returns the persisted top-level prefs. UI code reads these // after Load to seed dialog defaults. func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray bool) { s.mu.Lock() defer s.mu.Unlock() return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray } // SetPreferences updates the persisted top-level prefs and schedules a // save. Callers pass the current value for each field — there is no // per-field "leave unchanged" sentinel. func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray bool) { s.mu.Lock() s.language = language s.theme = theme s.autoLockMinutes = autoLockMinutes s.minimizeToTray = minimizeToTray s.mu.Unlock() s.Push() } // newStore initializes a store and starts the background save worker. // snapshotFn is invoked whenever a save runs; it must return a freshly // copied entries slice (the worker holds no UI locks). onError is called // asynchronously from the worker goroutine on save failures. func newStore(path string, snapshotFn func() []config.Entry, onError func(error)) *store { s := &store{ path: path, snapshotFn: snapshotFn, onError: onError, saveTrigger: make(chan struct{}, 1), } go s.run() return s } // Load reads the YAML file at path. If the file does not exist, returns // (nil, nil) — the caller should treat that as an empty config. If the // file is encrypted, passphrase must be valid; otherwise ErrPasswordRequired // or ErrPasswordWrong is returned. // // On success the store's passphrase + encrypted flag are updated. func (s *store) Load(passphrase []byte) (*config.Config, error) { const fn = "internal.ui.store.Load" logger := global.Log.WithField("func", fn).WithField("path", s.path) if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) { logger.Debug("config file does not exist; starting empty") s.mu.Lock() s.passphrase = nil s.encrypted = false s.mu.Unlock() return nil, nil } // First load: peek the header (no passphrase) to learn encrypted-ness. cfg, err := config.LoadYAML(s.path, passphrase) if err != nil { switch { case errors.Is(err, config.ErrPasswordRequired): return cfg, ErrPasswordRequired case errors.Is(err, config.ErrPasswordWrong): return cfg, ErrPasswordWrong default: return nil, err } } s.mu.Lock() s.passphrase = passphrase s.encrypted = cfg.Encrypted s.language = cfg.Language s.theme = cfg.Theme s.autoLockMinutes = cfg.AutoLockMinutes s.minimizeToTray = cfg.MinimizeToTray s.mu.Unlock() logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store") return cfg, nil } // Sentinel errors returned by store.Load to signal the password UI path. // We re-export config's sentinels here so the UI layer doesn't need to // import internal/config directly. var ( ErrPasswordRequired = config.ErrPasswordRequired ErrPasswordWrong = config.ErrPasswordWrong ) // SetPassword updates the in-memory passphrase. An empty value disables // encryption on the next save. The change is queued for save immediately. func (s *store) SetPassword(pw []byte) { s.mu.Lock() s.passphrase = pw s.encrypted = len(pw) > 0 s.mu.Unlock() s.Push() } // Encrypted reports whether the store will encrypt the next write. func (s *store) Encrypted() bool { s.mu.Lock() defer s.mu.Unlock() return s.encrypted } // Push schedules a save. Calls within ~300ms of each other coalesce into // a single write. func (s *store) Push() { s.mu.Lock() s.dirty = true s.mu.Unlock() select { case s.saveTrigger <- struct{}{}: default: } } // LastError returns the most recent save error, if any. func (s *store) LastError() error { s.mu.Lock() defer s.mu.Unlock() return s.pendingErr } func (s *store) run() { const fn = "internal.ui.store.run" for range s.saveTrigger { // debounce: wait briefly to coalesce bursts time.Sleep(300 * time.Millisecond) s.mu.Lock() if !s.dirty { s.mu.Unlock() continue } s.dirty = false pw := append([]byte(nil), s.passphrase...) enc := s.encrypted lang := s.language theme := s.theme autoLock := s.autoLockMinutes minToTray := s.minimizeToTray s.mu.Unlock() entries := s.snapshotFn() cfg := &config.Config{ Version: 1, Language: lang, Theme: theme, AutoLockMinutes: autoLock, MinimizeToTray: minToTray, Encrypted: enc, Entries: entries, } if err := config.SaveYAML(cfg, s.path, pw); err != nil { global.Log.WithField("func", fn).WithError(err).Error("save failed") s.mu.Lock() s.pendingErr = err s.mu.Unlock() if s.onError != nil { s.onError(err) } continue } s.mu.Lock() s.pendingErr = nil s.mu.Unlock() } }