feat(ui): 系统托盘 + 关闭最小化到托盘

- 原生 win32 实现的 TrayManager:Shell_NotifyIcon + 私有 message-only
  窗口承载 popup 菜单,专用 OS 线程跑 GetMessage 泵,避免引入 cgo 依赖。
- WindowSubclass 通过 SetWindowLongPtrW 子类化 Gio 主窗口,拦截
  WM_CLOSE:minimize_to_tray=true 时吞掉消息只隐藏窗口;exit 路径下
  放行让 Gio 正常关闭事件循环。
- Preferences 新增"最小化到托盘"复选框,切换后立即重装/卸载钩子,
  不需要重启程序。
- 托盘菜单提供 Show / Hide / Exit;左键单击图标等同 Show;右键弹菜单。
- 非 Windows 平台所有相关 API 退化为 ErrUnsupported / no-op stub。
This commit is contained in:
2026-06-12 04:13:35 +08:00
parent 0918fd446a
commit c0dd3b9028
11 changed files with 808 additions and 8 deletions
+27 -4
View File
@@ -119,6 +119,11 @@ type appState struct {
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.
@@ -174,6 +179,17 @@ func loop(w *app.Window, configPath string) error {
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)
@@ -188,6 +204,7 @@ func loop(w *app.Window, configPath string) error {
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)
@@ -436,8 +453,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog()
case settingsActionPreferences:
lang, theme, autoLock, _ := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock)
lang, theme, autoLock, minToTray := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray)
case settingsActionAbout:
st.aboutDialog = newAboutDialog()
}
@@ -450,11 +467,17 @@ 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 {
_, _, _, minToTray := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, minToTray)
_, _, _, 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()
+18 -4
View File
@@ -13,8 +13,7 @@ import (
)
// preferencesDialog edits the persistent UI prefs: UI language, theme,
// and auto-lock timeout. Minimize-to-tray lives in its own bucket and
// will appear here later.
// auto-lock timeout, and minimize-to-tray behaviour.
type preferencesDialog struct {
lang string
theme themeMode
@@ -35,6 +34,10 @@ type preferencesDialog struct {
// input on that, the store does.
autoLockEd widget.Editor
// Minimize-to-tray: when on, the X button hides the window to the
// system tray instead of exiting. Tray icon stays visible either way.
minToTray widget.Bool
okBt widget.Clickable
cancelBt widget.Clickable
}
@@ -46,6 +49,7 @@ type preferencesResult struct {
language string
theme themeMode
autoLockMinutes int
minimizeToTray bool
}
// supportedLanguages is the closed set the prefs dialog exposes. Keep
@@ -59,7 +63,7 @@ var supportedLanguages = []struct {
{"de", "Deutsch"},
}
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int) *preferencesDialog {
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray bool) *preferencesDialog {
if currentLang == "" {
currentLang = "en"
}
@@ -69,6 +73,7 @@ func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAut
d := &preferencesDialog{lang: currentLang, theme: currentTheme}
d.autoLockEd.SingleLine = true
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
d.minToTray.Value = currentMinToTray
return d
}
@@ -88,7 +93,12 @@ func (d *preferencesDialog) Layout(
if err != nil || mins < 0 {
mins = 0
}
onDone(preferencesResult{language: d.lang, theme: d.theme, autoLockMinutes: mins})
onDone(preferencesResult{
language: d.lang,
theme: d.theme,
autoLockMinutes: mins,
minimizeToTray: d.minToTray.Value,
})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
@@ -150,6 +160,10 @@ func (d *preferencesDialog) Layout(
layout.Rigid(labeledEditor(th, i18n.T("label_auto_lock_minutes"), &d.autoLockEd, "")),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_auto_lock_disabled")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.CheckBox(th, &d.minToTray, i18n.T("label_minimize_to_tray")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_minimize_to_tray")).Layout),
)
}
+155
View File
@@ -0,0 +1,155 @@
package ui
import (
"sync/atomic"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// Tray menu IDs. Reserved range: 1..255 for system items, 256+ for
// per-entry actions (none yet). 0 is the icon-click sentinel and MUST
// NOT be used here.
const (
trayCmdShow uint32 = 1
trayCmdHide uint32 = 2
trayCmdExit uint32 = 3
)
// trayRuntime owns the tray icon and the window subclass that intercepts
// WM_CLOSE. Both are best-effort: failure to install either is logged and
// the program continues without tray support.
type trayRuntime struct {
mgr *win32.TrayManager
subclass *win32.WindowSubclass
hwnd uintptr
// hidden tracks the most recently *requested* visibility. The
// Show/Hide menu labels are static today; flipping them would require
// rebuilding the popup which is more work than it's worth.
hidden atomic.Bool
// exiting flips when the user picks the tray "Exit" entry or when the
// process is shutting down. While set, the close hook stops swallowing
// WM_CLOSE so the window can actually go down.
exiting atomic.Bool
}
// installTrayRuntime resolves the Gio window's HWND by title (the only
// public hook available in v0.7) then sets up the icon + close hook.
// minimizeToTray controls the WM_CLOSE behaviour: when false we install
// no hook and let the window close normally; the tray icon is still
// useful for "Show/Hide" so we install it either way. onShow is invoked
// after the window is restored so the caller can Invalidate.
func installTrayRuntime(title string, minimizeToTray bool, onShow func()) *trayRuntime {
const fn = "internal.ui.installTrayRuntime"
// Gio creates the OS window from a goroutine inside app.Main(); on
// the very first frame the HWND may not be discoverable yet. Poll
// briefly so installation is reliable without making the UI wait.
var hwnd uintptr
for i := 0; i < 20; i++ {
hwnd = win32.FindWindowByTitle(title)
if hwnd != 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
if hwnd == 0 {
global.Log.WithField("func", fn).Warn("could not find window HWND; tray disabled")
return nil
}
tr := &trayRuntime{hwnd: hwnd}
items := []win32.TrayItem{
{ID: trayCmdShow, Label: i18n.T("tray_show")},
{ID: trayCmdHide, Label: i18n.T("tray_hide")},
{ID: trayCmdExit, Label: i18n.T("tray_exit")},
}
mgr, err := win32.NewTrayManager(i18n.T("app_title"), items)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("tray icon install failed")
return nil
}
tr.mgr = mgr
if minimizeToTray {
tr.installCloseHook()
}
go func() {
for ev := range mgr.Events() {
switch ev.ItemID {
case 0, trayCmdShow:
win32.ShowWindow(hwnd, win32.SW_RESTORE)
_ = win32.FocusWindow(hwnd)
tr.hidden.Store(false)
if onShow != nil {
onShow()
}
case trayCmdHide:
win32.ShowWindow(hwnd, win32.SW_HIDE)
tr.hidden.Store(true)
case trayCmdExit:
tr.exiting.Store(true)
// Route through WM_CLOSE so Gio sees a normal close and
// shuts the event loop down — the hook (if installed)
// reads tr.exiting and lets the message through.
win32.PostCloseMessage(hwnd)
}
}
}()
return tr
}
func (tr *trayRuntime) installCloseHook() {
const fn = "internal.ui.trayRuntime.installCloseHook"
hwnd := tr.hwnd
sc, err := win32.InstallCloseHook(hwnd, func() bool {
if tr.exiting.Load() {
return true
}
win32.ShowWindow(hwnd, win32.SW_HIDE)
tr.hidden.Store(true)
return false
})
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("close hook install failed")
return
}
tr.subclass = sc
}
// Stop tears down the tray + subclass. Idempotent.
func (tr *trayRuntime) Stop() {
if tr == nil {
return
}
if tr.subclass != nil {
tr.subclass.Remove()
}
if tr.mgr != nil {
tr.mgr.Stop()
}
}
// setMinimizeToTray hot-reloads the WM_CLOSE behaviour after the user
// toggles the preference: install the hook if it wasn't there, or
// remove it so the X button reverts to "actually quit." Safe to call
// repeatedly; no-op when the requested state already matches.
func (tr *trayRuntime) setMinimizeToTray(enable bool) {
if tr == nil {
return
}
if enable && tr.subclass == nil {
tr.installCloseHook()
return
}
if !enable && tr.subclass != nil {
tr.subclass.Remove()
tr.subclass = nil
}
}