Files
winauth-go/internal/ui/store.go
T
iceking2nd 1605b5b7ae feat(ui): 开机自启动 + 窗口大小记忆
- win32/autostart: SetAutoStart/IsAutoStartEnabled 操作 HKCU Run 键
- win32/window_size: GetWindowSize 通过 GetWindowRect 获取窗口尺寸
- config: 新增 AutoStart、WindowWidth、WindowHeight 字段
- store: Preferences/SetPreferences 扩展支持 autoStart + windowSize
- dialog_preferences: 新增"开机自启动"复选框
- app.go: 启动时恢复窗口大小,关闭时通过 SetWindowSize 保存
- i18n: label_auto_start / hint_auto_start 三语翻译
2026-06-12 11:10:25 +08:00

250 lines
7.1 KiB
Go

package ui
import (
"crypto/subtle"
"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
autoStart bool
windowWidth int
windowHeight int
// 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, autoStart bool, windowWidth, windowHeight int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray, s.autoStart, s.windowWidth, s.windowHeight
}
// 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, autoStart bool, windowWidth, windowHeight int) {
s.mu.Lock()
s.language = language
s.theme = theme
s.autoLockMinutes = autoLockMinutes
s.minimizeToTray = minimizeToTray
s.autoStart = autoStart
s.windowWidth = windowWidth
s.windowHeight = windowHeight
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.autoStart = cfg.AutoStart
s.windowWidth = cfg.WindowWidth
s.windowHeight = cfg.WindowHeight
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()
}
// VerifyPassword reports whether candidate matches the currently held
// passphrase. Uses subtle.ConstantTimeCompare so equal-length wrong
// guesses do not leak via timing. Holding a copy of the passphrase
// outside the store would expand its blast radius, so callers always
// go through this method.
func (s *store) VerifyPassword(candidate []byte) bool {
s.mu.Lock()
defer s.mu.Unlock()
return subtle.ConstantTimeCompare(candidate, s.passphrase) == 1
}
// 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
}
// SetWindowSize updates the stored window dimensions and schedules a save.
func (s *store) SetWindowSize(w, h int) {
s.mu.Lock()
s.windowWidth = w
s.windowHeight = h
s.mu.Unlock()
s.Push()
}
// 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
autoStart := s.autoStart
winW := s.windowWidth
winH := s.windowHeight
s.mu.Unlock()
entries := s.snapshotFn()
cfg := &config.Config{
Version: 1,
Language: lang,
Theme: theme,
AutoLockMinutes: autoLock,
MinimizeToTray: minToTray,
AutoStart: autoStart,
WindowWidth: winW,
WindowHeight: winH,
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()
}
}