From b483360778e630d2c45679b84dce578d0e947515 Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 03:38:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E4=B8=BB=E9=A2=98=E5=88=87?= =?UTF-8?q?=E6=8D=A2=20+=20=E8=AF=AD=E8=A8=80=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=20+=20About=20=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 light/dark 主题调色板与 Windows 注册表系统主题探测 - 偏好设置对话框:主题三选一 + 语言下拉(en/zh-CN/de),即时生效 - 持久化 Language/Theme/AutoLockMinutes/MinimizeToTray 至 YAML 顶层 - 启动时按 config 中保存的语言初始化 i18n - About 对话框展示 version/runtime/项目地址/许可证 - 全部硬编码颜色迁移到 themePalette,为深色模式做准备 --- cmd/winauth/main.go | 28 +++++- internal/config/model.go | 18 ++-- internal/i18n/locales/de.toml | 52 ++++++++++++ internal/i18n/locales/en.toml | 52 ++++++++++++ internal/i18n/locales/zh-CN.toml | 52 ++++++++++++ internal/ui/app.go | 60 +++++++++++-- internal/ui/dialog.go | 16 ++-- internal/ui/dialog_about.go | 55 ++++++++++++ internal/ui/dialog_preferences.go | 136 ++++++++++++++++++++++++++++++ internal/ui/entry_actions.go | 5 +- internal/ui/entry_ring.go | 7 +- internal/ui/settings_menu.go | 13 +-- internal/ui/store.go | 46 +++++++++- internal/ui/theme.go | 122 +++++++++++++++++++++++++++ internal/ui/theme_other.go | 7 ++ internal/ui/theme_windows.go | 25 ++++++ internal/ui/toast.go | 5 +- internal/ui/vendor_menu.go | 8 +- internal/version/version.go | 28 ++++++ 19 files changed, 687 insertions(+), 48 deletions(-) create mode 100644 internal/ui/dialog_about.go create mode 100644 internal/ui/dialog_preferences.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/theme_other.go create mode 100644 internal/ui/theme_windows.go create mode 100644 internal/version/version.go diff --git a/cmd/winauth/main.go b/cmd/winauth/main.go index acc32ac..1b6e2c5 100644 --- a/cmd/winauth/main.go +++ b/cmd/winauth/main.go @@ -6,10 +6,12 @@ import ( "github.com/spf13/cobra" + "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/logging" "git.wxccs.org/iceking2nd/winauth-go/internal/ui" + "git.wxccs.org/iceking2nd/winauth-go/internal/version" "git.wxccs.org/iceking2nd/winauth-go/internal/win32" ) @@ -53,7 +55,8 @@ func main() { } const fn = "cmd.winauth.main" - global.Log.WithField("func", fn).Info("winauth-go starting") + global.Log.WithField("func", fn). + WithField("version", version.String()).Info("winauth-go starting") release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex) if err != nil { @@ -70,7 +73,12 @@ func main() { defer release() } - if err := i18n.Init(""); err != nil { + // Peek the config (without password) to recover the user's + // last language choice before initialising i18n. Encrypted + // configs still expose Language at the YAML top level so we + // can localise the password prompt itself. + lang := preferredLanguage(configPath) + if err := i18n.Init(lang); err != nil { global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys") } @@ -91,3 +99,19 @@ func main() { os.Exit(1) } } + +// preferredLanguage reads the user's saved language tag out of the YAML +// config without requiring a passphrase. We resolve the default config +// path when configPath is blank and silently return "" on any failure; +// callers fall back to the i18n default in that case. Encrypted configs +// still expose the top-level Language field so this works for them too. +func preferredLanguage(configPath string) string { + if configPath == "" { + configPath = config.DefaultPath() + } + cfg, _ := config.LoadYAML(configPath, nil) + if cfg == nil { + return "" + } + return cfg.Language +} diff --git a/internal/config/model.go b/internal/config/model.go index c033722..9670c41 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -21,9 +21,17 @@ type Entry struct { // default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext // produced by internal/crypto.EncryptModern and Entries is empty on disk. type Config struct { - Version int `yaml:"version" json:"version"` - Language string `yaml:"language,omitempty" json:"language,omitempty"` - Encrypted bool `yaml:"encrypted" json:"encrypted"` - EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` - Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` + Version int `yaml:"version" json:"version"` + Language string `yaml:"language,omitempty" json:"language,omitempty"` + // Theme is one of "", "light", "dark", "system" (empty = system). + Theme string `yaml:"theme,omitempty" json:"theme,omitempty"` + // AutoLockMinutes locks the UI (hides codes, requires password) after + // this many minutes of inactivity. 0 disables auto-lock. + AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"` + // MinimizeToTray sends the window to the system tray instead of + // exiting when the close button is pressed. + MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"` + Encrypted bool `yaml:"encrypted" json:"encrypted"` + EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` + Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"` } diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index a9530a9..7558a17 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -367,3 +367,55 @@ other = "QR-Scan fehlgeschlagen: %s" [msg_clipboard_no_image] other = "Zwischenablage enthält kein Bild" + +# --- Einstellungen --- + +[menu_preferences] +other = "Einstellungen..." + +[dialog_preferences_title] +other = "Einstellungen" + +[label_theme] +other = "Design" + +[theme_system] +other = "System" + +[theme_light] +other = "Hell" + +[theme_dark] +other = "Dunkel" + +[label_language] +other = "Sprache" + +# --- Über-Dialog --- + +[dialog_about_title] +other = "Über WinAuth" + +[about_app_name] +other = "WinAuth (Go-Portierung) — TOTP/HOTP-Authentifikator" + +[about_version_label] +other = "Version" + +[about_runtime_label] +other = "Laufzeit" + +[about_project_label] +other = "Projekt" + +[about_project_url] +other = "https://git.wxccs.org/iceking2nd/winauth-go" + +[about_license_label] +other = "Lizenz" + +[about_license_value] +other = "MIT" + +[about_credits] +other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index aea6eaa..b01168e 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -369,3 +369,55 @@ other = "QR scan failed: %s" [msg_clipboard_no_image] other = "Clipboard does not contain an image" + +# --- Preferences --- + +[menu_preferences] +other = "Preferences..." + +[dialog_preferences_title] +other = "Preferences" + +[label_theme] +other = "Theme" + +[theme_system] +other = "System" + +[theme_light] +other = "Light" + +[theme_dark] +other = "Dark" + +[label_language] +other = "Language" + +# --- About dialog --- + +[dialog_about_title] +other = "About WinAuth" + +[about_app_name] +other = "WinAuth (Go port) — TOTP/HOTP authenticator" + +[about_version_label] +other = "Version" + +[about_runtime_label] +other = "Runtime" + +[about_project_label] +other = "Project" + +[about_project_url] +other = "https://git.wxccs.org/iceking2nd/winauth-go" + +[about_license_label] +other = "License" + +[about_license_value] +other = "MIT" + +[about_credits] +other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index 923b515..aadf2d1 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -367,3 +367,55 @@ other = "二维码扫描失败:%s" [msg_clipboard_no_image] other = "剪贴板中没有图片" + +# --- 偏好设置 --- + +[menu_preferences] +other = "偏好设置..." + +[dialog_preferences_title] +other = "偏好设置" + +[label_theme] +other = "主题" + +[theme_system] +other = "跟随系统" + +[theme_light] +other = "浅色" + +[theme_dark] +other = "深色" + +[label_language] +other = "语言" + +# --- 关于对话框 --- + +[dialog_about_title] +other = "关于 WinAuth" + +[about_app_name] +other = "WinAuth (Go 版) — TOTP/HOTP 验证器" + +[about_version_label] +other = "版本" + +[about_runtime_label] +other = "运行时" + +[about_project_label] +other = "项目主页" + +[about_project_url] +other = "https://git.wxccs.org/iceking2nd/winauth-go" + +[about_license_label] +other = "许可证" + +[about_license_value] +other = "MIT" + +[about_credits] +other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。" diff --git a/internal/ui/app.go b/internal/ui/app.go index 5d2f6ef..de9cd31 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -10,12 +10,10 @@ import ( "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" @@ -95,6 +93,8 @@ type appState struct { hotkeyDialog *hotkeyDialog hotkeyTarget *entry tradesDialog *steamTradesDialog + prefsDialog *preferencesDialog + aboutDialog *aboutDialog store *store saveErr string // surfaced in the top bar @@ -102,6 +102,13 @@ type appState struct { 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 } // snapshotEntries returns a freshly serialized slice of config entries. @@ -119,9 +126,6 @@ func (st *appState) snapshotEntries() []config.Entry { 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 @@ -146,6 +150,12 @@ func loop(w *app.Window, configPath string) error { 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). @@ -170,7 +180,15 @@ func loop(w *app.Window, configPath string) error { return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) - drawFrame(gtx, th, state, w) + 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) } } @@ -320,8 +338,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind st.setPwDialog = newSetPasswordDialog() case settingsActionImportLegacy: st.importDialog = newImportLegacyDialog() + case settingsActionPreferences: + lang, theme, _, _ := st.store.Preferences() + st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme)) case settingsActionAbout: - // TODO: about dialog (next phase). + st.aboutDialog = newAboutDialog() } w.Invalidate() } else { @@ -329,6 +350,27 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind } } + if st.prefsDialog != nil { + return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) { + if !r.cancel { + _, _, autoLock, minToTray := st.store.Preferences() + st.store.SetPreferences(r.language, string(r.theme), autoLock, minToTray) + i18n.SetLanguage(r.language) + st.themeMode = r.theme + st.theme = nil // force rebuild on next frame + } + 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 { @@ -427,7 +469,7 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind return layout.Dimensions{} } lbl := material.Body2(th, msg) - lbl.Color = color.NRGBA{R: 0xc0, A: 0xff} + lbl.Color = activePalette.ErrorFg return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), @@ -487,7 +529,7 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio }), 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} + lbl.Color = activePalette.RingFg return lbl.Layout(gtx) }), ) diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go index b1a51c1..1bfb6ed 100644 --- a/internal/ui/dialog.go +++ b/internal/ui/dialog.go @@ -1,8 +1,6 @@ package ui import ( - "image/color" - "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" @@ -36,16 +34,16 @@ func modalCard( cancelBtn *widget.Clickable, body layout.Widget, ) layout.Dimensions { - fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60}) + fillBackground(gtx, activePalette.ScrimBg) 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}, + Color: activePalette.DialogBorder, 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}) + fillBackground(gtx, activePalette.DialogBg) 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), @@ -79,16 +77,16 @@ func modalCardCancel( cancelBtn *widget.Clickable, body layout.Widget, ) layout.Dimensions { - fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60}) + fillBackground(gtx, activePalette.ScrimBg) 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}, + Color: activePalette.DialogBorder, 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}) + fillBackground(gtx, activePalette.DialogBg) 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), @@ -114,7 +112,7 @@ func errorLabel(th *material.Theme, msg string) layout.Widget { return layout.Dimensions{} } lbl := material.Body2(th, msg) - lbl.Color = color.NRGBA{R: 0xc0, A: 0xff} + lbl.Color = activePalette.ErrorFg return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout) } } diff --git a/internal/ui/dialog_about.go b/internal/ui/dialog_about.go new file mode 100644 index 0000000..529cf8e --- /dev/null +++ b/internal/ui/dialog_about.go @@ -0,0 +1,55 @@ +package ui + +import ( + "runtime" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" + "git.wxccs.org/iceking2nd/winauth-go/internal/version" +) + +// aboutDialog is a read-only popup describing the build, runtime and a +// short credits / project link blurb. Click "Close" to dismiss. +type aboutDialog struct { + closeBt widget.Clickable +} + +func newAboutDialog() *aboutDialog { return &aboutDialog{} } + +// Layout follows the simpler "single button" contract: onDone is called +// with no arguments when the user dismisses the dialog. +func (d *aboutDialog) Layout( + gtx layout.Context, th *material.Theme, + onDone func(), +) layout.Dimensions { + if d.closeBt.Clicked(gtx) { + onDone() + return layout.Dimensions{Size: gtx.Constraints.Max} + } + + body := func(gtx layout.Context) layout.Dimensions { + line := func(text string) layout.FlexChild { + return layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(2), Bottom: unit.Dp(2)}.Layout(gtx, + material.Body2(th, text).Layout) + }) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + line(i18n.T("about_app_name")), + line(i18n.T("about_version_label")+": "+version.String()), + line(i18n.T("about_runtime_label")+": "+runtime.Version()+" ("+runtime.GOOS+"/"+runtime.GOARCH+")"), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + line(i18n.T("about_project_label")+": "+i18n.T("about_project_url")), + line(i18n.T("about_license_label")+": "+i18n.T("about_license_value")), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + line(i18n.T("about_credits")), + ) + } + + return modalCardCancel(gtx, th, i18n.T("dialog_about_title"), + i18n.T("btn_close"), &d.closeBt, body) +} diff --git a/internal/ui/dialog_preferences.go b/internal/ui/dialog_preferences.go new file mode 100644 index 0000000..db6fb21 --- /dev/null +++ b/internal/ui/dialog_preferences.go @@ -0,0 +1,136 @@ +package ui + +import ( + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +// preferencesDialog edits the persistent UI prefs: UI language and +// theme. Auto-lock and minimize-to-tray live in their own bucket and +// will appear here later. +type preferencesDialog struct { + lang string + theme themeMode + + // Theme selection — three "radio" buttons. + themeSystemBt widget.Clickable + themeLightBt widget.Clickable + themeDarkBt widget.Clickable + + // Language selection — one button per supported locale. Cheap + // substitute for a dropdown until Gio gains a native one. + langEnBt widget.Clickable + langZhCNBt widget.Clickable + langDeBt widget.Clickable + + okBt widget.Clickable + cancelBt widget.Clickable +} + +// preferencesResult is what Layout's callback receives. cancel skips +// any persistence. +type preferencesResult struct { + cancel bool + language string + theme themeMode +} + +// supportedLanguages is the closed set the prefs dialog exposes. Keep +// in sync with internal/i18n/locales/*.toml. +var supportedLanguages = []struct { + tag string + label string +}{ + {"en", "English"}, + {"zh-CN", "中文 (简体)"}, + {"de", "Deutsch"}, +} + +func newPreferencesDialog(currentLang string, currentTheme themeMode) *preferencesDialog { + if currentLang == "" { + currentLang = "en" + } + if currentTheme == "" { + currentTheme = themeSystem + } + return &preferencesDialog{lang: currentLang, theme: currentTheme} +} + +func (d *preferencesDialog) Layout( + gtx layout.Context, th *material.Theme, + onDone func(preferencesResult), +) layout.Dimensions { + if d.cancelBt.Clicked(gtx) { + onDone(preferencesResult{cancel: true}) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + if d.okBt.Clicked(gtx) { + onDone(preferencesResult{language: d.lang, theme: d.theme}) + return layout.Dimensions{Size: gtx.Constraints.Max} + } + + // Map button clicks to model updates before the next layout. + switch { + case d.themeSystemBt.Clicked(gtx): + d.theme = themeSystem + case d.themeLightBt.Clicked(gtx): + d.theme = themeLight + case d.themeDarkBt.Clicked(gtx): + d.theme = themeDark + case d.langEnBt.Clicked(gtx): + d.lang = "en" + case d.langZhCNBt.Clicked(gtx): + d.lang = "zh-CN" + case d.langDeBt.Clicked(gtx): + d.lang = "de" + } + + // Render one labeled "chip" — emphasised when selected, regular + // otherwise. Implemented as a material.Button so the click target + // stays generous without us hand-rolling a Pointer area. + chip := func(btn *widget.Clickable, label string, selected bool) layout.FlexChild { + return layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Right: unit.Dp(8), Bottom: unit.Dp(4)}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + b := material.Button(th, btn, label) + if !selected { + b.Background = activePalette.RingBg + b.Color = activePalette.OnBackground + } + return b.Layout(gtx) + }) + }) + } + + body := func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(material.Body1(th, i18n.T("label_theme")).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + chip(&d.themeSystemBt, i18n.T("theme_system"), d.theme == themeSystem), + chip(&d.themeLightBt, i18n.T("theme_light"), d.theme == themeLight), + chip(&d.themeDarkBt, i18n.T("theme_dark"), d.theme == themeDark), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(material.Body1(th, i18n.T("label_language")).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + chip(&d.langEnBt, supportedLanguages[0].label, d.lang == "en"), + chip(&d.langZhCNBt, supportedLanguages[1].label, d.lang == "zh-CN"), + chip(&d.langDeBt, supportedLanguages[2].label, d.lang == "de"), + ) + }), + ) + } + + return modalCard(gtx, th, i18n.T("dialog_preferences_title"), + i18n.T("btn_ok"), i18n.T("btn_cancel"), + &d.okBt, &d.cancelBt, body) +} diff --git a/internal/ui/entry_actions.go b/internal/ui/entry_actions.go index dcc87d7..ba682cd 100644 --- a/internal/ui/entry_actions.go +++ b/internal/ui/entry_actions.go @@ -2,7 +2,6 @@ package ui import ( "image" - "image/color" "gioui.org/app" "gioui.org/layout" @@ -39,7 +38,7 @@ func (st *appState) copyCodeToClipboard(en *entry, w *app.Window) { 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} + lbl.Color = activePalette.MutedFg return layout.UniformInset(unit.Dp(8)).Layout(gtx, lbl.Layout) }) } @@ -50,7 +49,7 @@ 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.ColorOp{Color: activePalette.Divider}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) return layout.Dimensions{Size: size} } diff --git a/internal/ui/entry_ring.go b/internal/ui/entry_ring.go index f0ab4ce..358a768 100644 --- a/internal/ui/entry_ring.go +++ b/internal/ui/entry_ring.go @@ -1,7 +1,6 @@ package ui import ( - "image/color" "time" "gioui.org/layout" @@ -28,11 +27,11 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions { remaining := int64(period) - elapsed progress := float32(remaining) / float32(period) - fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff} + fg := activePalette.RingFg if remaining <= 5 { - fg = color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff} + fg = activePalette.RingFgWarn } - bg := color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff} + bg := activePalette.RingBg return progressRing{ Size: unit.Dp(ringDp), diff --git a/internal/ui/settings_menu.go b/internal/ui/settings_menu.go index ea4b604..1d225c0 100644 --- a/internal/ui/settings_menu.go +++ b/internal/ui/settings_menu.go @@ -1,8 +1,6 @@ package ui import ( - "image/color" - "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" @@ -18,6 +16,7 @@ const ( settingsActionNone settingsAction = iota settingsActionSetPassword settingsActionImportLegacy + settingsActionPreferences settingsActionAbout ) @@ -26,6 +25,7 @@ const ( type settingsMenu struct { setPwBtn widget.Clickable importBtn widget.Clickable + prefsBtn widget.Clickable aboutBtn widget.Clickable cancelBtn widget.Clickable } @@ -39,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) { return settingsActionSetPassword, true case m.importBtn.Clicked(gtx): return settingsActionImportLegacy, true + case m.prefsBtn.Clicked(gtx): + return settingsActionPreferences, true case m.aboutBtn.Clicked(gtx): return settingsActionAbout, true case m.cancelBtn.Clicked(gtx): @@ -48,7 +50,7 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) { } func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60}) + fillBackground(gtx, activePalette.ScrimBg) row := func(btn *widget.Clickable, label string) layout.FlexChild { return layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -63,17 +65,18 @@ func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dim 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}, + Color: activePalette.DialogBorder, 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}) + fillBackground(gtx, activePalette.DialogBg) 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.prefsBtn, i18n.T("menu_preferences")), row(&m.aboutBtn, i18n.T("menu_about")), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { diff --git a/internal/ui/store.go b/internal/ui/store.go index 8f33f86..b391ad6 100644 --- a/internal/ui/store.go +++ b/internal/ui/store.go @@ -26,6 +26,13 @@ type store struct { passphrase []byte encrypted bool + // Top-level user preferences kept alongside entries so save() can + // roundtrip them without requiring the UI to rebuild a full Config. + language string + theme string + autoLockMinutes int + minimizeToTray bool + // dirty signals a save is pending. The worker reads & resets it. dirty bool snapshotFn func() []config.Entry @@ -37,6 +44,27 @@ type store struct { onError func(error) } +// Preferences returns the persisted top-level prefs. UI code reads these +// after Load to seed dialog defaults. +func (s *store) Preferences() (language, theme string, autoLockMinutes int, minimizeToTray bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.language, s.theme, s.autoLockMinutes, s.minimizeToTray +} + +// SetPreferences updates the persisted top-level prefs and schedules a +// save. Callers pass the current value for each field — there is no +// per-field "leave unchanged" sentinel. +func (s *store) SetPreferences(language, theme string, autoLockMinutes int, minimizeToTray bool) { + s.mu.Lock() + s.language = language + s.theme = theme + s.autoLockMinutes = autoLockMinutes + s.minimizeToTray = minimizeToTray + s.mu.Unlock() + s.Push() +} + // 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 @@ -86,6 +114,10 @@ func (s *store) Load(passphrase []byte) (*config.Config, error) { s.mu.Lock() s.passphrase = passphrase s.encrypted = cfg.Encrypted + s.language = cfg.Language + s.theme = cfg.Theme + s.autoLockMinutes = cfg.AutoLockMinutes + s.minimizeToTray = cfg.MinimizeToTray s.mu.Unlock() logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store") return cfg, nil @@ -148,13 +180,21 @@ func (s *store) run() { s.dirty = false pw := append([]byte(nil), s.passphrase...) enc := s.encrypted + lang := s.language + theme := s.theme + autoLock := s.autoLockMinutes + minToTray := s.minimizeToTray s.mu.Unlock() entries := s.snapshotFn() cfg := &config.Config{ - Version: 1, - Encrypted: enc, - Entries: entries, + Version: 1, + Language: lang, + Theme: theme, + AutoLockMinutes: autoLock, + MinimizeToTray: minToTray, + Encrypted: enc, + Entries: entries, } if err := config.SaveYAML(cfg, s.path, pw); err != nil { global.Log.WithField("func", fn).WithError(err).Error("save failed") diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..3593d48 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,122 @@ +// Package ui internal helpers — theme palette + system-theme detection. + +package ui + +import ( + "image/color" + + "gioui.org/font/gofont" + "gioui.org/text" + "gioui.org/widget/material" +) + +// themeMode is one of the user-selectable theme settings. +type themeMode string + +const ( + themeSystem themeMode = "system" + themeLight themeMode = "light" + themeDark themeMode = "dark" +) + +// themePalette holds the small set of colors the app itself draws +// (toast, dividers, ring background, etc). The material.Theme handles +// button + body text colors via its own palette. +type themePalette struct { + Background color.NRGBA + OnBackground color.NRGBA + Divider color.NRGBA + RingBg color.NRGBA + RingFg color.NRGBA + RingFgWarn color.NRGBA + ToastBg color.NRGBA + ToastFg color.NRGBA + ErrorFg color.NRGBA + DialogBg color.NRGBA + DialogBorder color.NRGBA + ScrimBg color.NRGBA // backdrop behind modal dialogs + MutedFg color.NRGBA // for placeholder / hint text +} + +func lightPalette() themePalette { + return themePalette{ + Background: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, + OnBackground: color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff}, + Divider: color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff}, + RingBg: color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff}, + RingFg: color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}, + RingFgWarn: color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff}, + ToastBg: color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0}, + ToastFg: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, + ErrorFg: color.NRGBA{R: 0xc0, G: 0x00, B: 0x00, A: 0xff}, + DialogBg: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, + DialogBorder: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, + ScrimBg: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x60}, + MutedFg: color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}, + } +} + +func darkPalette() themePalette { + return themePalette{ + Background: color.NRGBA{R: 0x1e, G: 0x1e, B: 0x1e, A: 0xff}, + OnBackground: color.NRGBA{R: 0xf0, G: 0xf0, B: 0xf0, A: 0xff}, + Divider: color.NRGBA{R: 0x38, G: 0x38, B: 0x38, A: 0xff}, + RingBg: color.NRGBA{R: 0x3a, G: 0x3a, B: 0x3a, A: 0xff}, + RingFg: color.NRGBA{R: 0x4d, G: 0xa3, B: 0xff, A: 0xff}, + RingFgWarn: color.NRGBA{R: 0xff, G: 0x6b, B: 0x6b, A: 0xff}, + ToastBg: color.NRGBA{R: 0xf0, G: 0xf0, B: 0xf0, A: 0xee}, + ToastFg: color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff}, + ErrorFg: color.NRGBA{R: 0xff, G: 0x6b, B: 0x6b, A: 0xff}, + DialogBg: color.NRGBA{R: 0x2a, G: 0x2a, B: 0x2a, A: 0xff}, + DialogBorder: color.NRGBA{R: 0x60, G: 0x60, B: 0x60, A: 0xff}, + ScrimBg: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x90}, + MutedFg: color.NRGBA{R: 0x90, G: 0x90, B: 0x90, A: 0xff}, + } +} + +// buildTheme returns a fresh material.Theme + companion palette for the +// requested mode. "system" resolves via systemPrefersDark (Windows +// registry on win, false elsewhere). +func buildTheme(mode themeMode) (*material.Theme, themePalette) { + resolved := mode + if resolved == "" || resolved == themeSystem { + if systemPrefersDark() { + resolved = themeDark + } else { + resolved = themeLight + } + } + var pal themePalette + if resolved == themeDark { + pal = darkPalette() + } else { + pal = lightPalette() + } + + th := material.NewTheme() + th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) + th.Palette.Bg = pal.Background + th.Palette.Fg = pal.OnBackground + if resolved == themeDark { + th.Palette.ContrastBg = color.NRGBA{R: 0x4d, G: 0xa3, B: 0xff, A: 0xff} + th.Palette.ContrastFg = color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff} + } + return th, pal +} + +// normalizeThemeMode coerces an arbitrary config string into one of the +// known modes. Unknown values fall back to "system". +func normalizeThemeMode(s string) themeMode { + switch themeMode(s) { + case themeLight, themeDark, themeSystem: + return themeMode(s) + } + return themeSystem +} + +// activePalette holds the palette of the currently-rendered theme. Set +// once per frame at the top of drawFrame so shared helpers (toast, +// dividers, dialog backdrops) can colour themselves without dragging a +// palette argument through every signature. Gio invokes drawFrame on +// the single UI goroutine so plain assignment is safe. +var activePalette = lightPalette() diff --git a/internal/ui/theme_other.go b/internal/ui/theme_other.go new file mode 100644 index 0000000..4bb42d7 --- /dev/null +++ b/internal/ui/theme_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package ui + +// systemPrefersDark is the non-Windows stub; we cannot reliably read +// the OS theme preference, so default to light. +func systemPrefersDark() bool { return false } diff --git a/internal/ui/theme_windows.go b/internal/ui/theme_windows.go new file mode 100644 index 0000000..27754ae --- /dev/null +++ b/internal/ui/theme_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package ui + +import "golang.org/x/sys/windows/registry" + +// systemPrefersDark queries the Windows personalization registry key +// to find out whether the user picked dark mode for apps. Returns +// false on any read error so the default stays "light". +func systemPrefersDark() bool { + k, err := registry.OpenKey( + registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, + registry.QUERY_VALUE, + ) + if err != nil { + return false + } + defer k.Close() + v, _, err := k.GetIntegerValue("AppsUseLightTheme") + if err != nil { + return false + } + return v == 0 +} diff --git a/internal/ui/toast.go b/internal/ui/toast.go index f700f9d..6048522 100644 --- a/internal/ui/toast.go +++ b/internal/ui/toast.go @@ -2,7 +2,6 @@ package ui import ( "image" - "image/color" "time" "gioui.org/app" @@ -48,8 +47,8 @@ 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} + bg := activePalette.ToastBg + fg := activePalette.ToastFg macro := op.Record(gtx.Ops) pad := layout.UniformInset(unit.Dp(10)) diff --git a/internal/ui/vendor_menu.go b/internal/ui/vendor_menu.go index 5415be1..e1fc3db 100644 --- a/internal/ui/vendor_menu.go +++ b/internal/ui/vendor_menu.go @@ -1,8 +1,6 @@ package ui import ( - "image/color" - "gioui.org/layout" "gioui.org/unit" "gioui.org/widget" @@ -71,7 +69,7 @@ func (m *vendorMenu) Pick(gtx layout.Context) (vendor, bool) { } func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions { - fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60}) + fillBackground(gtx, activePalette.ScrimBg) row := func(btn *widget.Clickable, label string) layout.FlexChild { return layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -86,11 +84,11 @@ func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimen 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}, + Color: activePalette.DialogBorder, 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}) + fillBackground(gtx, activePalette.DialogBg) 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), diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..5587ca5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,28 @@ +// Package version exposes build metadata. Defaults are placeholders for +// dev builds; release builds inject real values via -ldflags: +// +// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.Version=1.2.3 +// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.Commit=abc1234 +// -X git.wxccs.org/iceking2nd/winauth-go/internal/version.BuildDate=2026-06-12 +package version + +// Version is the human-readable release version ("1.2.3" or "dev"). +var Version = "dev" + +// Commit is the short git SHA the binary was built from. Empty in dev. +var Commit = "" + +// BuildDate is the ISO date when the binary was built. Empty in dev. +var BuildDate = "" + +// String returns a one-line summary suitable for an About dialog. +func String() string { + s := Version + if Commit != "" { + s += " (" + Commit + ")" + } + if BuildDate != "" { + s += " — " + BuildDate + } + return s +}