// Package ui hosts the Gio-based desktop user interface. package ui import ( "errors" "fmt" "image/color" "os" "sync" "time" "gioui.org/app" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "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/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 changePwDlg *changePasswordDialog importDialog *importLegacyDialog hotkeyDialog *hotkeyDialog hotkeyTarget *entry tradesDialog *steamTradesDialog prefsDialog *preferencesDialog aboutDialog *aboutDialog store *store saveErr string // surfaced in the top bar hkMgr *win32.HotkeyManager toast toast // themeMode is the user's current theme preference. drawFrame // rebuilds the material.Theme when this changes. themeMode themeMode // theme is the cached material.Theme matching themeMode. nil forces a // rebuild on the next frame. theme *material.Theme // Auto-lock state. Active only when the config is encrypted and the // user set auto_lock_minutes > 0. lastActivity is bumped on any UI // interaction; once now-lastActivity exceeds the threshold the lock // dialog is raised and the entry list hidden until the user // re-enters the live passphrase. lastActivity time.Time locked bool lockDialog *passwordDialog // tray owns the system-tray icon and (when minimize_to_tray is on) // the WM_CLOSE subclass. nil on platforms or installs where it // could not be set up. tray *trayRuntime } // 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" state := &appState{} state.list.Axis = layout.Vertical state.lastActivity = time.Now() 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) } // Seed the theme cache from the stored preference. Empty / unknown // values resolve to "system" which buildTheme then maps to light or // dark via the OS-specific systemPrefersDark probe. _, themePref, _, _ := state.store.Preferences() state.themeMode = normalizeThemeMode(themePref) // 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) // Install the system tray icon + close-hook (Windows only). HWND // discovery races the very first FrameEvent, so kick it from a // goroutine that polls FindWindow for a few hundred ms. _, _, _, minToTray := state.store.Preferences() go func() { tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate) state.mu.Lock() state.tray = tr state.mu.Unlock() }() // 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") state.tray.Stop() return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) if state.theme == nil { th, pal := buildTheme(state.themeMode) state.theme = th activePalette = pal } // Paint the window background using the active palette so // dark mode does not show through as the Gio default grey. fillBackground(gtx, activePalette.Background) drawFrame(gtx, state.theme, state, w) e.Frame(gtx.Ops) } } } // maybeAutoLock checks whether the inactivity threshold has elapsed and, // if so, raises the lock dialog. Returns true when the UI should render // the lock prompt this frame and skip the rest of the layout. The check // is a no-op unless the configuration is encrypted AND the user set // auto_lock_minutes > 0 — locking a plaintext config buys nothing // security-wise (the file is already readable). // // Note: we do NOT clear store.passphrase on lock. Re-decrypt on every // unlock would mean a transient window where we cannot save (e.g. a // hotkey press just before unlock would race), and it would not improve // the security model — process memory holding the key is the threat // either way. func (st *appState) maybeAutoLock() bool { if st.locked { return true } if !st.store.Encrypted() { return false } _, _, autoLockMinutes, _ := st.store.Preferences() if autoLockMinutes <= 0 { return false } if st.lastActivity.IsZero() { st.lastActivity = time.Now() return false } threshold := time.Duration(autoLockMinutes) * time.Minute if time.Since(st.lastActivity) < threshold { return false } st.locked = true st.lockDialog = newPasswordDialog(i18n.T("msg_locked")) return true } // 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 { // Auto-lock check runs before any input is dispatched so a user who // returns mid-frame still has to type the passphrase before they can // interact. Active only when the config is encrypted and the user // configured a positive timeout. if st.maybeAutoLock() && st.lockDialog != nil { return st.lockDialog.Layout(gtx, th, func(pw []byte, ok bool) { if !ok { // Cancel is meaningless while locked — just re-prompt. st.lockDialog.SetError(i18n.T("msg_locked")) w.Invalidate() return } if st.store.VerifyPassword(pw) { st.locked = false st.lockDialog = nil st.lastActivity = time.Now() } else { st.lockDialog.SetError(i18n.T("msg_password_wrong")) } w.Invalidate() }) } if st.addBtn.Clicked(gtx) { st.vendorMenu = newVendorMenu() st.lastActivity = time.Now() } 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() st.lastActivity = time.Now() } if st.settingsBtn.Clicked(gtx) { st.settingsMenu = newSettingsMenu() st.lastActivity = time.Now() } // 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 st.lastActivity = time.Now() } } if en.hotkeyBtn.Clicked(gtx) { hotkeyTarget = en st.lastActivity = time.Now() } if en.copyBtn.Clicked(gtx) { copyTarget = en st.lastActivity = time.Now() } if en.Auth.Name() == "hotp" { if en.click.Clicked(gtx) { if code, err := en.Auth.CurrentCode(); err == nil { en.Code = code // Surface the new counter value (not the code) so the // user has feedback that the click registered. if h, ok := en.Auth.(*authenticator.HOTPAuthenticator); ok { st.toast.Show(fmt.Sprintf(i18n.T("msg_hotp_advanced"), h.Counter), w) } st.lastActivity = time.Now() // 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.changePwDlg != nil { return st.changePwDlg.Layout(gtx, th, func(oldPw []byte) bool { return st.store.VerifyPassword(oldPw) }, func(r changePasswordResult) { if !r.cancel { st.store.SetPassword(r.newPw) st.toast.Show(i18n.T("msg_password_changed"), w) } st.changePwDlg = 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 settingsActionChangePassword: st.changePwDlg = newChangePasswordDialog() case settingsActionImportLegacy: st.importDialog = newImportLegacyDialog() case settingsActionPreferences: lang, theme, autoLock, minToTray := st.store.Preferences() st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray) case settingsActionAbout: st.aboutDialog = newAboutDialog() } w.Invalidate() } else { return st.settingsMenu.Layout(gtx, th) } } if st.prefsDialog != nil { return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) { if !r.cancel { _, _, _, prevMinToTray := st.store.Preferences() st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray) i18n.SetLanguage(r.language) st.themeMode = r.theme st.theme = nil // force rebuild on next frame // Re-arm or release the WM_CLOSE hook when the user // flips the minimize-to-tray preference. Hot-reloading // avoids forcing a restart for a setting change. if prevMinToTray != r.minimizeToTray && st.tray != nil { st.tray.setMinimizeToTray(r.minimizeToTray) } } st.prefsDialog = nil w.Invalidate() }) } if st.aboutDialog != nil { return st.aboutDialog.Layout(gtx, th, func() { st.aboutDialog = nil w.Invalidate() }) } 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 = activePalette.ErrorFg 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 = activePalette.RingFg 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) }