c671f2115e
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。 核心模块: - internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含 enroll/sync/code 生成、Steam 交易确认轮询 - internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链) - internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB - internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) + SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换 - internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化 - internal/qr: gozxing 二维码解码 + otpauth:// URI 解析 - internal/i18n: en/zh-CN/de 三语 TOML UI 模块 (Gio): - 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线 - 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板) - 设置:密码加密、老版 XML 导入、每条目热键配置 - Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗 构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
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
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.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
|
|
s.mu.Unlock()
|
|
|
|
entries := s.snapshotFn()
|
|
cfg := &config.Config{
|
|
Version: 1,
|
|
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()
|
|
}
|
|
}
|