Files
iceking2nd c671f2115e feat: Phase 1 WinAuth Go 移植完整实现
将原 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 桩实现。
2026-06-12 03:10:37 +08:00

185 lines
5.2 KiB
Go

package authenticator
import (
"context"
"math/rand"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// confirmationEventDelay is the base sleep between firing successive
// OnConfirmation callbacks. Matches the C# CONFIRMATION_EVENT_DELAY:
// the per-event sleep is uniformly randomised to 100%-150% of this
// value, throttling the UI when several new trades arrive at once.
const confirmationEventDelay = 1000 * time.Millisecond
// defaultConfirmationPollerRetries is the number of consecutive failed
// poll cycles before OnConfirmationError fires. Mirrors the C# default.
const defaultConfirmationPollerRetries = 3
// ConfirmationCallback receives one notification per newly observed
// pending confirmation. action tells the UI whether the user wanted a
// passive notification, an interactive prompt, or silent auto-accept.
type ConfirmationCallback func(conf Confirmation, action PollerAction)
// ConfirmationErrorCallback is fired once per failure burst (every
// ConfirmationPollerRetries consecutive failures), letting the UI
// surface "Steam unreachable" once instead of on every cycle.
type ConfirmationErrorCallback func(message string, action PollerAction, err error)
// pollerHandle tracks a running background poller so it can be stopped
// cleanly. Kept private; SteamClient exposes Start/Stop wrappers.
type pollerHandle struct {
cancel context.CancelFunc
done chan struct{}
}
// StartConfirmationPoller starts (or restarts) the background goroutine
// that periodically calls GetConfirmations and fires OnConfirmation /
// OnConfirmationError. Passing a nil or zero-Duration poller stops any
// running poller and returns.
//
// It is safe to call StartConfirmationPoller repeatedly — the previous
// poller is stopped (and its goroutine joined) before the new one is
// started.
func (c *SteamClient) StartConfirmationPoller(poller *ConfirmationPoller) {
c.StopConfirmationPoller()
if poller == nil || poller.Duration <= 0 {
return
}
c.mu.Lock()
if c.Session == nil {
c.mu.Unlock()
return
}
c.Session.Confirmations = poller
if c.ConfirmationPollerRetries <= 0 {
c.ConfirmationPollerRetries = defaultConfirmationPollerRetries
}
retries := c.ConfirmationPollerRetries
c.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
h := &pollerHandle{cancel: cancel, done: make(chan struct{})}
c.mu.Lock()
c.poller = h
c.mu.Unlock()
go c.runPollerLoop(ctx, h, retries)
}
// StopConfirmationPoller cancels the running poller (if any) and waits
// for its goroutine to exit before returning. Also clears
// Session.Confirmations so a restored session does not auto-restart.
func (c *SteamClient) StopConfirmationPoller() {
c.mu.Lock()
h := c.poller
c.poller = nil
if c.Session != nil {
c.Session.Confirmations = nil
}
c.mu.Unlock()
if h == nil {
return
}
h.cancel()
<-h.done
}
// runPollerLoop is the goroutine body. It owns no locks across network
// calls. Snapshots of the poller config / retry budget are taken once
// per iteration to avoid races with concurrent Stop / Start callers.
func (c *SteamClient) runPollerLoop(ctx context.Context, h *pollerHandle, maxRetries int) {
const fn = "internal.authenticator.SteamClient.runPollerLoop"
logger := global.Log.WithField("func", fn)
defer close(h.done)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
retryCount := 0
for ctx.Err() == nil {
c.mu.Lock()
poller := c.Session.Confirmations
onConf := c.OnConfirmation
onErr := c.OnConfirmationError
c.mu.Unlock()
if poller == nil {
logger.Debug("poller cleared; exiting loop")
return
}
action := poller.Action
confs, err := c.GetConfirmations(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
retryCount++
logger.WithError(err).WithField("retry", retryCount).Warn("poll failed")
if retryCount >= maxRetries {
if onErr != nil {
onErr("Failed to read confirmations", action, err)
}
} else {
// Best-effort cookie refresh — matches the C# fallback.
_, _ = c.Refresh(ctx)
}
} else {
retryCount = 0
if onConf != nil {
for i := range confs {
if !confs[i].IsNew {
continue
}
if ctx.Err() != nil {
return
}
start := time.Now()
onConf(confs[i], action)
// Jitter the inter-event delay 100%-150% to keep
// the UI from being slammed during a burst.
delay := confirmationEventDelay + time.Duration(rng.Int63n(int64(confirmationEventDelay/2)))
elapsed := time.Since(start)
if delay > elapsed {
if !sleepWithCancel(ctx, delay-elapsed) {
return
}
}
}
}
}
// Re-read duration in case the user changed it mid-flight.
c.mu.Lock()
var wait time.Duration
if c.Session != nil && c.Session.Confirmations != nil {
wait = time.Duration(c.Session.Confirmations.Duration) * time.Minute
}
c.mu.Unlock()
if wait <= 0 {
return
}
if !sleepWithCancel(ctx, wait) {
return
}
}
}
// sleepWithCancel sleeps for d, or returns early if ctx is cancelled.
// Returns true if the full duration elapsed, false on cancellation.
func sleepWithCancel(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}