1605b5b7ae
- 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 三语翻译
250 lines
7.1 KiB
Go
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()
|
|
}
|
|
}
|