Files
winauth-go/internal/ui/dialog_steam_trades.go
T
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

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}
}
}