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 桩实现。
281 lines
8.5 KiB
Go
281 lines
8.5 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
|
)
|
|
|
|
// steamTradeRow holds per-row UI state for a single pending confirmation
|
|
// so accept/reject button clicks survive across frames.
|
|
type steamTradeRow struct {
|
|
conf authenticator.Confirmation
|
|
acceptBn widget.Clickable
|
|
rejectBn widget.Clickable
|
|
busy bool // an op is in flight for this id
|
|
status string // last per-row status message
|
|
}
|
|
|
|
// steamTradesDialog lists a Steam authenticator's pending trade /
|
|
// market confirmations and lets the user accept or reject each one.
|
|
//
|
|
// SECURITY: holds a live SteamClient with OAuth cookies. Closing the
|
|
// dialog drops the reference but does NOT log the user out — the same
|
|
// session will be reused next time the dialog is opened.
|
|
type steamTradesDialog struct {
|
|
client *authenticator.SteamClient
|
|
authImpl *authenticator.SteamAuthenticator
|
|
authName string
|
|
closeBtn widget.Clickable
|
|
refreshBn widget.Clickable
|
|
list widget.List
|
|
|
|
mu sync.Mutex
|
|
loading bool
|
|
loadErr error
|
|
rows []*steamTradeRow
|
|
loadedAt time.Time
|
|
|
|
invalidate func()
|
|
onClose func()
|
|
}
|
|
|
|
// newSteamTradesDialog constructs the dialog around an authenticator
|
|
// instance, reusing its persisted SessionData to skip a fresh login.
|
|
// Returns nil if the authenticator has no usable session.
|
|
func newSteamTradesDialog(
|
|
auth *authenticator.SteamAuthenticator, name string,
|
|
invalidate, onClose func(),
|
|
) (*steamTradesDialog, error) {
|
|
const fn = "internal.ui.newSteamTradesDialog"
|
|
client, err := authenticator.NewSteamClient(auth, auth.SessionData)
|
|
if err != nil {
|
|
global.Log.WithField("func", fn).WithError(err).Warn("steam client init failed")
|
|
return nil, err
|
|
}
|
|
d := &steamTradesDialog{
|
|
client: client,
|
|
authImpl: auth,
|
|
authName: name,
|
|
invalidate: invalidate,
|
|
onClose: onClose,
|
|
}
|
|
d.list.Axis = layout.Vertical
|
|
// Kick off the first load right away.
|
|
d.refresh()
|
|
return d, nil
|
|
}
|
|
|
|
// Layout draws the modal. It does not implement the Dialog interface
|
|
// (no Add-style onDone signature) — the parent appState owns the close
|
|
// callback directly.
|
|
func (d *steamTradesDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
|
|
if d.closeBtn.Clicked(gtx) && d.onClose != nil {
|
|
d.onClose()
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
}
|
|
if d.refreshBn.Clicked(gtx) {
|
|
d.refresh()
|
|
}
|
|
|
|
// Handle per-row accept/reject clicks.
|
|
d.mu.Lock()
|
|
rowsSnapshot := d.rows
|
|
d.mu.Unlock()
|
|
for _, r := range rowsSnapshot {
|
|
if r.busy {
|
|
continue
|
|
}
|
|
if r.acceptBn.Clicked(gtx) {
|
|
d.runConfirm(r, true)
|
|
} else if r.rejectBn.Clicked(gtx) {
|
|
d.runConfirm(r, false)
|
|
}
|
|
}
|
|
|
|
body := func(gtx layout.Context) layout.Dimensions {
|
|
d.mu.Lock()
|
|
loading := d.loading
|
|
loadErr := d.loadErr
|
|
rows := d.rows
|
|
d.mu.Unlock()
|
|
|
|
if loadErr != nil {
|
|
return errorLabel(th, fmt.Sprintf(i18n.T("steam_trades_error"), loadErr.Error()))(gtx)
|
|
}
|
|
if loading && len(rows) == 0 {
|
|
return material.Body2(th, i18n.T("steam_trades_loading")).Layout(gtx)
|
|
}
|
|
if len(rows) == 0 {
|
|
return material.Body2(th, i18n.T("steam_trades_empty")).Layout(gtx)
|
|
}
|
|
// Cap list height so the modal does not exceed the window.
|
|
gtx.Constraints.Max.Y = gtx.Dp(360)
|
|
return material.List(th, &d.list).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
|
return d.layoutRow(gtx, th, rows[i])
|
|
})
|
|
}
|
|
|
|
return modalCard(gtx, th, i18n.T("dialog_steam_trades_title"),
|
|
i18n.T("btn_refresh"), i18n.T("btn_close"),
|
|
&d.refreshBn, &d.closeBtn, body)
|
|
}
|
|
|
|
// layoutRow renders one pending confirmation. The image URL is shown
|
|
// rather than fetched: the trade list can be long and async image
|
|
// loading would add complexity not present in the original WinAuth UI.
|
|
func (d *steamTradesDialog) layoutRow(
|
|
gtx layout.Context, th *material.Theme, r *steamTradeRow,
|
|
) layout.Dimensions {
|
|
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body1(th, r.conf.Details).Layout),
|
|
layout.Rigid(material.Body2(th, r.conf.Traded).Layout),
|
|
layout.Rigid(material.Caption(th, r.conf.When).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if r.status == "" {
|
|
return layout.Dimensions{}
|
|
}
|
|
return material.Body2(th, r.status).Layout(gtx)
|
|
}),
|
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
|
}),
|
|
layout.Rigid(material.Button(th, &r.rejectBn, i18n.T("btn_reject")).Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
|
|
material.Button(th, &r.acceptBn, i18n.T("btn_accept")).Layout)
|
|
}),
|
|
)
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
layout.Rigid(thinDivider(th)),
|
|
)
|
|
})
|
|
}
|
|
|
|
// refresh kicks off (or restarts) a background GetConfirmations call.
|
|
func (d *steamTradesDialog) refresh() {
|
|
const fn = "internal.ui.steamTradesDialog.refresh"
|
|
d.mu.Lock()
|
|
if d.loading {
|
|
d.mu.Unlock()
|
|
return
|
|
}
|
|
d.loading = true
|
|
d.loadErr = nil
|
|
d.mu.Unlock()
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
confs, err := d.client.GetConfirmations(ctx)
|
|
d.mu.Lock()
|
|
d.loading = false
|
|
d.loadedAt = time.Now()
|
|
if err != nil {
|
|
d.loadErr = err
|
|
global.Log.WithField("func", fn).WithError(err).Warn("get confirmations failed")
|
|
} else {
|
|
d.rows = mergeConfirmations(d.rows, confs)
|
|
}
|
|
d.mu.Unlock()
|
|
// Refresh persists rotated session cookies; push them to disk.
|
|
d.syncSessionToAuthenticator()
|
|
if d.invalidate != nil {
|
|
d.invalidate()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// runConfirm fires the accept/reject HTTP call for a single trade.
|
|
func (d *steamTradesDialog) runConfirm(r *steamTradeRow, accept bool) {
|
|
const fn = "internal.ui.steamTradesDialog.runConfirm"
|
|
d.mu.Lock()
|
|
r.busy = true
|
|
r.status = i18n.T("msg_busy")
|
|
d.mu.Unlock()
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
ok, err := d.client.ConfirmTrade(ctx, r.conf.Id, r.conf.Key, accept)
|
|
d.mu.Lock()
|
|
r.busy = false
|
|
switch {
|
|
case err != nil:
|
|
r.status = fmt.Sprintf(i18n.T("steam_trades_error"), err.Error())
|
|
global.Log.WithField("func", fn).WithError(err).Warn("confirm trade failed")
|
|
case !ok:
|
|
r.status = i18n.T("steam_trades_error")
|
|
default:
|
|
// Drop the row from the visible list — Steam considers it done.
|
|
out := d.rows[:0]
|
|
for _, x := range d.rows {
|
|
if x.conf.Id != r.conf.Id {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
d.rows = out
|
|
}
|
|
d.mu.Unlock()
|
|
d.syncSessionToAuthenticator()
|
|
if d.invalidate != nil {
|
|
d.invalidate()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// syncSessionToAuthenticator copies the latest Session JSON from the
|
|
// client back onto the authenticator's SessionData so the next save
|
|
// persists rotated cookies. Called after any network round-trip.
|
|
func (d *steamTradesDialog) syncSessionToAuthenticator() {
|
|
if d.client == nil || d.authImpl == nil || d.client.Session == nil {
|
|
return
|
|
}
|
|
d.authImpl.SessionData = d.client.Session.ToJSON()
|
|
}
|
|
|
|
// mergeConfirmations rebuilds the row slice from a fresh confirmations
|
|
// list, preserving the per-row Clickable state for ids that survived.
|
|
func mergeConfirmations(
|
|
old []*steamTradeRow, fresh []authenticator.Confirmation,
|
|
) []*steamTradeRow {
|
|
byID := make(map[string]*steamTradeRow, len(old))
|
|
for _, r := range old {
|
|
byID[r.conf.Id] = r
|
|
}
|
|
out := make([]*steamTradeRow, 0, len(fresh))
|
|
for _, c := range fresh {
|
|
if existing, ok := byID[c.Id]; ok {
|
|
existing.conf = c
|
|
out = append(out, existing)
|
|
} else {
|
|
out = append(out, &steamTradeRow{conf: c})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// thinDivider returns a 1dp horizontal rule used between rows.
|
|
func thinDivider(_ *material.Theme) layout.Widget {
|
|
return func(gtx layout.Context) layout.Dimensions {
|
|
// material.Divider doesn't exist in this Gio version; draw a
|
|
// thin rectangle instead.
|
|
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(1))
|
|
gtx.Constraints.Max.Y = gtx.Constraints.Min.Y
|
|
return layout.Dimensions{Size: gtx.Constraints.Min}
|
|
}
|
|
}
|