diff --git a/application.go b/application.go index 2040fa0cc..11b5998b9 100644 --- a/application.go +++ b/application.go @@ -718,6 +718,8 @@ func AppendToWalkInit(fn func()) { } func appWinEventProc(hook win.HWINEVENTHOOK, event uint32, hwnd win.HWND, idObject int32, idChild int32, idEventThread uint32, eventTimeMilliseconds uint32) uintptr { + defer appSingleton.HandlePanicFromNativeCallback() + switch event { case win.EVENT_OBJECT_CLOAKED, win.EVENT_OBJECT_UNCLOAKED: var wparam uintptr @@ -868,7 +870,7 @@ func (app *Application) DeletePreTranslateHandlerForHWND(hwnd win.HWND) { // Go may be called from any goroutine. Go will not run f if // [(*Application).Exit] has already been called. func (app *Application) Go(f func(context.Context)) { - if app.ctx.Err() != nil { + if f == nil || app.ctx.Err() != nil { return } diff --git a/go.mod b/go.mod index d22c25bfa..b365ad10f 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/walk -go 1.21 +go 1.24.0 require ( github.com/dblohm7/wingoes v0.0.0-20231019175336-f6e33aa7cc34 - github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35 + github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1 golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 - golang.org/x/sys v0.8.0 + golang.org/x/sys v0.37.0 gopkg.in/Knetic/govaluate.v3 v3.0.0 ) diff --git a/go.sum b/go.sum index f4f60c8a8..d02e2e7ca 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ github.com/dblohm7/wingoes v0.0.0-20231019175336-f6e33aa7cc34 h1:FBMro26TLQwBk+n4fbTSmSf3QUKb09pvW4fz49lxpl0= github.com/dblohm7/wingoes v0.0.0-20231019175336-f6e33aa7cc34/go.mod h1:6NCrWM5jRefaG7iN0iMShPalLsljHWBh9v1zxM2f8Xs= -github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35 h1:wAZbkTZkqDzWsqxPh2qkBd3KvFU7tcxV0BP0Rnhkxog= -github.com/tailscale/win v0.0.0-20250213223159-5992cb43ca35/go.mod h1:aMd4yDHLjbOuYP6fMxj1d9ACDQlSWwYztcpybGHCQc8= +github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1 h1:QzOW7bZOlg5/hi1W6dStSN9qazTHkHj5kO7sTA5aymY= +github.com/tailscale/win v0.0.0-20260619195133-2d76c33a64c1/go.mod h1:vwG7VvIPzIYNJd0Nio6PZW7yr8KNNFEc8EVQ+J/B4bk= golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/notifyicon.go b/notifyicon.go index 2673ebe6d..661722eb1 100644 --- a/notifyicon.go +++ b/notifyicon.go @@ -45,21 +45,23 @@ func (niw *notifyIconWindow) Dispose() { niw.WindowBase.Dispose() } +func (niw *notifyIconWindow) nidToNotifyIcon(nid uint16) *NotifyIcon { + if ni := niw.owner; ni != nil { + return ni + } + + // No GUID, try resolving via integral ID. + return notifyIconIDs[nid] +} + func (niw *notifyIconWindow) WndProc(hwnd win.HWND, msg uint32, wParam, lParam uintptr) uintptr { switch msg { case notifyIconMessageID: lp32 := uint32(lParam) - ni := niw.owner - if ni == nil { - // No GUID, try resolving via integral ID. - ni = notifyIconIDs[win.HIWORD(lp32)] - if ni == nil { - // We don't need to call DefWindowProc because this is an app-defined message. - return 0 - } + if ni := niw.nidToNotifyIcon(win.HIWORD(lp32)); ni != nil { + ni.wndProc(hwnd, win.LOWORD(lp32), wParam) } - ni.wndProc(hwnd, win.LOWORD(lp32), wParam) // We don't need to call DefWindowProc because this is an app-defined message. return 0 case taskbarCreatedMsgId: @@ -73,6 +75,12 @@ func (niw *notifyIconWindow) WndProc(hwnd win.HWND, msg uint32, wParam, lParam u niw.forIcon(func(ni *NotifyIcon) { ni.activeContextMenus++ }) case win.WM_EXITMENULOOP: niw.forIcon(func(ni *NotifyIcon) { ni.activeContextMenus-- }) + case win.WM_TIMER: + tid := niTimerID(wParam) + if ni := niw.nidToNotifyIcon(tid.notificationIconID()); ni != nil { + ni.wndProc(hwnd, win.WM_TIMER, uintptr(tid.notificationCode())) + return 0 + } default: } @@ -100,9 +108,28 @@ func (ni *NotifyIcon) wndProc(hwnd win.HWND, msg uint16, wParam uintptr) { case win.WM_LBUTTONDOWN: ni.mouseDownPublisher.Publish(int(win.GET_X_LPARAM(wParam)), int(win.GET_Y_LPARAM(wParam)), LeftButton) - // We treat keyboard selection of the icon identically to a left-click. - // All three messages use the same format for wParam. - case win.NIN_KEYSELECT, win.NIN_SELECT, win.WM_LBUTTONUP: + case win.NIN_KEYSELECT: + // NIN_KEYSELECT is extremely poorly documented, but Spy++ dumps show it + // delivering a second notification immediately after the first under certain + // conditions (such as when the enter key is used to make the selection). + // Since we're only interested in the first notification, we set a timer + // and ignore any further notifications for a short duration of time. + // The double-click time feels like a sufficient duration to suppress + // the extras. + if ni.keySelectTimerID != 0 { + return + } + + // We use a HWND-based timer since we're already processing messages for + // the current window: timer notifications will therefore be posted via + // the same message queue already being used for keyboard input. + // We include ni's identifier in the timer ID to ensure that we can route + // the WM_TIMER to the appropriate NotifyIcon. + tid := makeNotifyIconTimerID(win.NIN_KEYSELECT, ni.id()) + ni.keySelectTimerID = niTimerID(win.SetTimer(hwnd, uintptr(tid), win.GetDoubleClickTime(), 0)) + fallthrough + + case win.NIN_SELECT: if ni.activeContextMenus > 0 { win.PostMessage(hwnd, win.WM_CANCELMODE, 0, 0) break @@ -142,6 +169,13 @@ func (ni *NotifyIcon) wndProc(hwnd win.HWND, msg uint16, wParam uintptr) { case win.NIN_BALLOONUSERCLICK: ni.reEnableToolTip() ni.messageClickedPublisher.Publish() + + case win.WM_TIMER: + if wParam == win.NIN_KEYSELECT { + if win.KillTimer(hwnd, uintptr(ni.keySelectTimerID)) { + ni.keySelectTimerID = 0 + } + } } } @@ -232,7 +266,7 @@ func newNotificationIconWindow() (*notifyIconWindow, error) { ClassName: notifyIconWindowClass, // Creating the window with WS_DISABLED in an effort to dissuade screen // readers from treating the hidden window as focusable content. - Style: win.WS_OVERLAPPEDWINDOW | win.WS_DISABLED, + Style: win.WS_OVERLAPPEDWINDOW | win.WS_DISABLED, // Always create the window at the origin, thus ensuring that the window // resides on the desktop's primary monitor, which is the same monitor where // the taskbar notification area resides. This ensures that the window's @@ -509,6 +543,20 @@ func (cmd *niCmd) execute() error { return showTipCmd.execute() } +type niTimerID uintptr + +func (tid niTimerID) notificationCode() uint16 { + return uint16(tid) +} + +func (tid niTimerID) notificationIconID() uint16 { + return win.HIWORD(uint32(tid)) +} + +func makeNotifyIconTimerID(notificationCode, notifyIconID uint16) niTimerID { + return niTimerID(win.MAKELONG(notificationCode, notifyIconID)) +} + // NotifyIcon represents an icon in the taskbar notification area. type NotifyIcon struct { shellIcon *shellNotificationIcon @@ -520,6 +568,7 @@ type NotifyIcon struct { messageClickedPublisher EventPublisher showingContextMenuPublisher ProceedEventPublisher activeContextMenus int // int because Win32 permits nested context menus + keySelectTimerID niTimerID disableShowContextMenu bool visible bool } @@ -571,6 +620,19 @@ func newNotifyIcon(guid *windows.GUID) (*NotifyIcon, error) { return ni, nil } +func (ni *NotifyIcon) id() uint16 { + si := ni.shellIcon + if si == nil { + return 0 + } + + if pid := si.id; pid != nil { + return uint16(*pid) + } + + return 0 +} + func (ni *NotifyIcon) DPI() int { return ni.shellIcon.window.DPI() }