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:
@@ -448,3 +448,20 @@ other = "Automatisch sperren nach (Minuten)"
|
|||||||
|
|
||||||
[hint_auto_lock_disabled]
|
[hint_auto_lock_disabled]
|
||||||
other = "0 deaktiviert. Nur wirksam, wenn die Konfiguration verschlüsselt ist."
|
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."
|
||||||
|
|||||||
@@ -450,3 +450,20 @@ other = "Auto-lock after (minutes)"
|
|||||||
|
|
||||||
[hint_auto_lock_disabled]
|
[hint_auto_lock_disabled]
|
||||||
other = "Set to 0 to disable. Only takes effect when the configuration is encrypted."
|
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."
|
||||||
|
|||||||
@@ -448,3 +448,20 @@ other = "自动锁定(分钟)"
|
|||||||
|
|
||||||
[hint_auto_lock_disabled]
|
[hint_auto_lock_disabled]
|
||||||
other = "填 0 关闭。仅当配置已加密时生效。"
|
other = "填 0 关闭。仅当配置已加密时生效。"
|
||||||
|
|
||||||
|
# --- 系统托盘 ---
|
||||||
|
|
||||||
|
[tray_show]
|
||||||
|
other = "显示 WinAuth"
|
||||||
|
|
||||||
|
[tray_hide]
|
||||||
|
other = "隐藏 WinAuth"
|
||||||
|
|
||||||
|
[tray_exit]
|
||||||
|
other = "退出"
|
||||||
|
|
||||||
|
[label_minimize_to_tray]
|
||||||
|
other = "关闭窗口时最小化到托盘"
|
||||||
|
|
||||||
|
[hint_minimize_to_tray]
|
||||||
|
other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。"
|
||||||
|
|||||||
+27
-4
@@ -119,6 +119,11 @@ type appState struct {
|
|||||||
lastActivity time.Time
|
lastActivity time.Time
|
||||||
locked bool
|
locked bool
|
||||||
lockDialog *passwordDialog
|
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.
|
// snapshotEntries returns a freshly serialized slice of config entries.
|
||||||
@@ -174,6 +179,17 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
state.registerAllHotkeys()
|
state.registerAllHotkeys()
|
||||||
go state.runHotkeyLoop(w)
|
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.
|
// Tick once per second to refresh TOTP codes.
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(time.Second)
|
t := time.NewTicker(time.Second)
|
||||||
@@ -188,6 +204,7 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
switch e := w.Event().(type) {
|
switch e := w.Event().(type) {
|
||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
global.Log.WithField("func", fn).Info("window closed")
|
global.Log.WithField("func", fn).Info("window closed")
|
||||||
|
state.tray.Stop()
|
||||||
return e.Err
|
return e.Err
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
gtx := app.NewContext(&ops, e)
|
gtx := app.NewContext(&ops, e)
|
||||||
@@ -436,8 +453,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
case settingsActionImportLegacy:
|
case settingsActionImportLegacy:
|
||||||
st.importDialog = newImportLegacyDialog()
|
st.importDialog = newImportLegacyDialog()
|
||||||
case settingsActionPreferences:
|
case settingsActionPreferences:
|
||||||
lang, theme, autoLock, _ := st.store.Preferences()
|
lang, theme, autoLock, minToTray := st.store.Preferences()
|
||||||
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock)
|
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray)
|
||||||
case settingsActionAbout:
|
case settingsActionAbout:
|
||||||
st.aboutDialog = newAboutDialog()
|
st.aboutDialog = newAboutDialog()
|
||||||
}
|
}
|
||||||
@@ -450,11 +467,17 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
if st.prefsDialog != nil {
|
if st.prefsDialog != nil {
|
||||||
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
|
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
|
||||||
if !r.cancel {
|
if !r.cancel {
|
||||||
_, _, _, minToTray := st.store.Preferences()
|
_, _, _, prevMinToTray := st.store.Preferences()
|
||||||
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, minToTray)
|
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray)
|
||||||
i18n.SetLanguage(r.language)
|
i18n.SetLanguage(r.language)
|
||||||
st.themeMode = r.theme
|
st.themeMode = r.theme
|
||||||
st.theme = nil // force rebuild on next frame
|
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
|
st.prefsDialog = nil
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// preferencesDialog edits the persistent UI prefs: UI language, theme,
|
// preferencesDialog edits the persistent UI prefs: UI language, theme,
|
||||||
// and auto-lock timeout. Minimize-to-tray lives in its own bucket and
|
// auto-lock timeout, and minimize-to-tray behaviour.
|
||||||
// will appear here later.
|
|
||||||
type preferencesDialog struct {
|
type preferencesDialog struct {
|
||||||
lang string
|
lang string
|
||||||
theme themeMode
|
theme themeMode
|
||||||
@@ -35,6 +34,10 @@ type preferencesDialog struct {
|
|||||||
// input on that, the store does.
|
// input on that, the store does.
|
||||||
autoLockEd widget.Editor
|
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
|
okBt widget.Clickable
|
||||||
cancelBt widget.Clickable
|
cancelBt widget.Clickable
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ type preferencesResult struct {
|
|||||||
language string
|
language string
|
||||||
theme themeMode
|
theme themeMode
|
||||||
autoLockMinutes int
|
autoLockMinutes int
|
||||||
|
minimizeToTray bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedLanguages is the closed set the prefs dialog exposes. Keep
|
// supportedLanguages is the closed set the prefs dialog exposes. Keep
|
||||||
@@ -59,7 +63,7 @@ var supportedLanguages = []struct {
|
|||||||
{"de", "Deutsch"},
|
{"de", "Deutsch"},
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int) *preferencesDialog {
|
func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAutoLock int, currentMinToTray bool) *preferencesDialog {
|
||||||
if currentLang == "" {
|
if currentLang == "" {
|
||||||
currentLang = "en"
|
currentLang = "en"
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ func newPreferencesDialog(currentLang string, currentTheme themeMode, currentAut
|
|||||||
d := &preferencesDialog{lang: currentLang, theme: currentTheme}
|
d := &preferencesDialog{lang: currentLang, theme: currentTheme}
|
||||||
d.autoLockEd.SingleLine = true
|
d.autoLockEd.SingleLine = true
|
||||||
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
|
d.autoLockEd.SetText(strconv.Itoa(currentAutoLock))
|
||||||
|
d.minToTray.Value = currentMinToTray
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,12 @@ func (d *preferencesDialog) Layout(
|
|||||||
if err != nil || mins < 0 {
|
if err != nil || mins < 0 {
|
||||||
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}
|
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(labeledEditor(th, i18n.T("label_auto_lock_minutes"), &d.autoLockEd, "")),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
layout.Rigid(material.Caption(th, i18n.T("hint_auto_lock_disabled")).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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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) {}
|
||||||
@@ -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)
|
||||||
|
}()
|
||||||
Reference in New Issue
Block a user