feat(ui): 主题切换 + 语言持久化 + About 对话框

- 新增 light/dark 主题调色板与 Windows 注册表系统主题探测
- 偏好设置对话框:主题三选一 + 语言下拉(en/zh-CN/de),即时生效
- 持久化 Language/Theme/AutoLockMinutes/MinimizeToTray 至 YAML 顶层
- 启动时按 config 中保存的语言初始化 i18n
- About 对话框展示 version/runtime/项目地址/许可证
- 全部硬编码颜色迁移到 themePalette,为深色模式做准备
This commit is contained in:
2026-06-12 03:38:13 +08:00
parent c671f2115e
commit b483360778
19 changed files with 687 additions and 48 deletions
+26 -2
View File
@@ -6,10 +6,12 @@ import (
"github.com/spf13/cobra" "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/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n" "git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/logging" "git.wxccs.org/iceking2nd/winauth-go/internal/logging"
"git.wxccs.org/iceking2nd/winauth-go/internal/ui" "git.wxccs.org/iceking2nd/winauth-go/internal/ui"
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32" "git.wxccs.org/iceking2nd/winauth-go/internal/win32"
) )
@@ -53,7 +55,8 @@ func main() {
} }
const fn = "cmd.winauth.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) release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
if err != nil { if err != nil {
@@ -70,7 +73,12 @@ func main() {
defer release() 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") global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys")
} }
@@ -91,3 +99,19 @@ func main() {
os.Exit(1) 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
}
+13 -5
View File
@@ -21,9 +21,17 @@ type Entry struct {
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext // default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
// produced by internal/crypto.EncryptModern and Entries is empty on disk. // produced by internal/crypto.EncryptModern and Entries is empty on disk.
type Config struct { type Config struct {
Version int `yaml:"version" json:"version"` Version int `yaml:"version" json:"version"`
Language string `yaml:"language,omitempty" json:"language,omitempty"` Language string `yaml:"language,omitempty" json:"language,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"` // Theme is one of "", "light", "dark", "system" (empty = system).
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"` Theme string `yaml:"theme,omitempty" json:"theme,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,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"`
} }
+52
View File
@@ -367,3 +367,55 @@ other = "QR-Scan fehlgeschlagen: %s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "Zwischenablage enthält kein Bild" 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."
+52
View File
@@ -369,3 +369,55 @@ other = "QR scan failed: %s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "Clipboard does not contain an 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."
+52
View File
@@ -367,3 +367,55 @@ other = "二维码扫描失败:%s"
[msg_clipboard_no_image] [msg_clipboard_no_image]
other = "剪贴板中没有图片" 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 移植由本项目作者维护。"
+51 -9
View File
@@ -10,12 +10,10 @@ import (
"time" "time"
"gioui.org/app" "gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
@@ -95,6 +93,8 @@ type appState struct {
hotkeyDialog *hotkeyDialog hotkeyDialog *hotkeyDialog
hotkeyTarget *entry hotkeyTarget *entry
tradesDialog *steamTradesDialog tradesDialog *steamTradesDialog
prefsDialog *preferencesDialog
aboutDialog *aboutDialog
store *store store *store
saveErr string // surfaced in the top bar saveErr string // surfaced in the top bar
@@ -102,6 +102,13 @@ type appState struct {
hkMgr *win32.HotkeyManager hkMgr *win32.HotkeyManager
toast toast 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. // 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 { func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop" const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{} state := &appState{}
state.list.Axis = layout.Vertical state.list.Axis = layout.Vertical
@@ -146,6 +150,12 @@ func loop(w *app.Window, configPath string) error {
state.absorbConfig(cfg) 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 // Spin up the global hotkey manager and register whatever the user
// already had configured. Failures are non-fatal (logged + the row // already had configured. Failures are non-fatal (logged + the row
// just won't fire). // just won't fire).
@@ -170,7 +180,15 @@ func loop(w *app.Window, configPath string) error {
return e.Err return e.Err
case app.FrameEvent: case app.FrameEvent:
gtx := app.NewContext(&ops, e) 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) e.Frame(gtx.Ops)
} }
} }
@@ -320,8 +338,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
st.setPwDialog = newSetPasswordDialog() st.setPwDialog = newSetPasswordDialog()
case settingsActionImportLegacy: case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog() st.importDialog = newImportLegacyDialog()
case settingsActionPreferences:
lang, theme, _, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme))
case settingsActionAbout: case settingsActionAbout:
// TODO: about dialog (next phase). st.aboutDialog = newAboutDialog()
} }
w.Invalidate() w.Invalidate()
} else { } 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 { if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) { return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil { 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{} return layout.Dimensions{}
} }
lbl := material.Body2(th, msg) 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) return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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 { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code) 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) return lbl.Layout(gtx)
}), }),
) )
+7 -9
View File
@@ -1,8 +1,6 @@
package ui package ui
import ( import (
"image/color"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
@@ -36,16 +34,16 @@ func modalCard(
cancelBtn *widget.Clickable, cancelBtn *widget.Clickable,
body layout.Widget, body layout.Widget,
) layout.Dimensions { ) 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 { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420) gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{ return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4), CornerRadius: unit.Dp(4),
Width: unit.Dp(1), Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { }.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout), layout.Rigid(material.H6(th, title).Layout),
@@ -79,16 +77,16 @@ func modalCardCancel(
cancelBtn *widget.Clickable, cancelBtn *widget.Clickable,
body layout.Widget, body layout.Widget,
) layout.Dimensions { ) 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 { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420) gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{ return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4), CornerRadius: unit.Dp(4),
Width: unit.Dp(1), Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { }.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout), layout.Rigid(material.H6(th, title).Layout),
@@ -114,7 +112,7 @@ func errorLabel(th *material.Theme, msg string) layout.Widget {
return layout.Dimensions{} return layout.Dimensions{}
} }
lbl := material.Body2(th, msg) 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) return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout)
} }
} }
+55
View File
@@ -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)
}
+136
View File
@@ -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)
}
+2 -3
View File
@@ -2,7 +2,6 @@ package ui
import ( import (
"image" "image"
"image/color"
"gioui.org/app" "gioui.org/app"
"gioui.org/layout" "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 { func drawEmptyPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(th, i18n.T("msg_empty_list")) 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) 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)) h := gtx.Dp(unit.Dp(1))
size := image.Pt(gtx.Constraints.Max.X, h) size := image.Pt(gtx.Constraints.Max.X, h)
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() 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) paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: size} return layout.Dimensions{Size: size}
} }
+3 -4
View File
@@ -1,7 +1,6 @@
package ui package ui
import ( import (
"image/color"
"time" "time"
"gioui.org/layout" "gioui.org/layout"
@@ -28,11 +27,11 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
remaining := int64(period) - elapsed remaining := int64(period) - elapsed
progress := float32(remaining) / float32(period) progress := float32(remaining) / float32(period)
fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff} fg := activePalette.RingFg
if remaining <= 5 { 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{ return progressRing{
Size: unit.Dp(ringDp), Size: unit.Dp(ringDp),
+8 -5
View File
@@ -1,8 +1,6 @@
package ui package ui
import ( import (
"image/color"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
@@ -18,6 +16,7 @@ const (
settingsActionNone settingsAction = iota settingsActionNone settingsAction = iota
settingsActionSetPassword settingsActionSetPassword
settingsActionImportLegacy settingsActionImportLegacy
settingsActionPreferences
settingsActionAbout settingsActionAbout
) )
@@ -26,6 +25,7 @@ const (
type settingsMenu struct { type settingsMenu struct {
setPwBtn widget.Clickable setPwBtn widget.Clickable
importBtn widget.Clickable importBtn widget.Clickable
prefsBtn widget.Clickable
aboutBtn widget.Clickable aboutBtn widget.Clickable
cancelBtn widget.Clickable cancelBtn widget.Clickable
} }
@@ -39,6 +39,8 @@ func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
return settingsActionSetPassword, true return settingsActionSetPassword, true
case m.importBtn.Clicked(gtx): case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true return settingsActionImportLegacy, true
case m.prefsBtn.Clicked(gtx):
return settingsActionPreferences, true
case m.aboutBtn.Clicked(gtx): case m.aboutBtn.Clicked(gtx):
return settingsActionAbout, true return settingsActionAbout, true
case m.cancelBtn.Clicked(gtx): 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 { 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 { row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(280) gtx.Constraints.Max.X = gtx.Dp(280)
return widget.Border{ return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4), CornerRadius: unit.Dp(4),
Width: unit.Dp(1), Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { }.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout), layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.setPwBtn, i18n.T("menu_set_password")), row(&m.setPwBtn, i18n.T("menu_set_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")), row(&m.importBtn, i18n.T("menu_import_legacy")),
row(&m.prefsBtn, i18n.T("menu_preferences")),
row(&m.aboutBtn, i18n.T("menu_about")), row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+43 -3
View File
@@ -26,6 +26,13 @@ type store struct {
passphrase []byte passphrase []byte
encrypted bool 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 signals a save is pending. The worker reads & resets it.
dirty bool dirty bool
snapshotFn func() []config.Entry snapshotFn func() []config.Entry
@@ -37,6 +44,27 @@ type store struct {
onError func(error) 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. // newStore initializes a store and starts the background save worker.
// snapshotFn is invoked whenever a save runs; it must return a freshly // snapshotFn is invoked whenever a save runs; it must return a freshly
// copied entries slice (the worker holds no UI locks). onError is called // 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.mu.Lock()
s.passphrase = passphrase s.passphrase = passphrase
s.encrypted = cfg.Encrypted s.encrypted = cfg.Encrypted
s.language = cfg.Language
s.theme = cfg.Theme
s.autoLockMinutes = cfg.AutoLockMinutes
s.minimizeToTray = cfg.MinimizeToTray
s.mu.Unlock() s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store") logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil return cfg, nil
@@ -148,13 +180,21 @@ func (s *store) run() {
s.dirty = false s.dirty = false
pw := append([]byte(nil), s.passphrase...) pw := append([]byte(nil), s.passphrase...)
enc := s.encrypted enc := s.encrypted
lang := s.language
theme := s.theme
autoLock := s.autoLockMinutes
minToTray := s.minimizeToTray
s.mu.Unlock() s.mu.Unlock()
entries := s.snapshotFn() entries := s.snapshotFn()
cfg := &config.Config{ cfg := &config.Config{
Version: 1, Version: 1,
Encrypted: enc, Language: lang,
Entries: entries, Theme: theme,
AutoLockMinutes: autoLock,
MinimizeToTray: minToTray,
Encrypted: enc,
Entries: entries,
} }
if err := config.SaveYAML(cfg, s.path, pw); err != nil { if err := config.SaveYAML(cfg, s.path, pw); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("save failed") global.Log.WithField("func", fn).WithError(err).Error("save failed")
+122
View File
@@ -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()
+7
View File
@@ -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 }
+25
View File
@@ -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
}
+2 -3
View File
@@ -2,7 +2,6 @@ package ui
import ( import (
"image" "image"
"image/color"
"time" "time"
"gioui.org/app" "gioui.org/app"
@@ -48,8 +47,8 @@ func (t *toast) draw(gtx layout.Context, th *material.Theme) {
if !t.active() { if !t.active() {
return return
} }
bg := color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0} bg := activePalette.ToastBg
fg := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} fg := activePalette.ToastFg
macro := op.Record(gtx.Ops) macro := op.Record(gtx.Ops)
pad := layout.UniformInset(unit.Dp(10)) pad := layout.UniformInset(unit.Dp(10))
+3 -5
View File
@@ -1,8 +1,6 @@
package ui package ui
import ( import (
"image/color"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "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 { 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 { row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 { return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(320) gtx.Constraints.Max.X = gtx.Dp(320)
return widget.Border{ return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4), CornerRadius: unit.Dp(4),
Width: unit.Dp(1), Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { }.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.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout), layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),
+28
View File
@@ -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
}