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 桩实现。
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user