diff --git a/v3/pkg/services/notifications/notifications_windows.go b/v3/pkg/services/notifications/notifications_windows.go index 51de4311c..ddb71f528 100644 --- a/v3/pkg/services/notifications/notifications_windows.go +++ b/v3/pkg/services/notifications/notifications_windows.go @@ -15,12 +15,24 @@ import ( "git.sr.ht/~jackmordaunt/go-toast/v2" "github.com/google/uuid" "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" "golang.org/x/sys/windows/registry" ) -var NotificationLock sync.RWMutex -var NotificationCategories = make(map[string]NotificationCategory) -var Icon []byte +var ( + NotificationLock sync.RWMutex + NotificationCategories = make(map[string]NotificationCategory) + AppName string + AppGUID string + IconPath string +) + +const ( + ToastRegistryPath = `Software\Classes\AppUserModelId\` + ToastRegistryGuidKey = "CustomActivator" + NotificationCategoriesRegistryPath = `SOFTWARE\%s\NotificationCategories` + NotificationCategoriesRegistryKey = "Categories" +) // NotificationPayload combines the action ID and user data into a single structure type NotificationPayload struct { @@ -39,17 +51,20 @@ func New() *Service { // ServiceStartup is called when the service is loaded // Sets an activation callback to emit an event when notifications are interacted with. func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - appName := application.Get().Config().Name + AppName = application.Get().Config().Name - guid, err := getGUID(appName) + guid, err := getGUID() if err != nil { return err } + AppGUID = guid + + IconPath = filepath.Join(os.TempDir(), AppName+guid+".png") toast.SetAppData(toast.AppData{ - AppID: appName, + AppID: AppName, GUID: guid, - IconPath: filepath.Join(os.TempDir(), appName+guid+".png"), + IconPath: IconPath, }) toast.SetActivationCallback(func(args string, data []toast.UserData) { @@ -103,10 +118,8 @@ func (ns *Service) CheckNotificationAuthorization() bool { // SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows. // (subtitle and category id are only available on macOS) func (ns *Service) SendNotification(options NotificationOptions) error { - if len(Icon) > 0 { - if err := saveIconToDir(); err != nil { - fmt.Printf("Error saving icon: %v\n", err) - } + if err := saveIconToDir(); err != nil { + fmt.Printf("Error saving icon: %v\n", err) } n := toast.Notification{ @@ -130,10 +143,8 @@ func (ns *Service) SendNotification(options NotificationOptions) error { // If a NotificationCategory is not registered a basic notification will be sent. // (subtitle and category id are only available on macOS) func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { - if len(Icon) > 0 { - if err := saveIconToDir(); err != nil { - fmt.Printf("Error saving icon: %v\n", err) - } + if err := saveIconToDir(); err != nil { + fmt.Printf("Error saving icon: %v\n", err) } NotificationLock.RLock() @@ -236,11 +247,6 @@ func (ns *Service) RemoveNotification(identifier string) error { return nil } -// SetIcon sets the notifications icon. -func (ns *Service) SetIcon(icon []byte) { - Icon = icon -} - // encodePayload combines an action ID and user data into a single encoded string func encodePayload(actionID string, data map[string]interface{}) (string, error) { payload := NotificationPayload{ @@ -287,25 +293,16 @@ func parseNotificationResponse(response string) (action string, data string) { } func saveIconToDir() error { - options := application.Get().Config() - appName := options.Name - - guid, err := getGUID(appName) + icon, err := application.NewIconFromResource(w32.GetModuleHandle(""), uint16(3)) if err != nil { - return fmt.Errorf("failed to retrieve application guid from registry") + return fmt.Errorf("failed to retrieve application icon") } - iconPath := filepath.Join(os.TempDir(), appName+guid+".png") - - return os.WriteFile(iconPath, Icon, 0644) + return saveHIconAsPNG(icon, IconPath) } func saveCategoriesToRegistry() error { - appName := application.Get().Config().Name - if appName == "" { - return fmt.Errorf("failed to save categories to registry: empty executable name") - } - registryPath := fmt.Sprintf(`SOFTWARE\%s\NotificationCategories`, appName) + registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, AppName) key, _, err := registry.CreateKey( registry.CURRENT_USER, @@ -324,15 +321,11 @@ func saveCategoriesToRegistry() error { return err } - return key.SetStringValue("Categories", string(data)) + return key.SetStringValue(NotificationCategoriesRegistryKey, string(data)) } func loadCategoriesFromRegistry() error { - appName := application.Get().Config().Name - if appName == "" { - return fmt.Errorf("failed to save categories to registry: empty executable name") - } - registryPath := fmt.Sprintf(`SOFTWARE\%s\NotificationCategories`, appName) + registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, AppName) key, err := registry.OpenKey( registry.CURRENT_USER, @@ -347,7 +340,7 @@ func loadCategoriesFromRegistry() error { } defer key.Close() - data, _, err := key.GetStringValue("Categories") + data, _, err := key.GetStringValue(NotificationCategoriesRegistryKey) if err != nil { return err } @@ -373,12 +366,12 @@ func getUserText(data []toast.UserData) (string, bool) { return "", false } -func getGUID(name string) (string, error) { - keyPath := `Software\Classes\AppUserModelId\` + name +func getGUID() (string, error) { + keyPath := ToastRegistryPath + AppName k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err == nil { - guid, _, err := k.GetStringValue("CustomActivator") + guid, _, err := k.GetStringValue(ToastRegistryGuidKey) k.Close() if err == nil && guid != "" { return guid, nil @@ -393,7 +386,7 @@ func getGUID(name string) (string, error) { } defer k.Close() - if err := k.SetStringValue("CustomActivator", guid); err != nil { + if err := k.SetStringValue(ToastRegistryGuidKey, guid); err != nil { return "", fmt.Errorf("failed to write GUID to registry: %w", err) } diff --git a/v3/pkg/services/notifications/notifications_windows_icon.go b/v3/pkg/services/notifications/notifications_windows_icon.go new file mode 100644 index 000000000..aed560594 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_windows_icon.go @@ -0,0 +1,173 @@ +//go:build windows + +package notifications + +import ( + "image" + "image/color" + "image/png" + "os" + "syscall" + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/w32" +) + +// Windows API constants +const ( + SRCCOPY = 0x00CC0020 + BI_RGB = 0 + DIB_RGB_COLORS = 0 +) + +// Windows structures +type ICONINFO struct { + FIcon int32 + XHotspot int32 + YHotspot int32 + HbmMask syscall.Handle + HbmColor syscall.Handle +} + +type BITMAP struct { + BmType int32 + BmWidth int32 + BmHeight int32 + BmWidthBytes int32 + BmPlanes uint16 + BmBitsPixel uint16 + BmBits uintptr +} + +type BITMAPINFOHEADER struct { + BiSize uint32 + BiWidth int32 + BiHeight int32 + BiPlanes uint16 + BiBitCount uint16 + BiCompression uint32 + BiSizeImage uint32 + BiXPelsPerMeter int32 + BiYPelsPerMeter int32 + BiClrUsed uint32 + BiClrImportant uint32 +} + +type RGBQUAD struct { + RgbBlue byte + RgbGreen byte + RgbRed byte + RgbReserved byte +} + +type BITMAPINFO struct { + BmiHeader BITMAPINFOHEADER + BmiColors [1]RGBQUAD +} + +func saveHIconAsPNG(hIcon w32.HICON, filePath string) error { + // Load necessary DLLs + user32 := syscall.NewLazyDLL("user32.dll") + gdi32 := syscall.NewLazyDLL("gdi32.dll") + + // Get procedures + getIconInfo := user32.NewProc("GetIconInfo") + getObject := gdi32.NewProc("GetObjectW") + createCompatibleDC := gdi32.NewProc("CreateCompatibleDC") + selectObject := gdi32.NewProc("SelectObject") + getDIBits := gdi32.NewProc("GetDIBits") + deleteObject := gdi32.NewProc("DeleteObject") + deleteDC := gdi32.NewProc("DeleteDC") + + // Get icon info + var iconInfo ICONINFO + ret, _, err := getIconInfo.Call( + uintptr(hIcon), + uintptr(unsafe.Pointer(&iconInfo)), + ) + if ret == 0 { + return err + } + defer deleteObject.Call(uintptr(iconInfo.HbmMask)) + defer deleteObject.Call(uintptr(iconInfo.HbmColor)) + + // Get bitmap info + var bmp BITMAP + ret, _, err = getObject.Call( + uintptr(iconInfo.HbmColor), + unsafe.Sizeof(bmp), + uintptr(unsafe.Pointer(&bmp)), + ) + if ret == 0 { + return err + } + + // Create DC + hdc, _, _ := createCompatibleDC.Call(0) + if hdc == 0 { + return syscall.EINVAL + } + defer deleteDC.Call(hdc) + + // Select bitmap into DC + oldBitmap, _, _ := selectObject.Call(hdc, uintptr(iconInfo.HbmColor)) + defer selectObject.Call(hdc, oldBitmap) + + // Prepare bitmap info header + var bi BITMAPINFO + bi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bi.BmiHeader)) + bi.BmiHeader.BiWidth = bmp.BmWidth + bi.BmiHeader.BiHeight = bmp.BmHeight + bi.BmiHeader.BiPlanes = 1 + bi.BmiHeader.BiBitCount = 32 + bi.BmiHeader.BiCompression = BI_RGB + + // Allocate memory for bitmap bits + width, height := int(bmp.BmWidth), int(bmp.BmHeight) + bufferSize := width * height * 4 + bits := make([]byte, bufferSize) + + // Get bitmap bits + ret, _, err = getDIBits.Call( + hdc, + uintptr(iconInfo.HbmColor), + 0, + uintptr(bmp.BmHeight), + uintptr(unsafe.Pointer(&bits[0])), + uintptr(unsafe.Pointer(&bi)), + DIB_RGB_COLORS, + ) + if ret == 0 { + return err + } + + // Create Go image + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // Convert DIB to RGBA + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + // DIB is bottom-up, so we need to invert Y + dibIndex := ((height-1-y)*width + x) * 4 + + // BGRA to RGBA + b := bits[dibIndex] + g := bits[dibIndex+1] + r := bits[dibIndex+2] + a := bits[dibIndex+3] + + // Set pixel in the image + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a}) + } + } + + // Create output file + outFile, err := os.Create(filePath) + if err != nil { + return err + } + defer outFile.Close() + + // Encode and save the image + return png.Encode(outFile, img) +}