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 桩实现。
184 lines
5.3 KiB
Go
184 lines
5.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"os"
|
|
|
|
"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"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/qr"
|
|
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
|
)
|
|
|
|
// scanQRDialog drives the "scan otpauth:// QR code" flow. It offers two
|
|
// entry points: read a PNG/JPG file from disk, or grab whatever image
|
|
// is currently on the clipboard (Win+Shift+S screenshot landing zone).
|
|
// On success the dialog hands the caller a fully built authenticator
|
|
// and a default display name derived from the QR's issuer + label.
|
|
type scanQRDialog struct {
|
|
pathEd widget.Editor
|
|
fromFileBt widget.Clickable
|
|
clipBt widget.Clickable
|
|
cancelBt widget.Clickable
|
|
|
|
errorMsg string
|
|
}
|
|
|
|
func newScanQRDialog() *scanQRDialog {
|
|
d := &scanQRDialog{}
|
|
d.pathEd.SingleLine = true
|
|
return d
|
|
}
|
|
|
|
// Layout follows the same Dialog contract as the per-vendor add
|
|
// dialogs: onDone(nil, "") on cancel, onDone(auth, displayName) on
|
|
// successful scan + parse.
|
|
func (d *scanQRDialog) Layout(
|
|
gtx layout.Context, th *material.Theme,
|
|
onDone func(authenticator.Authenticator, string),
|
|
) layout.Dimensions {
|
|
if d.cancelBt.Clicked(gtx) {
|
|
onDone(nil, "")
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
}
|
|
if d.fromFileBt.Clicked(gtx) {
|
|
path := d.pathEd.Text()
|
|
if path == "" {
|
|
d.errorMsg = i18n.T("msg_empty_qr_path")
|
|
} else if auth, name, err := decodeFromFile(path); err != nil {
|
|
d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error())
|
|
global.Log.WithField("func", "internal.ui.scanQRDialog.fromFile").
|
|
WithError(err).Warn("QR scan from file failed")
|
|
} else {
|
|
onDone(auth, name)
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
}
|
|
}
|
|
if d.clipBt.Clicked(gtx) {
|
|
if auth, name, err := decodeFromClipboard(); err != nil {
|
|
d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error())
|
|
global.Log.WithField("func", "internal.ui.scanQRDialog.fromClipboard").
|
|
WithError(err).Warn("QR scan from clipboard failed")
|
|
} else {
|
|
onDone(auth, name)
|
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
|
}
|
|
}
|
|
|
|
body := func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(material.Body2(th, i18n.T("qr_intro")).Layout),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
layout.Rigid(labeledEditor(th, i18n.T("label_qr_path"), &d.pathEd, "C:\\...\\code.png")),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
|
layout.Rigid(material.Button(th, &d.fromFileBt, i18n.T("btn_qr_from_file")).Layout),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
layout.Rigid(material.Button(th, &d.clipBt, i18n.T("btn_qr_from_clipboard")).Layout),
|
|
)
|
|
}),
|
|
layout.Rigid(errorLabel(th, d.errorMsg)),
|
|
)
|
|
}
|
|
|
|
// Use modalCard with only the cancel button (OK is a no-op here
|
|
// because the action buttons live inside the body).
|
|
return modalCardCancel(gtx, th, i18n.T("dialog_scan_qr_title"),
|
|
i18n.T("btn_cancel"), &d.cancelBt, body)
|
|
}
|
|
|
|
// decodeFromFile reads a PNG/JPG/GIF off disk, decodes any QR code in
|
|
// it, parses the otpauth URI, then builds the matching authenticator.
|
|
func decodeFromFile(path string) (authenticator.Authenticator, string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer f.Close()
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("decode image: %w", err)
|
|
}
|
|
return decodeAndBuild(img)
|
|
}
|
|
|
|
// decodeFromClipboard pulls the current clipboard image (Snipping Tool
|
|
// landing zone) and runs the same pipeline.
|
|
func decodeFromClipboard() (authenticator.Authenticator, string, error) {
|
|
img, err := win32.GetClipboardImage()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if img == nil {
|
|
return nil, "", fmt.Errorf("%s", i18n.T("msg_clipboard_no_image"))
|
|
}
|
|
return decodeAndBuild(img)
|
|
}
|
|
|
|
func decodeAndBuild(img image.Image) (authenticator.Authenticator, string, error) {
|
|
text, err := qr.DecodeImage(img)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
parsed, err := qr.ParseOtpAuth(text)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
auth, err := authenticatorFromOtpAuth(parsed)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return auth, displayNameFromOtpAuth(parsed), nil
|
|
}
|
|
|
|
func authenticatorFromOtpAuth(p *qr.OtpAuth) (authenticator.Authenticator, error) {
|
|
switch p.Type {
|
|
case "totp":
|
|
a := authenticator.NewGoogleAuthenticator()
|
|
if err := a.Enroll(p.SecretBase32); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Digits > 0 {
|
|
a.CodeDigits = p.Digits
|
|
}
|
|
if p.Period > 0 {
|
|
a.Period = p.Period
|
|
}
|
|
return a, nil
|
|
case "hotp":
|
|
a := authenticator.NewHOTPAuthenticator()
|
|
if err := a.Enroll(p.SecretBase32, p.Counter); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Digits > 0 {
|
|
a.CodeDigits = p.Digits
|
|
}
|
|
return a, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported otpauth type %q", p.Type)
|
|
}
|
|
}
|
|
|
|
func displayNameFromOtpAuth(p *qr.OtpAuth) string {
|
|
if p.Issuer != "" && p.Label != "" {
|
|
return p.Issuer + ": " + p.Label
|
|
}
|
|
if p.Label != "" {
|
|
return p.Label
|
|
}
|
|
if p.Issuer != "" {
|
|
return p.Issuer
|
|
}
|
|
return "QR"
|
|
}
|