mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-15 23:25:49 +01:00
Add guard against concurrent TrackPopupMenuEx calls using atomic.Bool to prevent race condition when user clicks systray icon repeatedly. Change TrackPopupMenuEx failure from fatal to debug log with early return so the application gracefully handles failed menu display attempts instead of crashing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Vibe Kanban <noreply@vibekanban.com> Co-authored-by: Claude <noreply@anthropic.com>
314 lines
7.6 KiB
Go
314 lines
7.6 KiB
Go
package application
|
|
|
|
import (
|
|
"sync/atomic"
|
|
"unsafe"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/w32"
|
|
)
|
|
|
|
const (
|
|
MenuItemMsgID = w32.WM_APP + 1024
|
|
)
|
|
|
|
type RadioGroupMember struct {
|
|
ID int
|
|
MenuItem *MenuItem
|
|
}
|
|
|
|
type RadioGroup []*RadioGroupMember
|
|
|
|
func (r *RadioGroup) Add(id int, item *MenuItem) {
|
|
*r = append(*r, &RadioGroupMember{
|
|
ID: id,
|
|
MenuItem: item,
|
|
})
|
|
}
|
|
|
|
func (r *RadioGroup) Bounds() (int, int) {
|
|
p := *r
|
|
return p[0].ID, p[len(p)-1].ID
|
|
}
|
|
|
|
func (r *RadioGroup) MenuID(item *MenuItem) int {
|
|
for _, member := range *r {
|
|
if member.MenuItem == item {
|
|
return member.ID
|
|
}
|
|
}
|
|
panic("RadioGroup.MenuID: item not found:")
|
|
}
|
|
|
|
type Win32Menu struct {
|
|
isPopup bool
|
|
menu w32.HMENU
|
|
parentWindow *windowsWebviewWindow
|
|
parent w32.HWND
|
|
menuMapping map[int]*MenuItem
|
|
checkboxItems map[*MenuItem][]int
|
|
radioGroups map[*MenuItem][]*RadioGroup
|
|
menuData *Menu
|
|
currentMenuID int
|
|
onMenuClose func()
|
|
onMenuOpen func()
|
|
isShowing atomic.Bool // guards against concurrent TrackPopupMenuEx calls
|
|
}
|
|
|
|
func (p *Win32Menu) newMenu() w32.HMENU {
|
|
if p.isPopup {
|
|
return w32.NewPopupMenu()
|
|
}
|
|
return w32.CreateMenu()
|
|
}
|
|
|
|
func (p *Win32Menu) buildMenu(parentMenu w32.HMENU, inputMenu *Menu) {
|
|
currentRadioGroup := RadioGroup{}
|
|
for _, item := range inputMenu.items {
|
|
p.currentMenuID++
|
|
itemID := p.currentMenuID
|
|
p.menuMapping[itemID] = item
|
|
|
|
menuItemImpl := newMenuItemImpl(item, parentMenu, itemID)
|
|
menuItemImpl.parent = inputMenu
|
|
item.impl = menuItemImpl
|
|
|
|
if item.Hidden() {
|
|
if item.accelerator != nil {
|
|
if p.parentWindow != nil {
|
|
// Remove the accelerator from the keybindings
|
|
p.parentWindow.parent.removeMenuBinding(item.accelerator)
|
|
} else {
|
|
// Remove the global keybindings
|
|
globalApplication.KeyBinding.Remove(item.accelerator.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
flags := uint32(w32.MF_STRING)
|
|
if item.disabled {
|
|
flags = flags | w32.MF_GRAYED
|
|
}
|
|
if item.checked {
|
|
flags = flags | w32.MF_CHECKED
|
|
}
|
|
if item.IsSeparator() {
|
|
flags = flags | w32.MF_SEPARATOR
|
|
}
|
|
|
|
if item.checked && item.IsRadio() {
|
|
flags = flags | w32.MFT_RADIOCHECK
|
|
}
|
|
|
|
if item.IsCheckbox() {
|
|
p.checkboxItems[item] = append(p.checkboxItems[item], itemID)
|
|
}
|
|
if item.IsRadio() {
|
|
currentRadioGroup.Add(itemID, item)
|
|
} else {
|
|
if len(currentRadioGroup) > 0 {
|
|
for _, radioMember := range currentRadioGroup {
|
|
currentRadioGroup := currentRadioGroup
|
|
p.radioGroups[radioMember.MenuItem] = append(p.radioGroups[radioMember.MenuItem], ¤tRadioGroup)
|
|
}
|
|
currentRadioGroup = RadioGroup{}
|
|
}
|
|
}
|
|
|
|
if item.submenu != nil {
|
|
flags = flags | w32.MF_POPUP
|
|
newSubmenu := p.newMenu()
|
|
p.buildMenu(newSubmenu, item.submenu)
|
|
itemID = int(newSubmenu)
|
|
menuItemImpl.submenu = newSubmenu
|
|
}
|
|
|
|
var menuText = item.Label()
|
|
if item.accelerator != nil {
|
|
menuText = menuText + "\t" + item.accelerator.String()
|
|
if item.callback != nil {
|
|
if p.parentWindow != nil {
|
|
p.parentWindow.parent.addMenuBinding(item.accelerator, item)
|
|
} else {
|
|
globalApplication.KeyBinding.Add(item.accelerator.String(), func(w Window) {
|
|
item.handleClick()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the item is hidden, don't append
|
|
if item.Hidden() {
|
|
continue
|
|
}
|
|
|
|
ok := w32.AppendMenu(parentMenu, flags, uintptr(itemID), w32.MustStringToUTF16Ptr(menuText))
|
|
if !ok {
|
|
globalApplication.fatal("error adding menu item '%s'", menuText)
|
|
}
|
|
if item.bitmap != nil {
|
|
if err := w32.SetMenuIcons(parentMenu, itemID, item.bitmap, nil); err != nil {
|
|
globalApplication.fatal("error setting menu icons: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if len(currentRadioGroup) > 0 {
|
|
for _, radioMember := range currentRadioGroup {
|
|
currentRadioGroup := currentRadioGroup
|
|
p.radioGroups[radioMember.MenuItem] = append(p.radioGroups[radioMember.MenuItem], ¤tRadioGroup)
|
|
}
|
|
currentRadioGroup = RadioGroup{}
|
|
}
|
|
}
|
|
|
|
func (p *Win32Menu) Update() {
|
|
p.menu = p.newMenu()
|
|
p.menuMapping = make(map[int]*MenuItem)
|
|
p.currentMenuID = MenuItemMsgID
|
|
p.buildMenu(p.menu, p.menuData)
|
|
p.updateRadioGroups()
|
|
}
|
|
|
|
func NewPopupMenu(parent w32.HWND, inputMenu *Menu) *Win32Menu {
|
|
result := &Win32Menu{
|
|
isPopup: true,
|
|
parent: parent,
|
|
menuData: inputMenu,
|
|
checkboxItems: make(map[*MenuItem][]int),
|
|
radioGroups: make(map[*MenuItem][]*RadioGroup),
|
|
}
|
|
result.Update()
|
|
return result
|
|
}
|
|
func NewApplicationMenu(parent *windowsWebviewWindow, inputMenu *Menu) *Win32Menu {
|
|
result := &Win32Menu{
|
|
parentWindow: parent,
|
|
parent: parent.hwnd,
|
|
menuData: inputMenu,
|
|
checkboxItems: make(map[*MenuItem][]int),
|
|
radioGroups: make(map[*MenuItem][]*RadioGroup),
|
|
}
|
|
result.Update()
|
|
return result
|
|
}
|
|
|
|
func (p *Win32Menu) ShowAt(x int, y int) {
|
|
// Prevent concurrent menu displays - TrackPopupMenuEx is blocking and
|
|
// calling it while another popup is showing causes "TrackPopupMenu failed"
|
|
if !p.isShowing.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
defer p.isShowing.Store(false)
|
|
|
|
w32.SetForegroundWindow(p.parent)
|
|
|
|
if p.onMenuOpen != nil {
|
|
p.onMenuOpen()
|
|
}
|
|
|
|
// Get screen dimensions to determine menu positioning
|
|
monitor := w32.MonitorFromWindow(p.parent, w32.MONITOR_DEFAULTTONEAREST)
|
|
var monitorInfo w32.MONITORINFO
|
|
monitorInfo.CbSize = uint32(unsafe.Sizeof(monitorInfo))
|
|
if !w32.GetMonitorInfo(monitor, &monitorInfo) {
|
|
globalApplication.fatal("GetMonitorInfo failed")
|
|
}
|
|
|
|
// Set flags to always position the menu above the cursor
|
|
menuFlags := uint32(w32.TPM_LEFTALIGN | w32.TPM_BOTTOMALIGN)
|
|
|
|
// Check if we're close to the right edge of the screen
|
|
// If so, right-align the menu with some padding
|
|
if x > int(monitorInfo.RcWork.Right)-200 { // Assuming 200px as a reasonable menu width
|
|
menuFlags = uint32(w32.TPM_RIGHTALIGN | w32.TPM_BOTTOMALIGN)
|
|
// Add a small padding (10px) from the right edge
|
|
x = int(monitorInfo.RcWork.Right) - 10
|
|
}
|
|
|
|
if !w32.TrackPopupMenuEx(p.menu, menuFlags, int32(x), int32(y), p.parent, nil) {
|
|
// TrackPopupMenuEx can fail if called during menu transitions or rapid clicks.
|
|
// This is not fatal - just skip this menu display attempt.
|
|
globalApplication.debug("TrackPopupMenu failed - menu may already be showing")
|
|
return
|
|
}
|
|
|
|
if p.onMenuClose != nil {
|
|
p.onMenuClose()
|
|
}
|
|
|
|
if !w32.PostMessage(p.parent, w32.WM_NULL, 0, 0) {
|
|
globalApplication.fatal("PostMessage failed")
|
|
}
|
|
|
|
}
|
|
|
|
func (p *Win32Menu) ShowAtCursor() {
|
|
x, y, ok := w32.GetCursorPos()
|
|
if ok == false {
|
|
globalApplication.fatal("GetCursorPos failed")
|
|
}
|
|
|
|
p.ShowAt(x, y)
|
|
}
|
|
|
|
func (p *Win32Menu) ProcessCommand(cmdMsgID int) bool {
|
|
item := p.menuMapping[cmdMsgID]
|
|
if item == nil {
|
|
return false
|
|
}
|
|
if item.IsRadio() {
|
|
if item.checked {
|
|
return true
|
|
}
|
|
item.checked = true
|
|
p.updateRadioGroup(item)
|
|
}
|
|
if item.callback != nil {
|
|
item.handleClick()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (p *Win32Menu) Destroy() {
|
|
w32.DestroyMenu(p.menu)
|
|
}
|
|
|
|
func (p *Win32Menu) UpdateMenuItem(item *MenuItem) {
|
|
if item.IsCheckbox() {
|
|
for _, itemID := range p.checkboxItems[item] {
|
|
var checkState uint = w32.MF_UNCHECKED
|
|
if item.checked {
|
|
checkState = w32.MF_CHECKED
|
|
}
|
|
w32.CheckMenuItem(p.menu, uintptr(itemID), checkState)
|
|
}
|
|
return
|
|
}
|
|
if item.IsRadio() && item.checked == true {
|
|
p.updateRadioGroup(item)
|
|
}
|
|
}
|
|
|
|
func (p *Win32Menu) updateRadioGroups() {
|
|
for menuItem := range p.radioGroups {
|
|
if menuItem.checked {
|
|
p.updateRadioGroup(menuItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Win32Menu) updateRadioGroup(item *MenuItem) {
|
|
for _, radioGroup := range p.radioGroups[item] {
|
|
thisMenuID := radioGroup.MenuID(item)
|
|
startID, endID := radioGroup.Bounds()
|
|
w32.CheckRadio(p.menu, startID, endID, thisMenuID)
|
|
|
|
}
|
|
}
|
|
|
|
func (p *Win32Menu) OnMenuOpen(fn func()) {
|
|
p.onMenuOpen = fn
|
|
}
|
|
|
|
func (p *Win32Menu) OnMenuClose(fn func()) {
|
|
p.onMenuClose = fn
|
|
}
|