From 21683cd47543ef7d9e3a56d955006c5914527853 Mon Sep 17 00:00:00 2001 From: Wails Documentation Agent Date: Wed, 22 Apr 2026 02:41:35 +1000 Subject: [PATCH] fix: support custom dialog buttons on Windows via TaskDialog API Windows dialogs with custom buttons and OnClick callbacks were silently ignored because MessageBox only supports predefined button sets (OK, Yes, No, etc.). Users could add buttons with custom labels but callbacks never fired. Use TaskDialogIndirect (comctl32.dll, Vista+) when custom buttons are present, which supports arbitrary button labels and proper callback dispatch by button ID. Falls back to the existing MessageBox path when no custom buttons are defined. Fixes #4402 --- v3/pkg/application/dialogs_taskdialog_test.go | 82 +++++++++ v3/pkg/application/dialogs_windows.go | 6 +- .../application/dialogs_windows_taskdialog.go | 171 ++++++++++++++++++ 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 v3/pkg/application/dialogs_taskdialog_test.go create mode 100644 v3/pkg/application/dialogs_windows_taskdialog.go diff --git a/v3/pkg/application/dialogs_taskdialog_test.go b/v3/pkg/application/dialogs_taskdialog_test.go new file mode 100644 index 00000000000..71bec09a006 --- /dev/null +++ b/v3/pkg/application/dialogs_taskdialog_test.go @@ -0,0 +1,82 @@ +package application + +import ( + "sync" + "testing" +) + +func TestTaskDialogButtonCallbackStorage(t *testing.T) { + callbacks := make(map[int32]func()) + var mu sync.Mutex + + quitCalled := false + cancelCalled := false + + callbacks[100] = func() { quitCalled = true } + callbacks[101] = func() { cancelCalled = true } + + mu.Lock() + if cb, ok := callbacks[100]; ok && cb != nil { + cb() + } + mu.Unlock() + + if !quitCalled { + t.Error("callback for button 100 should have been called") + } + if cancelCalled { + t.Error("callback for button 101 should NOT have been called yet") + } + + mu.Lock() + if cb, ok := callbacks[101]; ok && cb != nil { + cb() + } + mu.Unlock() + + if !cancelCalled { + t.Error("callback for button 101 should have been called") + } +} + +func TestTaskDialogButtonIDMapping(t *testing.T) { + buttons := []*Button{ + {Label: "Quit", IsDefault: true}, + {Label: "Cancel", IsCancel: true}, + {Label: "Retry"}, + } + + const customButtonBase = 100 + for i, btn := range buttons { + id := int32(customButtonBase + i) + expectedID := int32(100 + i) + if id != expectedID { + t.Errorf("button %q: id = %d, want %d", btn.Label, id, expectedID) + } + if btn.Label == "Quit" && !btn.IsDefault { + t.Error("Quit button should be default") + } + if btn.Label == "Cancel" && !btn.IsCancel { + t.Error("Cancel button should be cancel") + } + } +} + +func TestTaskDialogDefaultButtonSelection(t *testing.T) { + buttons := []*Button{ + {Label: "No", IsDefault: false}, + {Label: "Yes", IsDefault: true}, + } + + var defaultButtonID int32 + const customButtonBase = 100 + for i, btn := range buttons { + if btn.IsDefault { + defaultButtonID = int32(customButtonBase + i) + } + } + + if defaultButtonID != 101 { + t.Errorf("defaultButtonID = %d, want 101", defaultButtonID) + } +} diff --git a/v3/pkg/application/dialogs_windows.go b/v3/pkg/application/dialogs_windows.go index 73084d098e0..e59b79e6ec1 100644 --- a/v3/pkg/application/dialogs_windows.go +++ b/v3/pkg/application/dialogs_windows.go @@ -31,6 +31,9 @@ type windowsDialog struct { } func (m *windowsDialog) show() { + if len(m.dialog.Buttons) > 0 && showTaskDialog(m.dialog) { + return + } title := w32.MustStringToUTF16Ptr(m.dialog.Title) message := w32.MustStringToUTF16Ptr(m.dialog.Message) @@ -47,7 +50,6 @@ func (m *windowsDialog) show() { } if m.UseAppIcon || m.dialog.Icon != nil { - // 3 is the application icon button, err = w32.MessageBoxWithIcon(parentWindow, message, title, 3, windows.MB_OK|windows.MB_USERICON) if err != nil { globalApplication.handleFatalError(err) @@ -58,13 +60,11 @@ func (m *windowsDialog) show() { globalApplication.handleFatalError(err) } } - // This maps MessageBox return values to strings responses := []string{"", "Ok", "Cancel", "Abort", "Retry", "Ignore", "Yes", "No", "", "", "Try Again", "Continue"} result := "Error" if int(button) < len(responses) { result = responses[button] } - // Check if there's a callback for the button pressed for _, buttonInDialog := range m.dialog.Buttons { if buttonInDialog.Label == result { if buttonInDialog.Callback != nil { diff --git a/v3/pkg/application/dialogs_windows_taskdialog.go b/v3/pkg/application/dialogs_windows_taskdialog.go new file mode 100644 index 00000000000..b7c8eba9795 --- /dev/null +++ b/v3/pkg/application/dialogs_windows_taskdialog.go @@ -0,0 +1,171 @@ +//go:build windows + +package application + +import ( + "sync" + "syscall" + "unsafe" +) + +const ( + tdfAllowDialogCancellation = 0x0008 +) + +const ( + tdcbfOkButton = 0x0001 + tdcbfCancelButton = 0x0008 +) + +const ( + tdiInformationIcon = 65534 + tdiErrorIcon = 65531 + tdiWarningIcon = 65533 +) + +type taskDialogButton struct { + nButtonID int32 + pszButtonText *uint16 +} + +type taskDialogConfig struct { + cbSize uint32 + hwndParent uintptr + hInstance uintptr + dwFlags uint32 + dwCommonButtons uint32 + pszWindowTitle *uint16 + _ uintptr + pszMainIcon *uint16 + pszMainInstruction *uint16 + pszContent *uint16 + cButtons uint32 + pButtons uintptr + nDefaultButton int32 + cRadioButtons uint32 + pRadioButtons uintptr + nDefaultRadioButton int32 + pszVerificationText *uint16 + pszExpandedInfo *uint16 + pszExpandedCtrlText *uint16 + pszCollapsedCtrlText *uint16 + _ uintptr + pszFooterIcon *uint16 + pszFooter *uint16 + pfCallback uintptr + lpCallbackData uintptr + cxWidth uint32 +} + +var ( + lazyComctl32 = syscall.NewLazyDLL("comctl32.dll") + procTaskDialogIndirect = lazyComctl32.NewProc("TaskDialogIndirect") + taskDialogCallbackMutex sync.Mutex + taskDialogButtonCallback map[int32]func() +) + +func init() { + taskDialogButtonCallback = make(map[int32]func()) +} + +func taskDialogAvailable() bool { + return procTaskDialogIndirect.Find() == nil +} + +func showTaskDialog(dialog *MessageDialog) bool { + if !taskDialogAvailable() { + return false + } + + var parentWindow uintptr + if dialog.window != nil { + if nativeWindow := dialog.window.NativeWindow(); nativeWindow != nil { + parentWindow = uintptr(nativeWindow) + } + } + + cfg := taskDialogConfig{ + cbSize: uint32(unsafe.Sizeof(taskDialogConfig{})), + hwndParent: parentWindow, + dwFlags: tdfAllowDialogCancellation, + } + + if dialog.Title != "" { + cfg.pszWindowTitle = syscall.StringToUTF16Ptr(dialog.Title) + } + + if dialog.Message != "" { + cfg.pszMainInstruction = syscall.StringToUTF16Ptr(dialog.Message) + } + + switch dialog.DialogType { + case InfoDialogType: + cfg.pszMainIcon = makeIntResource(tdiInformationIcon) + case ErrorDialogType: + cfg.pszMainIcon = makeIntResource(tdiErrorIcon) + case WarningDialogType: + cfg.pszMainIcon = makeIntResource(tdiWarningIcon) + case QuestionDialogType: + cfg.pszMainIcon = makeIntResource(tdiInformationIcon) + } + + if len(dialog.Buttons) == 0 { + cfg.dwCommonButtons = tdcbfOkButton + } + + var buttons []taskDialogButton + const customButtonBase = 100 + + taskDialogCallbackMutex.Lock() + for id := range taskDialogButtonCallback { + delete(taskDialogButtonCallback, id) + } + + for i, btn := range dialog.Buttons { + id := int32(customButtonBase + i) + buttons = append(buttons, taskDialogButton{ + nButtonID: id, + pszButtonText: syscall.StringToUTF16Ptr(btn.Label), + }) + if btn.Callback != nil { + taskDialogButtonCallback[id] = btn.Callback + } + if btn.IsDefault { + cfg.nDefaultButton = id + } + } + taskDialogCallbackMutex.Unlock() + + if len(buttons) > 0 { + cfg.cButtons = uint32(len(buttons)) + cfg.pButtons = uintptr(unsafe.Pointer(&buttons[0])) + } + + var buttonPressed int32 + ret, _, _ := procTaskDialogIndirect.Call( + uintptr(unsafe.Pointer(&cfg)), + uintptr(unsafe.Pointer(&buttonPressed)), + 0, + 0, + ) + + if ret != 0 { + return false + } + + if buttonPressed >= customButtonBase { + taskDialogCallbackMutex.Lock() + if cb, ok := taskDialogButtonCallback[buttonPressed]; ok && cb != nil { + taskDialogCallbackMutex.Unlock() + cb() + return true + } + taskDialogCallbackMutex.Unlock() + } + + return true +} + +func makeIntResource(id uint16) *uint16 { + return (*uint16)(unsafe.Pointer(uintptr(id))) +}