From c0dd3b90285993e5ff75934ff353a3609811d9c6 Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 04:13:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E7=B3=BB=E7=BB=9F=E6=89=98?= =?UTF-8?q?=E7=9B=98=20+=20=E5=85=B3=E9=97=AD=E6=9C=80=E5=B0=8F=E5=8C=96?= =?UTF-8?q?=E5=88=B0=E6=89=98=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原生 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。 --- internal/i18n/locales/de.toml | 17 ++ internal/i18n/locales/en.toml | 17 ++ internal/i18n/locales/zh-CN.toml | 17 ++ internal/ui/app.go | 31 +- internal/ui/dialog_preferences.go | 22 +- internal/ui/tray_runtime.go | 155 ++++++++++ internal/win32/tray_other.go | 31 ++ internal/win32/tray_windows.go | 357 ++++++++++++++++++++++ internal/win32/window_subclass_amd64.go | 7 + internal/win32/window_subclass_other.go | 23 ++ internal/win32/window_subclass_windows.go | 139 +++++++++ 11 files changed, 808 insertions(+), 8 deletions(-) create mode 100644 internal/ui/tray_runtime.go create mode 100644 internal/win32/tray_other.go create mode 100644 internal/win32/tray_windows.go create mode 100644 internal/win32/window_subclass_amd64.go create mode 100644 internal/win32/window_subclass_other.go create mode 100644 internal/win32/window_subclass_windows.go diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml index 5589d36..a2c9123 100644 --- a/internal/i18n/locales/de.toml +++ b/internal/i18n/locales/de.toml @@ -448,3 +448,20 @@ other = "Automatisch sperren nach (Minuten)" [hint_auto_lock_disabled] other = "0 deaktiviert. Nur wirksam, wenn die Konfiguration verschlüsselt ist." + +# --- System-Tray --- + +[tray_show] +other = "WinAuth anzeigen" + +[tray_hide] +other = "WinAuth ausblenden" + +[tray_exit] +other = "Beenden" + +[label_minimize_to_tray] +other = "Beim Schließen in den Tray minimieren" + +[hint_minimize_to_tray] +other = "Schließen des Fensters versteckt es im System-Tray statt das Programm zu beenden." diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml index e7190d4..2a3e450 100644 --- a/internal/i18n/locales/en.toml +++ b/internal/i18n/locales/en.toml @@ -450,3 +450,20 @@ other = "Auto-lock after (minutes)" [hint_auto_lock_disabled] other = "Set to 0 to disable. Only takes effect when the configuration is encrypted." + +# --- System tray --- + +[tray_show] +other = "Show WinAuth" + +[tray_hide] +other = "Hide WinAuth" + +[tray_exit] +other = "Exit" + +[label_minimize_to_tray] +other = "Minimize to tray when closing the window" + +[hint_minimize_to_tray] +other = "Closing the window hides it to the system tray instead of exiting." diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml index 195e76d..0e18dae 100644 --- a/internal/i18n/locales/zh-CN.toml +++ b/internal/i18n/locales/zh-CN.toml @@ -448,3 +448,20 @@ other = "自动锁定(分钟)" [hint_auto_lock_disabled] other = "填 0 关闭。仅当配置已加密时生效。" + +# --- 系统托盘 --- + +[tray_show] +other = "显示 WinAuth" + +[tray_hide] +other = "隐藏 WinAuth" + +[tray_exit] +other = "退出" + +[label_minimize_to_tray] +other = "关闭窗口时最小化到托盘" + +[hint_minimize_to_tray] +other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。" diff --git a/internal/ui/app.go b/internal/ui/app.go index 8c1e97a..e5c384e 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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() diff --git a/internal/ui/dialog_preferences.go b/internal/ui/dialog_preferences.go index ca2ab71..f5941ca 100644 --- a/internal/ui/dialog_preferences.go +++ b/internal/ui/dialog_preferences.go @@ -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), ) } diff --git a/internal/ui/tray_runtime.go b/internal/ui/tray_runtime.go new file mode 100644 index 0000000..2e5ec5e --- /dev/null +++ b/internal/ui/tray_runtime.go @@ -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 + } +} diff --git a/internal/win32/tray_other.go b/internal/win32/tray_other.go new file mode 100644 index 0000000..ac7f3b2 --- /dev/null +++ b/internal/win32/tray_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +package win32 + +// TrayItem / TrayEvent / TrayManager are no-op stubs on non-Windows. + +type TrayItem struct { + ID uint32 + Label string +} + +type TrayEvent struct { + ItemID uint32 +} + +type TrayManager struct { + events chan TrayEvent +} + +func NewTrayManager(tooltip string, items []TrayItem) (*TrayManager, error) { + return nil, ErrUnsupported +} + +func (m *TrayManager) Events() <-chan TrayEvent { + if m == nil { + return nil + } + return m.events +} + +func (m *TrayManager) Stop() {} diff --git a/internal/win32/tray_windows.go b/internal/win32/tray_windows.go new file mode 100644 index 0000000..c86592a --- /dev/null +++ b/internal/win32/tray_windows.go @@ -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 +} diff --git a/internal/win32/window_subclass_amd64.go b/internal/win32/window_subclass_amd64.go new file mode 100644 index 0000000..42c99a1 --- /dev/null +++ b/internal/win32/window_subclass_amd64.go @@ -0,0 +1,7 @@ +//go:build windows && amd64 + +package win32 + +// 64-bit Windows uses SetWindowLongPtrW. 32-bit builds map to +// SetWindowLongW (see _386.go); the LONG_PTR difference is just a name. +const setWindowLongPtrName = "SetWindowLongPtrW" diff --git a/internal/win32/window_subclass_other.go b/internal/win32/window_subclass_other.go new file mode 100644 index 0000000..2c6ffee --- /dev/null +++ b/internal/win32/window_subclass_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package win32 + +type WindowSubclass struct{} + +func InstallCloseHook(hwnd uintptr, onClose func() bool) (*WindowSubclass, error) { + return nil, ErrUnsupported +} + +func (s *WindowSubclass) Remove() {} + +func ShowWindow(hwnd uintptr, cmdShow int32) {} + +const ( + SW_HIDE int32 = 0 + SW_SHOW int32 = 5 + SW_RESTORE int32 = 9 +) + +func FindWindowByTitle(title string) uintptr { return 0 } + +func PostCloseMessage(hwnd uintptr) {} diff --git a/internal/win32/window_subclass_windows.go b/internal/win32/window_subclass_windows.go new file mode 100644 index 0000000..53d01cc --- /dev/null +++ b/internal/win32/window_subclass_windows.go @@ -0,0 +1,139 @@ +//go:build windows + +package win32 + +import ( + "fmt" + "sync" + "syscall" + "unsafe" +) + +// WindowSubclass intercepts the WM_CLOSE message on an external window +// (in our case Gio's app window) and redirects it to a Go callback. The +// caller decides whether to actually close, hide, or ignore. +// +// Implementation notes +// +// - We swap the window procedure via SetWindowLongPtrW(GWLP_WNDPROC), +// keeping a reference to the original so non-WM_CLOSE messages pass +// through unchanged. +// - The Go callback runs on the UI thread of the subclassed window +// (because that's who DispatchMessage'd into our hook). Returning +// false from the callback drops the message; true forwards it on. +// - Only one subclass per HWND is supported. Calling Install a second +// time on the same HWND returns an error. +type WindowSubclass struct { + hwnd uintptr + origProc uintptr + cb func() bool + + once sync.Once + gone bool +} + +var ( + subclassMu sync.Mutex + subclasses = map[uintptr]*WindowSubclass{} +) + +// InstallCloseHook subclasses hwnd and routes WM_CLOSE through onClose. +// If onClose returns false the close is swallowed; if true the original +// window procedure handles it (the default behaviour, which destroys +// the window). +func InstallCloseHook(hwnd uintptr, onClose func() bool) (*WindowSubclass, error) { + if hwnd == 0 { + return nil, fmt.Errorf("win32: InstallCloseHook: nil hwnd") + } + subclassMu.Lock() + defer subclassMu.Unlock() + if _, exists := subclasses[hwnd]; exists { + return nil, fmt.Errorf("win32: hwnd %#x already subclassed", hwnd) + } + + s := &WindowSubclass{hwnd: hwnd, cb: onClose} + proc := syscall.NewCallback(s.wndProc) + orig, _, e := procSetWindowLongPtrW.Call(hwnd, gwlpWndProc, proc) + if orig == 0 { + if errno, ok := e.(syscall.Errno); ok && errno != 0 { + return nil, fmt.Errorf("win32: SetWindowLongPtrW: %w", errno) + } + return nil, fmt.Errorf("win32: SetWindowLongPtrW returned 0") + } + s.origProc = orig + subclasses[hwnd] = s + return s, nil +} + +// Remove restores the original window procedure. Safe to call more than +// once; subsequent calls are no-ops. +func (s *WindowSubclass) Remove() { + s.once.Do(func() { + s.gone = true + subclassMu.Lock() + delete(subclasses, s.hwnd) + subclassMu.Unlock() + procSetWindowLongPtrW.Call(s.hwnd, gwlpWndProc, s.origProc) + }) +} + +func (s *WindowSubclass) wndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr { + if msg == wmClose && s.cb != nil && !s.gone { + if !s.cb() { + return 0 + } + } + r, _, _ := procCallWindowProcW.Call(s.origProc, hwnd, uintptr(msg), wParam, lParam) + return r +} + +// ShowWindow toggles a window's visibility. cmdShow uses the standard +// SW_* constants (SW_HIDE = 0, SW_SHOW = 5, SW_RESTORE = 9). +func ShowWindow(hwnd uintptr, cmdShow int32) { + procShowWindowProc.Call(hwnd, uintptr(cmdShow)) +} + +// SW_* constants exposed so callers do not need to redefine them. +const ( + SW_HIDE int32 = 0 + SW_SHOW int32 = 5 + SW_RESTORE int32 = 9 +) + +// FindWindowByTitle searches top-level windows for one whose title +// exactly matches. Returns 0 when nothing matches. +func FindWindowByTitle(title string) uintptr { + wtitle, err := syscall.UTF16PtrFromString(title) + if err != nil { + return 0 + } + r, _, _ := procFindWindowW.Call(0, uintptr(unsafe.Pointer(wtitle))) + return r +} + +// PostCloseMessage asks the window to close the same way the user +// clicking the X would. Combined with InstallCloseHook this lets the +// tray "Exit" item route through the same path as a real close, so the +// hook can let it through after setting an "exiting" flag. +func PostCloseMessage(hwnd uintptr) { + procPostMessageW.Call(hwnd, uintptr(wmClose), 0, 0) +} + +// ----------------------------------------------------------------------------- +// raw syscalls + +var ( + procSetWindowLongPtrW = user32.NewProc(setWindowLongPtrName) + procCallWindowProcW = user32.NewProc("CallWindowProcW") +) + +const ( + wmClose uint32 = 0x0010 +) + +// gwlpWndProc is the GWLP_WNDPROC index. Declared as a var (not a const) +// because Go forbids constant negative-to-uintptr conversion. +var gwlpWndProc = func() uintptr { + x := int32(-4) + return uintptr(x) +}()