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
+357
View File
@@ -0,0 +1,357 @@
//go:build windows
package win32
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// TrayItem describes a single popup-menu entry. ID is what comes back on
// Events() when the user clicks it; Label is the visible text.
type TrayItem struct {
ID uint32
Label string
}
// TrayEvent fires when the user activates a menu item or left-clicks the
// icon. ItemID == 0 means a bare icon click (caller convention: treat as
// "show main window").
type TrayEvent struct {
ItemID uint32
}
// TrayManager owns a dedicated OS thread that runs a message-only window
// to host Shell_NotifyIcon and dispatch popup-menu commands. All public
// methods are safe to call from any goroutine; they post work onto the
// tray thread via PostMessage.
type TrayManager struct {
tooltip string
items []TrayItem
events chan TrayEvent
mu sync.Mutex
hwnd uintptr // message-only window owned by tray thread
menu uintptr // HMENU
stopped atomic.Bool
started chan struct{}
done chan struct{}
}
// NewTrayManager spawns the tray thread, creates the icon, and returns
// once the icon is visible. tooltip is the hover string; items become
// popup-menu entries in order. The reserved ItemID values 0 (icon click)
// and 0xFFFF are off-limits to avoid colliding with our internal codes.
func NewTrayManager(tooltip string, items []TrayItem) (*TrayManager, error) {
for _, it := range items {
if it.ID == 0 || it.ID == cmdQuitSentinel {
return nil, fmt.Errorf("win32: tray item ID %d is reserved", it.ID)
}
}
m := &TrayManager{
tooltip: tooltip,
items: items,
events: make(chan TrayEvent, 16),
started: make(chan struct{}),
done: make(chan struct{}),
}
initErr := make(chan error, 1)
go m.run(initErr)
if err := <-initErr; err != nil {
return nil, err
}
return m, nil
}
// Events yields a stream of clicks until Stop closes the channel.
func (m *TrayManager) Events() <-chan TrayEvent { return m.events }
// Stop tears the icon down and exits the tray thread. Safe to call more
// than once.
func (m *TrayManager) Stop() {
if !m.stopped.CompareAndSwap(false, true) {
return
}
m.mu.Lock()
hwnd := m.hwnd
m.mu.Unlock()
if hwnd != 0 {
procPostMessageW.Call(hwnd, uintptr(wmTrayQuit), 0, 0)
}
<-m.done
}
const (
wmUser uint32 = 0x0400
wmTrayCallback = wmUser + 1
wmTrayQuit = wmUser + 2
cmdQuitSentinel uint32 = 0xFFFF
nimAdd uint32 = 0x00000000
nimDelete uint32 = 0x00000002
nifMessage uint32 = 0x00000001
nifIcon uint32 = 0x00000002
nifTip uint32 = 0x00000004
wmCommand uint32 = 0x0111
wmRButtonUp uint32 = 0x0205
wmLButtonUp uint32 = 0x0202
mfString uint32 = 0x00000000
tpmLeftAlign uint32 = 0x0000
tpmRightButton uint32 = 0x0002
idiApplication uintptr = 32512
)
// trayClassName is unique enough to avoid clashing if another component
// in the same process happens to register classes.
var trayClassName, _ = syscall.UTF16PtrFromString("WinAuthGoTrayHost")
func (m *TrayManager) run(initErr chan<- error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer close(m.done)
defer close(m.events)
hinst, _, _ := procGetModuleHandleW.Call(0)
if hinst == 0 {
initErr <- fmt.Errorf("win32: GetModuleHandle failed")
return
}
wndProc := syscall.NewCallback(m.wndProc)
wc := wndClassExW{
cbSize: uint32(unsafe.Sizeof(wndClassExW{})),
lpfnWndProc: wndProc,
hInstance: hinst,
hbrBackground: 0,
lpszClassName: trayClassName,
}
wc.cbSize = uint32(unsafe.Sizeof(wc))
atom, _, e := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc)))
if atom == 0 {
// ERROR_CLASS_ALREADY_EXISTS is OK — a previous tray manager left
// the class behind. Other errors are fatal.
if errno, ok := e.(syscall.Errno); ok && errno != 1410 /*ERROR_CLASS_ALREADY_EXISTS*/ {
initErr <- fmt.Errorf("win32: RegisterClassExW: %w", errno)
return
}
}
// HWND_MESSAGE = -3. A message-only window never paints but still
// receives messages, which is exactly what Shell_NotifyIcon needs.
const hwndMessage = ^uintptr(2) // ((HWND)-3)
hwnd, _, e := procCreateWindowExW.Call(
0,
uintptr(unsafe.Pointer(trayClassName)),
0,
0,
0, 0, 0, 0,
hwndMessage,
0,
hinst,
0,
)
if hwnd == 0 {
initErr <- fmt.Errorf("win32: CreateWindowExW (tray host): %w", e)
return
}
m.mu.Lock()
m.hwnd = hwnd
m.mu.Unlock()
// Build the popup menu once. Item IDs are passed straight to
// TrackPopupMenu and come back via WM_COMMAND's wParam low word.
menu, _, _ := procCreatePopupMenu.Call()
if menu == 0 {
procDestroyWindow.Call(hwnd)
initErr <- fmt.Errorf("win32: CreatePopupMenu failed")
return
}
for _, it := range m.items {
label, err := syscall.UTF16PtrFromString(it.Label)
if err != nil {
continue
}
procAppendMenuW.Call(menu, uintptr(mfString), uintptr(it.ID), uintptr(unsafe.Pointer(label)))
}
m.mu.Lock()
m.menu = menu
m.mu.Unlock()
// Load a stock icon as the placeholder. Real apps ship an .ico
// resource and load it via LoadImageW; doing so requires a build-time
// .syso embed which is documented in internal/ui/assets/README.md.
hIcon, _, _ := procLoadIconW.Call(0, idiApplication)
nid := notifyIconData{
cbSize: uint32(unsafe.Sizeof(notifyIconData{})),
hWnd: hwnd,
uID: 1,
uFlags: nifMessage | nifIcon | nifTip,
uCallbackMessage: wmTrayCallback,
hIcon: hIcon,
}
copyTooltip(&nid.szTip, m.tooltip)
r, _, _ := procShellNotifyIconW.Call(uintptr(nimAdd), uintptr(unsafe.Pointer(&nid)))
if r == 0 {
procDestroyMenu.Call(menu)
procDestroyWindow.Call(hwnd)
initErr <- fmt.Errorf("win32: Shell_NotifyIcon NIM_ADD failed")
return
}
initErr <- nil
// Standard GetMessage pump. Exits when our WM_TRAYQUIT handler calls
// DestroyWindow which posts WM_QUIT via PostQuitMessage.
var msg msgStruct
for {
r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
if r == 0 || int32(r) == -1 {
break
}
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg)))
}
// Cleanup: remove icon, destroy menu, the window already went down
// during WM_TRAYQUIT.
procShellNotifyIconW.Call(uintptr(nimDelete), uintptr(unsafe.Pointer(&nid)))
procDestroyMenu.Call(menu)
}
// wndProc handles the small set of messages we care about. Anything else
// falls through to DefWindowProcW.
func (m *TrayManager) wndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
switch msg {
case wmTrayQuit:
procDestroyWindow.Call(hwnd)
procPostQuitMessage.Call(0)
return 0
case wmTrayCallback:
// lParam low word holds the mouse message; wParam is the icon ID
// (we only have one icon so we ignore it).
mouse := uint32(lParam) & 0xFFFF
switch mouse {
case wmLButtonUp:
select {
case m.events <- TrayEvent{ItemID: 0}:
default:
}
case wmRButtonUp:
m.showPopupMenu(hwnd)
}
return 0
case wmCommand:
id := uint32(wParam) & 0xFFFF
if id != 0 {
select {
case m.events <- TrayEvent{ItemID: id}:
default:
}
}
return 0
}
r, _, _ := procDefWindowProcW.Call(hwnd, uintptr(msg), wParam, lParam)
return r
}
// showPopupMenu positions the menu at the cursor and posts the selection
// back to us as a WM_COMMAND. SetForegroundWindow before TrackPopupMenu
// is the documented dance to avoid the menu lingering when the user
// clicks elsewhere.
func (m *TrayManager) showPopupMenu(hwnd uintptr) {
var pt struct{ X, Y int32 }
procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
procSetForegroundWindowProc.Call(hwnd)
procTrackPopupMenu.Call(
m.menu,
uintptr(tpmLeftAlign|tpmRightButton),
uintptr(pt.X), uintptr(pt.Y),
0, hwnd, 0,
)
// MSDN: post a benign null message so the menu's modal loop exits
// cleanly when the user clicks outside it.
procPostMessageW.Call(hwnd, 0, 0, 0)
}
// copyTooltip writes s into the fixed-size buffer used by NOTIFYICONDATAW,
// truncating to 127 chars + NUL.
func copyTooltip(dst *[128]uint16, s string) {
utf := utf16FromString(s)
if len(utf) >= len(dst) {
utf = utf[:len(dst)-1]
}
copy(dst[:], utf)
dst[len(utf)] = 0
}
func utf16FromString(s string) []uint16 {
r, _ := syscall.UTF16FromString(s)
if n := len(r); n > 0 && r[n-1] == 0 {
return r[:n-1]
}
return r
}
// -----------------------------------------------------------------------------
// raw syscalls + structs
var (
procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
procRegisterClassExW = user32.NewProc("RegisterClassExW")
procCreateWindowExW = user32.NewProc("CreateWindowExW")
procDestroyWindow = user32.NewProc("DestroyWindow")
procDefWindowProcW = user32.NewProc("DefWindowProcW")
procGetMessageW = user32.NewProc("GetMessageW")
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessageW = user32.NewProc("DispatchMessageW")
procPostMessageW = user32.NewProc("PostMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procLoadIconW = user32.NewProc("LoadIconW")
procCreatePopupMenu = user32.NewProc("CreatePopupMenu")
procAppendMenuW = user32.NewProc("AppendMenuW")
procDestroyMenu = user32.NewProc("DestroyMenu")
procTrackPopupMenu = user32.NewProc("TrackPopupMenu")
procGetCursorPos = user32.NewProc("GetCursorPos")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellNotifyIconW = shell32.NewProc("Shell_NotifyIconW")
)
type wndClassExW struct {
cbSize uint32
style uint32
lpfnWndProc uintptr
cbClsExtra int32
cbWndExtra int32
hInstance uintptr
hIcon uintptr
hCursor uintptr
hbrBackground uintptr
lpszMenuName *uint16
lpszClassName *uint16
hIconSm uintptr
}
type notifyIconData struct {
cbSize uint32
hWnd uintptr
uID uint32
uFlags uint32
uCallbackMessage uint32
hIcon uintptr
szTip [128]uint16
dwState uint32
dwStateMask uint32
szInfo [256]uint16
uVersion uint32
szInfoTitle [64]uint16
dwInfoFlags uint32
guidItem [16]byte
hBalloonIcon uintptr
}