c0dd3b9028
- 原生 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。
156 lines
4.3 KiB
Go
156 lines
4.3 KiB
Go
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
|
|
}
|
|
}
|