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:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+540
View File
@@ -0,0 +1,540 @@
// Package ui hosts the Gio-based desktop user interface.
package ui
import (
"errors"
"fmt"
"image/color"
"os"
"sync"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"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/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// Run starts the Gio event loop and blocks until the window is closed.
// configPath is the YAML path to load from / save to. An empty string
// uses config.DefaultPath().
func Run(configPath string) error {
const fn = "internal.ui.Run"
if configPath == "" {
configPath = config.DefaultPath()
}
global.Log.WithField("func", fn).WithField("config", configPath).Info("starting Gio UI")
go func() {
w := new(app.Window)
w.Option(
app.Title(i18n.T("app_title")),
app.Size(unit.Dp(560), unit.Dp(420)),
)
if err := loop(w, configPath); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
os.Exit(1)
}
os.Exit(0)
}()
app.Main()
return nil
}
type entry struct {
Name string
Auth authenticator.Authenticator
Code string
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
// or "" if none is set.
Hotkey string
// hotkeyID is the win32 manager's id for the currently-registered
// shortcut, or 0 if not registered.
hotkeyID int32
// Clickable backing the row; for HOTP entries clicking advances the
// counter and reveals the next code.
click widget.Clickable
// tradesBtn is wired only for Steam entries; clicking it opens the
// confirmations window.
tradesBtn widget.Clickable
// hotkeyBtn opens the per-entry hotkey editor.
hotkeyBtn widget.Clickable
// copyBtn copies the current code to the clipboard.
copyBtn widget.Clickable
}
type appState struct {
mu sync.Mutex
entries []*entry
addBtn widget.Clickable
removeBtn widget.Clickable
settingsBtn widget.Clickable
list widget.List
vendorMenu *vendorMenu
settingsMenu *settingsMenu
dialog Dialog
pwDialog *passwordDialog
setPwDialog *setPasswordDialog
importDialog *importLegacyDialog
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
tradesDialog *steamTradesDialog
store *store
saveErr string // surfaced in the top bar
hkMgr *win32.HotkeyManager
toast toast
}
// snapshotEntries returns a freshly serialized slice of config entries.
// Called from the store goroutine, so it must take appState.mu itself.
func (st *appState) snapshotEntries() []config.Entry {
st.mu.Lock()
defer st.mu.Unlock()
out := make([]config.Entry, 0, len(st.entries))
for _, en := range st.entries {
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey))
}
return out
}
func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{}
state.list.Axis = layout.Vertical
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
state.mu.Lock()
state.saveErr = fmt.Sprintf(i18n.T("msg_save_failed"), err.Error())
state.mu.Unlock()
w.Invalidate()
})
// First-load attempt: empty passphrase. If the file is encrypted we'll
// surface a password dialog on the first frame.
if cfg, err := state.store.Load(nil); err != nil {
switch {
case errors.Is(err, ErrPasswordRequired):
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
default:
global.Log.WithField("func", fn).WithError(err).Warn("config load failed; starting empty")
state.saveErr = fmt.Sprintf(i18n.T("msg_load_failed"), err.Error())
}
} else if cfg != nil {
state.absorbConfig(cfg)
}
// Spin up the global hotkey manager and register whatever the user
// already had configured. Failures are non-fatal (logged + the row
// just won't fire).
state.hkMgr = win32.NewHotkeyManager()
state.registerAllHotkeys()
go state.runHotkeyLoop(w)
// Tick once per second to refresh TOTP codes.
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
for range t.C {
w.Invalidate()
}
}()
var ops op.Ops
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
global.Log.WithField("func", fn).Info("window closed")
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
drawFrame(gtx, th, state, w)
e.Frame(gtx.Ops)
}
}
}
// absorbConfig replaces the in-memory entries with the contents of cfg,
// best-effort: bad entries are logged and skipped.
func (st *appState) absorbConfig(cfg *config.Config) {
const fn = "internal.ui.appState.absorbConfig"
if cfg == nil {
return
}
st.mu.Lock()
defer st.mu.Unlock()
st.entries = st.entries[:0]
for _, e := range cfg.Entries {
a, err := buildAuthenticator(e)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
}
}
// mergeImportedConfig appends entries from cfg onto the live list
// without dropping anything the user already had. Bad entries are
// logged and skipped, same as absorbConfig.
func (st *appState) mergeImportedConfig(cfg *config.Config) {
const fn = "internal.ui.appState.mergeImportedConfig"
if cfg == nil {
return
}
st.mu.Lock()
defer st.mu.Unlock()
for _, e := range cfg.Entries {
a, err := buildAuthenticator(e)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
}
}
func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions {
if st.addBtn.Clicked(gtx) {
st.vendorMenu = newVendorMenu()
}
if st.removeBtn.Clicked(gtx) {
st.mu.Lock()
if len(st.entries) > 0 {
st.entries = st.entries[:len(st.entries)-1]
}
st.mu.Unlock()
st.store.Push()
}
if st.settingsBtn.Clicked(gtx) {
st.settingsMenu = newSettingsMenu()
}
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
st.mu.Lock()
var tradesTarget *entry
var hotkeyTarget *entry
var copyTarget *entry
for _, en := range st.entries {
if en.Auth.Name() == "steam" {
if en.tradesBtn.Clicked(gtx) {
tradesTarget = en
}
}
if en.hotkeyBtn.Clicked(gtx) {
hotkeyTarget = en
}
if en.copyBtn.Clicked(gtx) {
copyTarget = en
}
if en.Auth.Name() == "hotp" {
if en.click.Clicked(gtx) {
if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code
// Counter advanced — persist so a restart does not
// reuse the same counter value.
go st.store.Push()
}
}
continue
}
if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code
}
}
st.mu.Unlock()
if tradesTarget != nil {
st.openTradesDialog(tradesTarget, w)
}
if hotkeyTarget != nil {
st.hotkeyTarget = hotkeyTarget
st.hotkeyDialog = newHotkeyDialog(hotkeyTarget)
}
if copyTarget != nil {
st.copyCodeToClipboard(copyTarget, w)
}
// Password retry / first-decrypt loop.
if st.pwDialog != nil {
return st.pwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if !ok {
// User cancelled. Leave the entry list empty; do NOT
// trigger a save (we don't want to overwrite the
// encrypted file with an empty plaintext one).
st.pwDialog = nil
w.Invalidate()
return
}
cfg, err := st.store.Load(pw)
switch {
case errors.Is(err, ErrPasswordWrong):
st.pwDialog.SetError(i18n.T("msg_password_wrong"))
case err != nil:
st.pwDialog.SetError(fmt.Sprintf(i18n.T("msg_load_failed"), err.Error()))
default:
st.absorbConfig(cfg)
st.pwDialog = nil
}
w.Invalidate()
})
}
if st.setPwDialog != nil {
return st.setPwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if ok {
st.store.SetPassword(pw)
}
st.setPwDialog = nil
w.Invalidate()
})
}
if st.settingsMenu != nil {
if act, closed := st.settingsMenu.Pick(gtx); closed {
st.settingsMenu = nil
switch act {
case settingsActionSetPassword:
st.setPwDialog = newSetPasswordDialog()
case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog()
case settingsActionAbout:
// TODO: about dialog (next phase).
}
w.Invalidate()
} else {
return st.settingsMenu.Layout(gtx, th)
}
}
if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil {
st.mergeImportedConfig(r.cfg)
st.store.Push()
}
st.importDialog = nil
w.Invalidate()
})
}
if st.vendorMenu != nil {
if v, closed := st.vendorMenu.Pick(gtx); closed {
st.vendorMenu = nil
switch v {
case vendorGoogle:
st.dialog = newAddGoogleDialog()
case vendorMicrosoft:
st.dialog = newAddMicrosoftDialog()
case vendorOkta:
st.dialog = newAddOktaDialog()
case vendorHOTP:
st.dialog = newAddHOTPDialog()
case vendorBattleNet:
st.dialog = newAddBattleNetDialog(w.Invalidate)
case vendorBattleNetRestore:
st.dialog = newRestoreBattleNetDialog(w.Invalidate)
case vendorSteam:
st.dialog = newAddSteamDialog(w.Invalidate)
case vendorScanQR:
st.dialog = newScanQRDialog()
}
w.Invalidate()
} else {
return st.vendorMenu.Layout(gtx, th)
}
}
if st.dialog != nil {
return st.dialog.Layout(gtx, th, func(added authenticator.Authenticator, name string) {
if added != nil {
st.mu.Lock()
st.entries = append(st.entries, &entry{Name: name, Auth: added})
st.mu.Unlock()
st.store.Push()
}
st.dialog = nil
w.Invalidate()
})
}
if st.hotkeyDialog != nil {
return st.hotkeyDialog.Layout(gtx, th, func(r hotkeyResult) {
if !r.cancel && st.hotkeyTarget != nil {
if r.cleared {
st.applyHotkey(st.hotkeyTarget, "")
} else {
st.applyHotkey(st.hotkeyTarget, r.value)
}
st.store.Push()
}
st.hotkeyDialog = nil
st.hotkeyTarget = nil
w.Invalidate()
})
}
if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th)
}
dims := layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("app_title")).Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Rigid(material.Button(th, &st.addBtn, i18n.T("btn_add")).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &st.removeBtn, i18n.T("btn_remove")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &st.settingsBtn, i18n.T("menu_settings")).Layout)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
st.mu.Lock()
msg := st.saveErr
st.mu.Unlock()
if msg == "" {
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
st.mu.Lock()
count := len(st.entries)
st.mu.Unlock()
if count == 0 {
return drawEmptyPlaceholder(gtx, th)
}
return material.List(th, &st.list).Layout(gtx, count, func(gtx layout.Context, i int) layout.Dimensions {
st.mu.Lock()
en := st.entries[i]
st.mu.Unlock()
return entryRow(gtx, th, en)
})
}),
)
})
st.toast.draw(gtx, th)
return dims
}
func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensions {
return en.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(6), Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return entryProgressRing(gtx, en)
})
}),
layout.Flexed(1, material.Body1(th, en.Name).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := i18n.T("btn_hotkey")
if en.Hotkey != "" {
label = en.Hotkey
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.hotkeyBtn, label).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if en.Auth.Name() != "steam" {
return layout.Dimensions{}
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.tradesBtn, i18n.T("btn_trades")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if en.Auth.Name() == "hotp" {
return layout.Dimensions{}
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code)
lbl.Color = color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
return lbl.Layout(gtx)
}),
)
})
}),
layout.Rigid(drawDivider),
)
})
}
// openTradesDialog initialises the Steam confirmations modal for the
// given entry. If the authenticator has no usable session we surface a
// hint in the top bar instead of opening an empty window.
func (st *appState) openTradesDialog(en *entry, w *app.Window) {
const fn = "internal.ui.appState.openTradesDialog"
sauth, ok := en.Auth.(*authenticator.SteamAuthenticator)
if !ok {
return
}
if sauth.SessionData == "" {
st.mu.Lock()
st.saveErr = i18n.T("steam_trades_session_missing")
st.mu.Unlock()
return
}
dlg, err := newSteamTradesDialog(sauth, en.Name, w.Invalidate, func() {
st.tradesDialog = nil
// Persist any session-cookie rotation that happened while the
// dialog was open.
st.store.Push()
w.Invalidate()
})
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("open trades dialog failed")
st.mu.Lock()
st.saveErr = fmt.Sprintf(i18n.T("steam_trades_error"), err.Error())
st.mu.Unlock()
return
}
st.tradesDialog = dlg
w.Invalidate()
}
// fillBackground paints a rectangle that covers gtx with the given color.
// Useful for dialog backdrops without depending on material.Surface.
func fillBackground(gtx layout.Context, c color.NRGBA) {
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: c}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"fmt"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// entryFromAuthenticator builds a serializable config.Entry from an
// in-memory authenticator plus its display name. The vendor string is
// derived from the authenticator's Name() (which already returns
// "google" / "microsoft" / "okta" / "hotp" / "battlenet" / "steam").
func entryFromAuthenticator(name string, a authenticator.Authenticator, hotkey string) config.Entry {
return config.Entry{
Name: name,
Vendor: a.Name(),
SecretRaw: a.SecretData(),
Hotkey: hotkey,
}
}
// buildAuthenticator is the reverse of entryFromAuthenticator: it picks
// the right concrete type based on vendor, then asks it to parse the
// stored secret blob.
func buildAuthenticator(e config.Entry) (authenticator.Authenticator, error) {
const fn = "internal.ui.buildAuthenticator"
var a authenticator.Authenticator
switch e.Vendor {
case "google", "":
a = authenticator.NewGoogleAuthenticator()
case "microsoft":
a = authenticator.NewMicrosoftAuthenticator()
case "okta":
a = authenticator.NewOktaVerifyAuthenticator()
case "hotp":
a = authenticator.NewHOTPAuthenticator()
case "battlenet":
a = authenticator.NewBattleNetAuthenticator()
case "steam":
a = authenticator.NewSteamAuthenticator()
default:
return nil, fmt.Errorf("%s: unknown vendor %q", fn, e.Vendor)
}
if err := a.SetSecretData(e.SecretRaw); err != nil {
return nil, fmt.Errorf("%s: decode entry %q: %w", fn, e.Name, err)
}
return a, nil
}
+132
View File
@@ -0,0 +1,132 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
)
// Dialog is the common contract implemented by every modal dialog.
// onDone is invoked with the newly built authenticator + display name on
// successful submission, or with (nil, "") on cancel.
type Dialog interface {
Layout(gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string)) layout.Dimensions
}
// modalCard renders the standard backdrop + centered card with the given
// title, body widget and an OK + Cancel button row. okLabel allows callers
// to override the primary button label (e.g. "Enroll" for Battle.Net).
//
// The function does not handle the click events itself; callers should
// query okBtn.Clicked / cancelBtn.Clicked before laying out so they can
// short-circuit the frame.
func modalCard(
gtx layout.Context,
th *material.Theme,
title string,
okLabel string,
cancelLabel string,
okBtn *widget.Clickable,
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(body),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, okBtn, okLabel).Layout)
}),
)
}),
)
})
})
})
}
// modalCardCancel is a variant of modalCard with only a Cancel button.
// Useful for dialogs whose primary actions live inside the body itself
// (e.g. the QR scan dialog with separate "from file" / "from clipboard"
// buttons).
func modalCardCancel(
gtx layout.Context,
th *material.Theme,
title string,
cancelLabel string,
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(body),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
)
}),
)
})
})
})
}
// errorLabel returns a layout widget that renders msg in red, or nothing
// when msg is empty. Used by dialogs to display validation errors.
func errorLabel(th *material.Theme, msg string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if msg == "" {
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout)
}
}
// labeledEditor lays out a small body label above the editor. hint is the
// editor placeholder text.
func labeledEditor(th *material.Theme, label string, ed *widget.Editor, hint string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, label).Layout),
layout.Rigid(material.Editor(th, ed, hint).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
)
}
}
+117
View File
@@ -0,0 +1,117 @@
package ui
import (
"context"
"fmt"
"sync"
"time"
"gioui.org/layout"
"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"
)
// addBattleNetDialog drives the Battle.Net mobile-service enrollment flow.
// Unlike Google/Microsoft/Okta the secret is server-issued, so the user
// only chooses a display name and an optional region override; on Enroll
// we kick off a background goroutine and disable the button while waiting.
type addBattleNetDialog struct {
nameEd widget.Editor
regionEd widget.Editor // empty = auto
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
mu sync.Mutex
pending bool
result *authenticator.BattleNetAuthenticator
resultEr error
invalidate func() // set by Layout on first call
}
func newAddBattleNetDialog(invalidate func()) *addBattleNetDialog {
d := &addBattleNetDialog{invalidate: invalidate}
d.nameEd.SingleLine = true
d.regionEd.SingleLine = true
return d
}
func (d *addBattleNetDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addBattleNetDialog.Layout"
d.mu.Lock()
pending := d.pending
finished := !pending && (d.result != nil || d.resultEr != nil)
res := d.result
resErr := d.resultEr
d.mu.Unlock()
if finished {
if resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error())
// clear the latched result so retry works
d.mu.Lock()
d.result, d.resultEr = nil, nil
d.mu.Unlock()
} else if res != nil {
name := d.nameEd.Text()
if name == "" {
name = i18n.T("vendor_battlenet")
}
onDone(res, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.okBtn.Clicked(gtx) && !pending {
d.mu.Lock()
d.pending = true
d.errorMsg = i18n.T("msg_enrolling")
d.mu.Unlock()
region := d.regionEd.Text()
go func(region string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b := authenticator.NewBattleNetAuthenticator()
err := b.Enroll(ctx, region)
d.mu.Lock()
d.pending = false
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("battle.net enroll failed")
d.resultEr = err
} else {
d.result = b
}
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(region)
}
if d.cancelBtn.Clicked(gtx) && !pending {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_battlenet"))),
layout.Rigid(labeledEditor(th, i18n.T("label_region"), &d.regionEd, i18n.T("region_auto"))),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
// disable buttons while pending by swallowing inputs (no Gio-native
// "disabled" flag on material.Button — we just gate via the pending check
// above before reacting to clicks)
return modalCard(gtx, th, i18n.T("dialog_add_battlenet_title"),
i18n.T("btn_enroll"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+146
View File
@@ -0,0 +1,146 @@
package ui
import (
"context"
"fmt"
"strings"
"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"
)
// restoreBattleNetDialog drives the paper-restore flow: the user types
// the serial they wrote down at enrollment time plus the 10-char
// restore code, and the backend asks Blizzard for the original secret.
//
// SECURITY: the restore code is functionally a root key — anybody
// holding it can recover the authenticator and authorize Battle.Net
// logins. The editor masks it by default and we never log it.
type restoreBattleNetDialog struct {
nameEd widget.Editor
serialEd widget.Editor
codeEd widget.Editor
showCode widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
mu sync.Mutex
pending bool
result *authenticator.BattleNetAuthenticator
resultEr error
invalidate func()
}
func newRestoreBattleNetDialog(invalidate func()) *restoreBattleNetDialog {
d := &restoreBattleNetDialog{invalidate: invalidate}
d.nameEd.SingleLine = true
d.serialEd.SingleLine = true
d.codeEd.SingleLine = true
d.codeEd.Mask = '*'
return d
}
func (d *restoreBattleNetDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.restoreBattleNetDialog.Layout"
if d.showCode.Update(gtx) {
if d.showCode.Value {
d.codeEd.Mask = 0
} else {
d.codeEd.Mask = '*'
}
}
d.mu.Lock()
pending := d.pending
finished := !pending && (d.result != nil || d.resultEr != nil)
res := d.result
resErr := d.resultEr
d.mu.Unlock()
if finished {
if resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_restore_failed"), resErr.Error())
d.mu.Lock()
d.result, d.resultEr = nil, nil
d.mu.Unlock()
} else if res != nil {
name := strings.TrimSpace(d.nameEd.Text())
if name == "" {
name = i18n.T("vendor_battlenet")
}
// Wipe the code editor so a leftover value cannot be read
// off the screen if the parent reuses the dialog.
d.codeEd.SetText("")
onDone(res, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.okBtn.Clicked(gtx) && !pending {
serial := strings.TrimSpace(d.serialEd.Text())
code := d.codeEd.Text()
if serial == "" {
d.errorMsg = i18n.T("msg_empty_serial")
} else if strings.TrimSpace(code) == "" {
d.errorMsg = i18n.T("msg_empty_restore_code")
} else {
d.mu.Lock()
d.pending = true
d.errorMsg = i18n.T("msg_restoring")
d.mu.Unlock()
go func(serial, code string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b := authenticator.NewBattleNetAuthenticator()
err := b.Restore(ctx, serial, code)
d.mu.Lock()
d.pending = false
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("battle.net restore failed")
d.resultEr = err
} else {
d.result = b
}
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(serial, code)
}
}
if d.cancelBtn.Clicked(gtx) && !pending {
// Clear the code editor on cancel so the secret does not linger.
d.codeEd.SetText("")
onDone(nil, "")
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("battlenet_restore_intro")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_battlenet"))),
layout.Rigid(labeledEditor(th, i18n.T("label_serial"), &d.serialEd, "US-1234-5678-9012")),
layout.Rigid(labeledEditor(th, i18n.T("label_restore_code"), &d.codeEd, "")),
layout.Rigid(material.CheckBox(th, &d.showCode, i18n.T("label_show_restore_code")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_restore_battlenet_title"),
i18n.T("btn_restore"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"gioui.org/layout"
"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"
)
// addTOTPDialog is the shared "Add ..." form used by Google, Microsoft and
// Okta Verify — vendors that all use the same {Name, Base32-secret} input
// surface and only differ by display title, default name, and factory
// function.
type addTOTPDialog struct {
title string
defaultName string
factory func() authenticator.Authenticator
nameEd widget.Editor
secretEd widget.Editor
showSec widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newAddGoogleDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_google_title"),
i18n.T("vendor_google"),
func() authenticator.Authenticator { return authenticator.NewGoogleAuthenticator() },
)
}
func newAddMicrosoftDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_microsoft_title"),
i18n.T("vendor_microsoft"),
func() authenticator.Authenticator { return authenticator.NewMicrosoftAuthenticator() },
)
}
func newAddOktaDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_okta_title"),
i18n.T("vendor_okta"),
func() authenticator.Authenticator { return authenticator.NewOktaVerifyAuthenticator() },
)
}
func newAddTOTPDialog(title, defaultName string, factory func() authenticator.Authenticator) *addTOTPDialog {
d := &addTOTPDialog{title: title, defaultName: defaultName, factory: factory}
d.nameEd.SingleLine = true
d.secretEd.SingleLine = true
d.secretEd.Mask = '*'
return d
}
// enrollable is implemented by authenticators whose secret is provided as a
// raw Base32 string by the user (Google / Microsoft / Okta).
type enrollable interface {
authenticator.Authenticator
Enroll(secret string) error
}
func (d *addTOTPDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addTOTPDialog.Layout"
if d.showSec.Update(gtx) {
if d.showSec.Value {
d.secretEd.Mask = 0
} else {
d.secretEd.Mask = '*'
}
}
if d.okBtn.Clicked(gtx) {
secret := d.secretEd.Text()
if secret == "" {
d.errorMsg = i18n.T("msg_empty_secret")
} else {
a := d.factory()
if en, ok := a.(enrollable); ok {
if err := en.Enroll(secret); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("enroll failed")
d.errorMsg = i18n.T("msg_invalid_secret")
} else {
name := d.nameEd.Text()
if name == "" {
name = d.defaultName
}
onDone(a, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
} else {
d.errorMsg = i18n.T("msg_invalid_secret")
}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, d.defaultName)),
layout.Rigid(labeledEditor(th, i18n.T("label_secret_key"), &d.secretEd, "ABCDEF...")),
layout.Rigid(material.CheckBox(th, &d.showSec, i18n.T("label_show_secret")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, d.title,
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+87
View File
@@ -0,0 +1,87 @@
package ui
import (
"errors"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/hotkey"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// hotkeyDialog edits the hotkey string of a single entry. It's a thin
// wrapper around an Editor: validation runs synchronously on submit and
// the caller is told the *parsed* value (still in canonical string
// form, "" for clear).
type hotkeyDialog struct {
target *entry
ed widget.Editor
clearBt widget.Clickable
okBt widget.Clickable
cancelBt widget.Clickable
errorMsg string
}
func newHotkeyDialog(target *entry) *hotkeyDialog {
d := &hotkeyDialog{target: target}
d.ed.SingleLine = true
d.ed.SetText(target.Hotkey)
return d
}
// hotkeyResult tells the caller what to do. cleared=true means remove
// any existing hotkey. value is the canonical string ("Ctrl+Alt+G") if
// set is true.
type hotkeyResult struct {
cancel bool
cleared bool
value string
}
func (d *hotkeyDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(hotkeyResult),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
onDone(hotkeyResult{cancel: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.clearBt.Clicked(gtx) {
onDone(hotkeyResult{cleared: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBt.Clicked(gtx) {
txt := d.ed.Text()
h, err := hotkey.Parse(txt)
switch {
case errors.Is(err, hotkey.ErrEmpty):
onDone(hotkeyResult{cleared: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
case err != nil:
d.errorMsg = err.Error()
default:
onDone(hotkeyResult{value: hotkey.Format(h)})
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("hotkey_intro")).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_hotkey"), &d.ed, "Ctrl+Alt+G")),
layout.Rigid(errorLabel(th, d.errorMsg)),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.Button(th, &d.clearBt, i18n.T("btn_clear_hotkey")).Layout(gtx)
}),
)
}
return modalCard(gtx, th, i18n.T("dialog_hotkey_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBt, &d.cancelBt, body)
}
+91
View File
@@ -0,0 +1,91 @@
package ui
import (
"strconv"
"gioui.org/layout"
"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"
)
// addHOTPDialog asks for {Name, Base32 secret, starting counter}.
type addHOTPDialog struct {
nameEd widget.Editor
secretEd widget.Editor
counterEd widget.Editor
showSec widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newAddHOTPDialog() *addHOTPDialog {
d := &addHOTPDialog{}
d.nameEd.SingleLine = true
d.secretEd.SingleLine = true
d.secretEd.Mask = '*'
d.counterEd.SingleLine = true
d.counterEd.SetText("0")
return d
}
func (d *addHOTPDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addHOTPDialog.Layout"
if d.showSec.Update(gtx) {
if d.showSec.Value {
d.secretEd.Mask = 0
} else {
d.secretEd.Mask = '*'
}
}
if d.okBtn.Clicked(gtx) {
secret := d.secretEd.Text()
if secret == "" {
d.errorMsg = i18n.T("msg_empty_secret")
} else {
counter, err := strconv.ParseUint(d.counterEd.Text(), 10, 64)
if err != nil {
d.errorMsg = i18n.T("msg_invalid_counter")
} else {
h := authenticator.NewHOTPAuthenticator()
if err := h.Enroll(secret, counter); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("enroll failed")
d.errorMsg = i18n.T("msg_invalid_secret")
} else {
name := d.nameEd.Text()
if name == "" {
name = i18n.T("vendor_hotp")
}
onDone(h, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_hotp"))),
layout.Rigid(labeledEditor(th, i18n.T("label_secret_key"), &d.secretEd, "ABCDEF...")),
layout.Rigid(labeledEditor(th, i18n.T("label_counter"), &d.counterEd, "0")),
layout.Rigid(material.CheckBox(th, &d.showSec, i18n.T("label_show_secret")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_add_hotp_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"errors"
"fmt"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// importLegacyDialog drives the "Import legacy WinAuth XML..." flow.
// It is intentionally minimal: a path field, an optional password field
// for entries the user encrypted in the original C# WinAuth, and Import
// + Cancel buttons. DPAPI-encrypted entries decrypt transparently on
// Windows; on other platforms they are skipped with a warning in the
// log.
type importLegacyDialog struct {
pathEd widget.Editor
pwEd widget.Editor
importBt widget.Clickable
cancelBt widget.Clickable
errorMsg string
}
func newImportLegacyDialog() *importLegacyDialog {
d := &importLegacyDialog{}
d.pathEd.SingleLine = true
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
return d
}
// importLegacyResult is what the dialog reports to its caller.
type importLegacyResult struct {
cfg *config.Config
cancel bool
}
// Layout returns dimensions and reports outcomes via onDone. onDone is
// called with cancel=true on Cancel and with a non-nil cfg on success;
// validation errors are kept inside the dialog so the user can retry.
func (d *importLegacyDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(importLegacyResult),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
d.wipe()
onDone(importLegacyResult{cancel: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.importBt.Clicked(gtx) {
path := d.pathEd.Text()
if path == "" {
d.errorMsg = i18n.T("msg_empty_import_path")
} else {
password := []byte(d.pwEd.Text())
cfg, err := config.LoadLegacyXML(path, password)
for i := range password {
password[i] = 0
}
switch {
case errors.Is(err, config.ErrLegacyPasswordRequired):
d.errorMsg = i18n.T("msg_legacy_password_required")
case errors.Is(err, config.ErrLegacyPasswordWrong):
d.errorMsg = i18n.T("msg_legacy_password_wrong")
case err != nil:
d.errorMsg = fmt.Sprintf(i18n.T("msg_import_failed"), err.Error())
global.Log.WithField("func", "internal.ui.importLegacyDialog.Layout").
WithError(err).Warn("legacy import failed")
default:
d.wipe()
onDone(importLegacyResult{cfg: cfg})
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("import_legacy_intro")).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_import_path"), &d.pathEd, "C:\\Users\\...\\winauth.xml")),
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_import_legacy_title"),
i18n.T("btn_import"), i18n.T("btn_cancel"),
&d.importBt, &d.cancelBt, body)
}
// wipe clears the password editor so the plaintext bytes are not kept
// after the dialog closes.
func (d *importLegacyDialog) wipe() {
d.pwEd.SetText("")
}
+69
View File
@@ -0,0 +1,69 @@
package ui
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// passwordDialog asks the user for the password to decrypt an existing
// config. The dialog is shown at startup when the config file is found
// to be encrypted; on submit it calls onDone with the entered passphrase.
// Cancel returns an empty passphrase and signals abort.
type passwordDialog struct {
prompt string // optional explanatory line above the field
errorMsg string
pwEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
}
func newPasswordDialog(prompt string) *passwordDialog {
d := &passwordDialog{prompt: prompt}
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
return d
}
// SetError lets the caller surface a "wrong password" message after a
// failed Load attempt, so the same dialog can be reused for a retry loop.
func (d *passwordDialog) SetError(msg string) { d.errorMsg = msg }
// Layout takes onDone(password, ok). ok=true on submit; ok=false on cancel.
// On ok the password slice is freshly allocated (the editor's underlying
// buffer is not retained).
func (d *passwordDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(password []byte, ok bool),
) layout.Dimensions {
if d.okBtn.Clicked(gtx) {
pw := []byte(d.pwEd.Text())
onDone(pw, true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{}
if d.prompt != "" {
children = append(children,
layout.Rigid(material.Body2(th, d.prompt).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
)
}
children = append(children,
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
return modalCard(gtx, th, i18n.T("dialog_password_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+183
View File
@@ -0,0 +1,183 @@
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"
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// setPasswordDialog collects a new password and a confirmation. Leaving
// both fields empty disables encryption (passphrase = nil).
type setPasswordDialog struct {
pwEd widget.Editor
confirmEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newSetPasswordDialog() *setPasswordDialog {
d := &setPasswordDialog{}
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
d.confirmEd.SingleLine = true
d.confirmEd.Mask = '*'
return d
}
// Layout takes onDone(password, ok). ok=true on submit; an empty
// password byte slice signals "disable encryption", a non-empty slice
// is the new passphrase.
func (d *setPasswordDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(password []byte, ok bool),
) layout.Dimensions {
if d.okBtn.Clicked(gtx) {
pw := d.pwEd.Text()
confirm := d.confirmEd.Text()
if pw != confirm {
d.errorMsg = i18n.T("msg_password_mismatch")
} else {
onDone([]byte(pw), true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(labeledEditor(th, i18n.T("label_password_confirm"), &d.confirmEd, "")),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_password_empty_disables")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_set_password_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+440
View File
@@ -0,0 +1,440 @@
package ui
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"sync"
"time"
"gioui.org/layout"
"gioui.org/op/paint"
"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"
)
// steamWizardStep enumerates the wizard pages. The state machine is
// driven by the EnrollState.Requires* flags returned by each Enroll
// call; we just map them onto a UI step.
type steamWizardStep int
const (
steamStepCredentials steamWizardStep = iota
steamStepCaptcha
steamStepEmail
steamStepActivation
steamStepDone
)
// addSteamDialog is the Steam mobile authenticator enrollment wizard.
// It owns a single long-lived EnrollState plus a SteamAuthenticator
// instance; each "OK"-style button kicks off a goroutine that runs one
// Enroll round and updates the UI when it finishes.
//
// SECURITY: state.Password is wiped by Enroll itself once the credentials
// have been RSA-encrypted and sent. Captcha / email / SMS codes are not
// secrets per se but we still avoid logging them anywhere.
type addSteamDialog struct {
nameEd widget.Editor
usernameEd widget.Editor
passwordEd widget.Editor
captchaEd widget.Editor
emailEd widget.Editor
activateEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
step steamWizardStep
errorMsg string
mu sync.Mutex
pending bool
authImpl *authenticator.SteamAuthenticator
state *authenticator.EnrollState
lastDone bool
lastErr error
finalName string
// Cached decoded captcha image, plus a marker so we re-fetch only
// when the URL actually changes between Enroll rounds.
captchaURLLoaded string
captchaImg image.Image
captchaImgErr error
captchaLoading bool
invalidate func()
}
func newAddSteamDialog(invalidate func()) *addSteamDialog {
d := &addSteamDialog{
step: steamStepCredentials,
authImpl: authenticator.NewSteamAuthenticator(),
state: &authenticator.EnrollState{},
invalidate: invalidate,
}
d.nameEd.SingleLine = true
d.usernameEd.SingleLine = true
d.passwordEd.SingleLine = true
d.passwordEd.Mask = '*'
d.captchaEd.SingleLine = true
d.emailEd.SingleLine = true
d.activateEd.SingleLine = true
return d
}
func (d *addSteamDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addSteamDialog.Layout"
d.mu.Lock()
pending := d.pending
finished := !pending && d.lastDone
resErr := d.lastErr
stateErr := ""
if d.state != nil {
stateErr = d.state.Error
}
d.mu.Unlock()
// Network-error path (e.g. transport, parse): show and let user retry.
if finished && resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error())
d.mu.Lock()
d.lastDone = false
d.lastErr = nil
d.mu.Unlock()
}
// Successful Enroll round: advance the wizard based on which flags
// the state machine just set, or surface state.Error for soft fails.
if finished && resErr == nil {
d.mu.Lock()
d.lastDone = false
st := d.state
d.mu.Unlock()
if st.Success {
d.step = steamStepDone
d.errorMsg = ""
} else if stateErr != "" {
d.errorMsg = stateErr
// Stay on current step so the user can retry the same input.
} else {
d.errorMsg = ""
switch {
case st.RequiresCaptcha:
d.step = steamStepCaptcha
case st.RequiresEmailAuth:
d.step = steamStepEmail
case st.RequiresActivation:
d.step = steamStepActivation
}
}
}
// Captcha image fetch when we land on the captcha step with a new URL.
if d.step == steamStepCaptcha && d.state != nil && d.state.CaptchaURL != "" &&
d.state.CaptchaURL != d.captchaURLLoaded && !d.captchaLoading {
d.captchaLoading = true
d.captchaURLLoaded = d.state.CaptchaURL
urlCopy := d.state.CaptchaURL
go func(u string) {
img, err := fetchCaptchaImage(u)
d.mu.Lock()
d.captchaImg = img
d.captchaImgErr = err
d.captchaLoading = false
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(urlCopy)
}
// Cancel: always allowed; abort whatever step we're on.
if d.cancelBtn.Clicked(gtx) {
// Scrub the password just in case the user cancels before Enroll
// had a chance to wipe it.
d.mu.Lock()
if d.state != nil {
d.state.Password = ""
}
d.mu.Unlock()
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
// OK button: action depends on current step.
if d.okBtn.Clicked(gtx) && !pending {
switch d.step {
case steamStepDone:
// Final hand-off: build SessionData JSON now (we couldn't
// earlier because we kept the jar live until success).
d.mu.Lock()
sess := authenticator.SessionFromEnrollState(d.state)
d.authImpl.SessionData = sess.ToJSON()
d.mu.Unlock()
name := d.finalName
if name == "" {
name = i18n.T("vendor_steam")
}
onDone(d.authImpl, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
case steamStepCredentials:
username := strings.TrimSpace(d.usernameEd.Text())
password := d.passwordEd.Text()
if username == "" {
d.errorMsg = i18n.T("msg_empty_username")
break
}
if password == "" {
d.errorMsg = i18n.T("msg_empty_password")
break
}
name := strings.TrimSpace(d.nameEd.Text())
if name == "" {
name = i18n.T("vendor_steam")
}
d.finalName = name
d.errorMsg = ""
d.mu.Lock()
d.state.Username = username
d.state.Password = password
// Drop the editor's plaintext copy as soon as we've handed
// it to the state machine.
d.passwordEd.SetText("")
d.mu.Unlock()
d.runEnroll(fn)
case steamStepCaptcha:
txt := strings.TrimSpace(d.captchaEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_captcha")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.CaptchaText = txt
d.mu.Unlock()
d.captchaEd.SetText("")
d.runEnroll(fn)
case steamStepEmail:
txt := strings.TrimSpace(d.emailEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_email_code")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.EmailAuthText = txt
d.mu.Unlock()
d.emailEd.SetText("")
d.runEnroll(fn)
case steamStepActivation:
txt := strings.TrimSpace(d.activateEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_activation_code")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.ActivationCode = txt
d.mu.Unlock()
d.activateEd.SetText("")
d.runEnroll(fn)
}
}
body := d.layoutBody(th)
return modalCard(gtx, th, i18n.T("dialog_add_steam_title"),
d.okLabel(), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
// runEnroll fires off a background goroutine that performs exactly one
// Enroll round. The Layout function picks up the result on the next frame.
func (d *addSteamDialog) runEnroll(fn string) {
d.mu.Lock()
if d.pending {
d.mu.Unlock()
return
}
d.pending = true
d.errorMsg = i18n.T("msg_busy")
d.mu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := d.authImpl.Enroll(ctx, d.state)
d.mu.Lock()
d.pending = false
d.lastDone = true
d.lastErr = err
d.mu.Unlock()
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("steam enroll round failed")
}
if d.invalidate != nil {
d.invalidate()
}
}()
}
// okLabel chooses the primary-button text for the current step.
func (d *addSteamDialog) okLabel() string {
switch d.step {
case steamStepCredentials:
return i18n.T("btn_login")
case steamStepActivation:
return i18n.T("btn_activate")
case steamStepDone:
return i18n.T("btn_finish")
default:
return i18n.T("btn_continue")
}
}
// layoutBody renders the inputs specific to the current wizard step.
func (d *addSteamDialog) layoutBody(th *material.Theme) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
switch d.step {
case steamStepCredentials:
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_credentials")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_steam"))),
layout.Rigid(labeledEditor(th, i18n.T("label_username"), &d.usernameEd, "")),
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.passwordEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepCaptcha:
d.mu.Lock()
img := d.captchaImg
loading := d.captchaLoading
imgErr := d.captchaImgErr
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_captcha")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(captchaImageWidget(th, img, loading, imgErr)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_captcha_text"), &d.captchaEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepEmail:
domain := ""
d.mu.Lock()
if d.state != nil {
domain = d.state.EmailDomain
}
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, fmt.Sprintf(i18n.T("steam_step_email"), domain)).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_email_code"), &d.emailEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepActivation:
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_activation")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_activation_code"), &d.activateEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepDone:
revocation := ""
d.mu.Lock()
if d.state != nil {
revocation = d.state.RevocationCode
}
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_done")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("label_revocation_code")).Layout),
layout.Rigid(material.H6(th, revocation).Layout),
)
}),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return layout.Dimensions{}
}
}
// captchaImageWidget paints the captcha PNG/JPEG, or a placeholder line
// while it loads / on failure. We never log the captcha URL because it
// contains the captcha gid which is a Steam session token.
func captchaImageWidget(th *material.Theme, img image.Image, loading bool, imgErr error) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if loading {
return material.Body2(th, i18n.T("hint_captcha_loading")).Layout(gtx)
}
if imgErr != nil {
return material.Body2(th, fmt.Sprintf(i18n.T("hint_captcha_failed"), imgErr.Error())).Layout(gtx)
}
if img == nil {
return layout.Dimensions{}
}
return widget.Image{
Src: paint.NewImageOp(img),
Fit: widget.Unscaled,
}.Layout(gtx)
}
}
// fetchCaptchaImage GETs the captcha URL and decodes it as PNG/JPEG/GIF.
// 10s timeout — Steam's captchas are tiny so anything longer is a hang.
func fetchCaptchaImage(rawURL string) (image.Image, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
// Steam's captcha endpoint accepts any UA but the rest of the flow
// uses the mobile UA, so be consistent.
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 4.1.1) Mobile")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
// Cap at 256KB — captchas are tiny; anything bigger is suspicious.
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return nil, err
}
return img, nil
}
+280
View File
@@ -0,0 +1,280 @@
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}
}
}
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"image"
"image/color"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// copyCodeToClipboard pushes the current OTP onto the system clipboard
// and pops a brief toast confirming the action. Errors are surfaced via
// the same toast so the user actually sees them.
func (st *appState) copyCodeToClipboard(en *entry, w *app.Window) {
const fn = "internal.ui.appState.copyCodeToClipboard"
if en == nil || en.Code == "" {
return
}
if err := win32.SetClipboardText(en.Code); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("clipboard copy failed")
st.toast.Show(i18n.T("msg_copy_failed"), w)
w.Invalidate()
return
}
st.toast.Show(i18n.T("msg_copied"), w)
w.Invalidate()
}
// drawEmptyPlaceholder paints the centered "no entries yet" hint shown
// when the entries list is empty.
func drawEmptyPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(th, i18n.T("msg_empty_list"))
lbl.Color = color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}
return layout.UniformInset(unit.Dp(8)).Layout(gtx, lbl.Layout)
})
}
// drawDivider paints a 1px-tall light-gray line across the available
// horizontal space. Used between entries in the list.
func drawDivider(gtx layout.Context) layout.Dimensions {
h := gtx.Dp(unit.Dp(1))
size := image.Pt(gtx.Constraints.Max.X, h)
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: size}
}
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"image/color"
"time"
"gioui.org/layout"
"gioui.org/unit"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
)
// entryProgressRing draws the per-row TOTP countdown ring. For HOTP
// entries it returns a same-sized blank box so the rows still line up.
func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
const ringDp = 22
if en.Auth == nil || en.Auth.Name() == "hotp" {
return layout.Dimensions{Size: gtx.Constraints.Constrain(
layout.Spacer{Width: unit.Dp(ringDp), Height: unit.Dp(ringDp)}.Layout(gtx).Size,
)}
}
period := totpPeriod(en.Auth)
if period <= 0 {
period = authenticator.DefaultPeriod
}
now := time.Now().Unix()
elapsed := now % int64(period)
remaining := int64(period) - elapsed
progress := float32(remaining) / float32(period)
fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
if remaining <= 5 {
fg = color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff}
}
bg := color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff}
return progressRing{
Size: unit.Dp(ringDp),
Stroke: unit.Dp(2.5),
Progress: progress,
Color: fg,
BgColor: bg,
}.Layout(gtx)
}
// totpPeriod extracts the configured period from any authenticator whose
// underlying Base we can reach. Returns 0 if the authenticator does not
// expose one (in which case callers fall back to the default).
func totpPeriod(a authenticator.Authenticator) int {
type periodGetter interface{ GetPeriod() int }
if pg, ok := a.(periodGetter); ok {
return pg.GetPeriod()
}
// All current TOTP-like authenticators (Google/Microsoft/Okta/Steam/
// BattleNet) embed authenticator.Base whose default period is 30s,
// matching the C# original. Hard-code that here.
return authenticator.DefaultPeriod
}
+133
View File
@@ -0,0 +1,133 @@
package ui
import (
"errors"
"gioui.org/app"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/hotkey"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// registerAllHotkeys walks every entry and registers its configured
// hotkey. Failure on a single row is logged and skipped so the rest
// keep working. Called once at startup; applyHotkey handles incremental
// updates.
func (st *appState) registerAllHotkeys() {
const fn = "internal.ui.appState.registerAllHotkeys"
st.mu.Lock()
defer st.mu.Unlock()
for _, en := range st.entries {
if en.Hotkey == "" {
continue
}
h, err := hotkey.Parse(en.Hotkey)
if err != nil {
global.Log.WithField("func", fn).WithField("entry", en.Name).
WithError(err).Warn("bad hotkey string; skipping")
continue
}
id, err := st.hkMgr.Register(h)
if err != nil {
global.Log.WithField("func", fn).WithField("entry", en.Name).
WithField("hotkey", en.Hotkey).WithError(err).
Warn("hotkey registration failed (already taken?)")
continue
}
en.hotkeyID = id
}
}
// applyHotkey updates target.Hotkey to value (empty string clears),
// unregistering the old binding and registering the new one. Errors are
// surfaced via st.saveErr so the user sees them.
func (st *appState) applyHotkey(target *entry, value string) {
const fn = "internal.ui.appState.applyHotkey"
st.mu.Lock()
defer st.mu.Unlock()
if target.hotkeyID != 0 {
if err := st.hkMgr.Unregister(target.hotkeyID); err != nil {
global.Log.WithField("func", fn).WithError(err).
Warn("unregister old hotkey failed")
}
target.hotkeyID = 0
}
target.Hotkey = value
if value == "" {
return
}
h, err := hotkey.Parse(value)
if err != nil {
st.saveErr = err.Error()
target.Hotkey = ""
return
}
id, err := st.hkMgr.Register(h)
if err != nil {
st.saveErr = err.Error()
target.Hotkey = ""
return
}
target.hotkeyID = id
}
// runHotkeyLoop drains the manager's Events channel and triggers the
// Auto-type flow for whichever entry owns the fired ID. Runs as a
// daemon goroutine until the events channel closes (Stop).
func (st *appState) runHotkeyLoop(w *app.Window) {
const fn = "internal.ui.appState.runHotkeyLoop"
for ev := range st.hkMgr.Events() {
target := st.findEntryByHotkeyID(ev.ID)
if target == nil {
continue
}
code, err := target.Auth.CurrentCode()
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("compute OTP failed")
continue
}
st.autoType(code)
w.Invalidate()
}
}
// findEntryByHotkeyID is a tiny lookup with the lock held just for the
// scan. Returns nil if no entry matches.
func (st *appState) findEntryByHotkeyID(id int32) *entry {
st.mu.Lock()
defer st.mu.Unlock()
for _, en := range st.entries {
if en.hotkeyID == id {
return en
}
}
return nil
}
// autoType pushes the OTP onto the clipboard and types it into the
// currently-foreground window. If GetForegroundWindow points at
// winauth-go itself (because the user was looking at it when the
// hotkey fired) we only copy — typing would inject into our own
// editor field which is almost never useful.
func (st *appState) autoType(code string) {
const fn = "internal.ui.appState.autoType"
if err := win32.SetClipboardText(code); err != nil {
if !errors.Is(err, win32.ErrUnsupported) {
global.Log.WithField("func", fn).WithError(err).Warn("clipboard write failed")
}
}
// We do not have a HWND for our own Gio window via the public API,
// so we cannot reliably detect "foreground is us." In practice the
// global hotkey almost always fires while another window is on top
// (that's the whole point), so we just inject blindly. The OTP is
// also on the clipboard as a safety net.
if err := win32.TypeUnicode(code); err != nil {
if !errors.Is(err, win32.ErrUnsupported) {
global.Log.WithField("func", fn).WithError(err).Warn("send input failed")
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package ui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
// progressRing renders a circular progress indicator. Progress is clamped
// to [0, 1]; 1.0 fills the full circle. Drawing approximates the arc with
// short line segments, which is plenty smooth at the small sizes used in
// list rows.
type progressRing struct {
Size unit.Dp
Stroke unit.Dp
Progress float32
Color color.NRGBA
BgColor color.NRGBA
}
func (r progressRing) Layout(gtx layout.Context) layout.Dimensions {
sizePx := gtx.Dp(r.Size)
if sizePx <= 0 {
return layout.Dimensions{}
}
strokePx := float32(gtx.Dp(r.Stroke))
if strokePx <= 0 {
strokePx = 2
}
center := f32.Pt(float32(sizePx)/2, float32(sizePx)/2)
radius := float32(sizePx)/2 - strokePx/2
if r.BgColor.A > 0 {
var bg clip.Path
bg.Begin(gtx.Ops)
buildArc(&bg, center, radius, -math.Pi/2, math.Pi*2)
paint.FillShape(gtx.Ops, r.BgColor,
clip.Stroke{Path: bg.End(), Width: strokePx}.Op())
}
if r.Progress > 0 {
sweep := float64(r.Progress) * math.Pi * 2
if sweep > math.Pi*2 {
sweep = math.Pi * 2
}
var fg clip.Path
fg.Begin(gtx.Ops)
buildArc(&fg, center, radius, -math.Pi/2, sweep)
paint.FillShape(gtx.Ops, r.Color,
clip.Stroke{Path: fg.End(), Width: strokePx}.Op())
}
return layout.Dimensions{Size: image.Pt(sizePx, sizePx)}
}
// buildArc emits a polyline approximation of an arc into p. startRad is
// the starting angle (radians, 0 = +x axis, clockwise), sweepRad is the
// signed angular extent.
func buildArc(p *clip.Path, center f32.Point, radius float32, startRad, sweepRad float64) {
const segs = 48
step := sweepRad / float64(segs)
start := f32.Pt(
center.X+radius*float32(math.Cos(startRad)),
center.Y+radius*float32(math.Sin(startRad)),
)
p.MoveTo(start)
for i := 1; i <= segs; i++ {
a := startRad + step*float64(i)
p.LineTo(f32.Pt(
center.X+radius*float32(math.Cos(a)),
center.Y+radius*float32(math.Sin(a)),
))
}
}
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// settingsAction is what the settings popup returns to the caller.
type settingsAction int
const (
settingsActionNone settingsAction = iota
settingsActionSetPassword
settingsActionImportLegacy
settingsActionAbout
)
// settingsMenu is the small popup that opens when the user clicks the
// gear button in the main window's top bar.
type settingsMenu struct {
setPwBtn widget.Clickable
importBtn widget.Clickable
aboutBtn widget.Clickable
cancelBtn widget.Clickable
}
func newSettingsMenu() *settingsMenu { return &settingsMenu{} }
// Pick returns the chosen action and whether the menu should close.
func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
switch {
case m.setPwBtn.Clicked(gtx):
return settingsActionSetPassword, true
case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true
case m.aboutBtn.Clicked(gtx):
return settingsActionAbout, true
case m.cancelBtn.Clicked(gtx):
return settingsActionNone, true
}
return settingsActionNone, false
}
func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return material.Button(th, btn, label).Layout(gtx)
})
})
}
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(280)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.setPwBtn, i18n.T("menu_set_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")),
row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout),
)
}),
)
})
})
})
}
+173
View File
@@ -0,0 +1,173 @@
package ui
import (
"errors"
"os"
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// store wraps the on-disk YAML config plus the in-memory passphrase.
// All writes go through an async, coalescing worker: callers Push() and
// the worker debounces rapid bursts (e.g. HOTP code clicks) into a single
// disk write.
//
// passphrase is held in memory for the lifetime of the process. We do not
// attempt to zero it after use — Go's garbage collector may move strings
// around freely, so secure-erase is largely placebo and would only buy a
// false sense of security. We instead enforce that it is never logged.
type store struct {
path string
mu sync.Mutex
passphrase []byte
encrypted bool
// dirty signals a save is pending. The worker reads & resets it.
dirty bool
snapshotFn func() []config.Entry
pendingErr error
saveTrigger chan struct{}
// onError is called from the save goroutine when a write fails.
// The caller is responsible for marshalling back to the UI thread.
onError func(error)
}
// newStore initializes a store and starts the background save worker.
// snapshotFn is invoked whenever a save runs; it must return a freshly
// copied entries slice (the worker holds no UI locks). onError is called
// asynchronously from the worker goroutine on save failures.
func newStore(path string, snapshotFn func() []config.Entry, onError func(error)) *store {
s := &store{
path: path,
snapshotFn: snapshotFn,
onError: onError,
saveTrigger: make(chan struct{}, 1),
}
go s.run()
return s
}
// Load reads the YAML file at path. If the file does not exist, returns
// (nil, nil) — the caller should treat that as an empty config. If the
// file is encrypted, passphrase must be valid; otherwise ErrPasswordRequired
// or ErrPasswordWrong is returned.
//
// On success the store's passphrase + encrypted flag are updated.
func (s *store) Load(passphrase []byte) (*config.Config, error) {
const fn = "internal.ui.store.Load"
logger := global.Log.WithField("func", fn).WithField("path", s.path)
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
logger.Debug("config file does not exist; starting empty")
s.mu.Lock()
s.passphrase = nil
s.encrypted = false
s.mu.Unlock()
return nil, nil
}
// First load: peek the header (no passphrase) to learn encrypted-ness.
cfg, err := config.LoadYAML(s.path, passphrase)
if err != nil {
switch {
case errors.Is(err, config.ErrPasswordRequired):
return cfg, ErrPasswordRequired
case errors.Is(err, config.ErrPasswordWrong):
return cfg, ErrPasswordWrong
default:
return nil, err
}
}
s.mu.Lock()
s.passphrase = passphrase
s.encrypted = cfg.Encrypted
s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil
}
// Sentinel errors returned by store.Load to signal the password UI path.
// We re-export config's sentinels here so the UI layer doesn't need to
// import internal/config directly.
var (
ErrPasswordRequired = config.ErrPasswordRequired
ErrPasswordWrong = config.ErrPasswordWrong
)
// SetPassword updates the in-memory passphrase. An empty value disables
// encryption on the next save. The change is queued for save immediately.
func (s *store) SetPassword(pw []byte) {
s.mu.Lock()
s.passphrase = pw
s.encrypted = len(pw) > 0
s.mu.Unlock()
s.Push()
}
// Encrypted reports whether the store will encrypt the next write.
func (s *store) Encrypted() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.encrypted
}
// Push schedules a save. Calls within ~300ms of each other coalesce into
// a single write.
func (s *store) Push() {
s.mu.Lock()
s.dirty = true
s.mu.Unlock()
select {
case s.saveTrigger <- struct{}{}:
default:
}
}
// LastError returns the most recent save error, if any.
func (s *store) LastError() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.pendingErr
}
func (s *store) run() {
const fn = "internal.ui.store.run"
for range s.saveTrigger {
// debounce: wait briefly to coalesce bursts
time.Sleep(300 * time.Millisecond)
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
continue
}
s.dirty = false
pw := append([]byte(nil), s.passphrase...)
enc := s.encrypted
s.mu.Unlock()
entries := s.snapshotFn()
cfg := &config.Config{
Version: 1,
Encrypted: enc,
Entries: entries,
}
if err := config.SaveYAML(cfg, s.path, pw); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("save failed")
s.mu.Lock()
s.pendingErr = err
s.mu.Unlock()
if s.onError != nil {
s.onError(err)
}
continue
}
s.mu.Lock()
s.pendingErr = nil
s.mu.Unlock()
}
}
+84
View File
@@ -0,0 +1,84 @@
package ui
import (
"image"
"image/color"
"time"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
)
// toast is a transient top-of-window notification ("Copied", error
// messages, etc). It is non-modal: drawing it does not consume pointer
// input. Lifetime is governed by `until`; the next frame after the
// deadline simply skips drawing.
type toast struct {
msg string
until time.Time
}
const toastDuration = 1500 * time.Millisecond
// Show stores the message and schedules an Invalidate after the toast
// expires so the window redraws and removes it without waiting for the
// next user interaction.
func (t *toast) Show(msg string, w *app.Window) {
t.msg = msg
t.until = time.Now().Add(toastDuration)
go func(deadline time.Time) {
time.Sleep(time.Until(deadline) + 50*time.Millisecond)
w.Invalidate()
}(t.until)
}
// active reports whether the toast should be drawn this frame.
func (t *toast) active() bool {
return t.msg != "" && time.Now().Before(t.until)
}
// draw paints the toast as an overlay centered near the top of gtx.
// Call AFTER laying out the rest of the frame so it stacks on top.
func (t *toast) draw(gtx layout.Context, th *material.Theme) {
if !t.active() {
return
}
bg := color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0}
fg := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
macro := op.Record(gtx.Ops)
pad := layout.UniformInset(unit.Dp(10))
dims := pad.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(th, t.msg)
lbl.Color = fg
return lbl.Layout(gtx)
})
call := macro.Stop()
// Center horizontally near the top.
x := (gtx.Constraints.Max.X - dims.Size.X) / 2
if x < 0 {
x = 0
}
y := gtx.Dp(unit.Dp(12))
stack := op.Offset(image.Point{X: x, Y: y}).Push(gtx.Ops)
rrect := clip.RRect{
Rect: image.Rectangle{Max: dims.Size},
SE: gtx.Dp(unit.Dp(6)),
SW: gtx.Dp(unit.Dp(6)),
NE: gtx.Dp(unit.Dp(6)),
NW: gtx.Dp(unit.Dp(6)),
}
bgArea := rrect.Push(gtx.Ops)
paint.ColorOp{Color: bg}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgArea.Pop()
call.Add(gtx.Ops)
stack.Pop()
}
+116
View File
@@ -0,0 +1,116 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// vendor identifies which Add dialog should be opened next.
type vendor int
const (
vendorNone vendor = iota
vendorGoogle
vendorMicrosoft
vendorOkta
vendorHOTP
vendorBattleNet
vendorBattleNetRestore
vendorSteam
vendorScanQR
)
// vendorMenu is the little popup shown after clicking the Add button.
// It lets the user pick which kind of authenticator to enroll. Selecting
// any entry returns that vendor via Pick(); Cancel returns vendorNone and
// signals the menu should close.
type vendorMenu struct {
googleBtn widget.Clickable
microsoftBtn widget.Clickable
oktaBtn widget.Clickable
hotpBtn widget.Clickable
bnetBtn widget.Clickable
bnetRestoreBtn widget.Clickable
steamBtn widget.Clickable
scanQRBtn widget.Clickable
cancelBtn widget.Clickable
}
func newVendorMenu() *vendorMenu { return &vendorMenu{} }
// Pick returns the vendor selected this frame (vendorNone if no click) and
// whether the menu should close (true on any click, including Cancel).
func (m *vendorMenu) Pick(gtx layout.Context) (vendor, bool) {
switch {
case m.googleBtn.Clicked(gtx):
return vendorGoogle, true
case m.microsoftBtn.Clicked(gtx):
return vendorMicrosoft, true
case m.oktaBtn.Clicked(gtx):
return vendorOkta, true
case m.hotpBtn.Clicked(gtx):
return vendorHOTP, true
case m.bnetBtn.Clicked(gtx):
return vendorBattleNet, true
case m.bnetRestoreBtn.Clicked(gtx):
return vendorBattleNetRestore, true
case m.steamBtn.Clicked(gtx):
return vendorSteam, true
case m.scanQRBtn.Clicked(gtx):
return vendorScanQR, true
case m.cancelBtn.Clicked(gtx):
return vendorNone, true
}
return vendorNone, false
}
func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return material.Button(th, btn, label).Layout(gtx)
})
})
}
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(320)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.googleBtn, i18n.T("vendor_google")),
row(&m.microsoftBtn, i18n.T("vendor_microsoft")),
row(&m.oktaBtn, i18n.T("vendor_okta")),
row(&m.hotpBtn, i18n.T("vendor_hotp")),
row(&m.bnetBtn, i18n.T("vendor_battlenet")),
row(&m.bnetRestoreBtn, i18n.T("vendor_battlenet_restore")),
row(&m.steamBtn, i18n.T("vendor_steam")),
row(&m.scanQRBtn, i18n.T("vendor_scan_qr")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout),
)
}),
)
})
})
})
}