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()