feat: Phase 1 WinAuth Go 移植完整实现
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。 核心模块: - internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含 enroll/sync/code 生成、Steam 交易确认轮询 - internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链) - internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB - internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) + SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换 - internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化 - internal/qr: gozxing 二维码解码 + otpauth:// URI 解析 - internal/i18n: en/zh-CN/de 三语 TOML UI 模块 (Gio): - 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线 - 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板) - 设置:密码加密、老版 XML 导入、每条目热键配置 - Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗 构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user