windows badge

This commit is contained in:
popaprozac 2025-04-24 14:44:30 -07:00
commit be28da26b8
3 changed files with 139 additions and 273 deletions

View file

@ -12,6 +12,7 @@ type platformBadge interface {
Shutdown() error
SetBadge(label string) error
RemoveBadge() error
}
// Service represents the notifications service
@ -37,3 +38,7 @@ func (b *Service) ServiceShutdown() error {
func (b *Service) SetBadge(label string) error {
return b.impl.SetBadge(label)
}
func (b *Service) RemoveBadge() error {
return b.impl.RemoveBadge()
}

View file

@ -49,3 +49,8 @@ func (d *darwinBadge) SetBadge(label string) error {
C.setBadge(cLabel)
return nil
}
func (d *darwinBadge) RemoveBadge() error {
C.setBadge(nil)
return nil
}

View file

@ -3,44 +3,47 @@
package badge
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"syscall"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/w32"
)
// COM GUIDs for taskbar interfaces
var (
CLSID_TaskbarList = syscall.GUID{
Data1: 0x56FDF344,
Data2: 0xFD6D,
Data3: 0x11D0,
Data4: [8]byte{0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90},
}
IID_ITaskbarList3 = syscall.GUID{
Data1: 0xEA1AFB91,
Data2: 0x9E28,
Data3: 0x4B86,
Data4: [8]byte{0x90, 0xE9, 0x9E, 0x9F, 0x8A, 0x5E, 0xEF, 0xAF},
}
ole32 = syscall.NewLazyDLL("ole32.dll")
shobjidl = syscall.NewLazyDLL("shell32.dll")
coCreateInstance = ole32.NewProc("CoCreateInstance")
)
// ITaskbarList3 COM interface
type ITaskbarList3Vtbl struct {
QueryInterface uintptr
AddRef uintptr
Release uintptr
// ITaskbarList methods
HrInit uintptr
AddTab uintptr
DeleteTab uintptr
ActivateTab uintptr
SetActiveAlt uintptr
// ITaskbarList2 methods
MarkFullscreenWindow uintptr
// ITaskbarList3 methods
const (
CLSCTX_INPROC_SERVER = 0x1
)
var (
CLSID_TaskbarList = syscall.GUID{0x56FDF344, 0xFD6D, 0x11D0, [8]byte{0x95, 0x8A, 0x00, 0x60, 0x97, 0xC9, 0xA0, 0x90}}
IID_ITaskbarList3 = syscall.GUID{0xEA1AFB91, 0x9E28, 0x4B86, [8]byte{0x90, 0xE9, 0x9E, 0x9F, 0x8A, 0x5E, 0xEF, 0xAF}}
)
type ITaskbarList3 struct {
lpVtbl *taskbarList3Vtbl
}
type taskbarList3Vtbl struct {
QueryInterface uintptr
AddRef uintptr
Release uintptr
HrInit uintptr
AddTab uintptr
DeleteTab uintptr
ActivateTab uintptr
SetActiveAlt uintptr
MarkFullscreenWindow uintptr
SetProgressValue uintptr
SetProgressState uintptr
RegisterTab uintptr
@ -53,308 +56,161 @@ type ITaskbarList3Vtbl struct {
SetOverlayIcon uintptr
SetThumbnailTooltip uintptr
SetThumbnailClip uintptr
SetTabProperties uintptr
}
type ITaskbarList3 struct {
Vtbl *ITaskbarList3Vtbl
func newTaskbarList3() (*ITaskbarList3, error) {
var taskbar *ITaskbarList3
hr, _, _ := coCreateInstance.Call(
uintptr(unsafe.Pointer(&CLSID_TaskbarList)),
0,
uintptr(CLSCTX_INPROC_SERVER),
uintptr(unsafe.Pointer(&IID_ITaskbarList3)),
uintptr(unsafe.Pointer(&taskbar)),
)
if hr != 0 {
return nil, syscall.Errno(hr)
}
return taskbar, nil
}
func (t *ITaskbarList3) SetOverlayIcon(hwnd syscall.Handle, hIcon syscall.Handle, description *uint16) error {
ret, _, _ := syscall.SyscallN(
t.lpVtbl.SetOverlayIcon,
uintptr(unsafe.Pointer(t)),
uintptr(hwnd),
uintptr(hIcon),
uintptr(unsafe.Pointer(description)),
)
if ret != 0 {
return syscall.Errno(ret)
}
return nil
}
type windowsBadge struct {
taskbarList *ITaskbarList3
mainWindow *application.WebviewWindow
initialized bool
redBadgeIcon syscall.Handle
taskbar *ITaskbarList3
}
func New() *Service {
return &Service{
impl: &windowsBadge{
mainWindow: application.Get().CurrentWindow(),
redBadgeIcon: 0,
},
impl: &windowsBadge{},
}
}
func (d *windowsBadge) Startup(ctx context.Context, options application.ServiceOptions) error {
if d.initialized {
return nil
}
// Initialize COM
err := coInitialize()
taskbar, err := newTaskbarList3()
if err != nil {
return fmt.Errorf("failed to initialize COM: %w", err)
return err
}
d.taskbar = taskbar
// Create an instance of the TaskbarList COM object
var taskbarList *ITaskbarList3
hr, _, _ := procCoCreateInstance.Call(
uintptr(unsafe.Pointer(&CLSID_TaskbarList)),
0,
21, // CLSCTX_INPROC_SERVER
uintptr(unsafe.Pointer(&IID_ITaskbarList3)),
uintptr(unsafe.Pointer(&taskbarList)))
if hr != 0 {
return fmt.Errorf("failed to create TaskbarList instance: %d", hr)
}
// Initialize the taskbar list
hr, _, _ = syscall.Syscall(
taskbarList.Vtbl.HrInit,
1,
uintptr(unsafe.Pointer(taskbarList)),
0,
0)
if hr != 0 {
return fmt.Errorf("failed to initialize TaskbarList: %d", hr)
}
// Create the red badge icon
redBadge, err := d.createRedBadgeIcon()
if err != nil {
return fmt.Errorf("failed to create red badge icon: %w", err)
}
d.taskbarList = taskbarList
d.redBadgeIcon = redBadge
d.initialized = true
// Don't try to get the window handle here - wait until SetBadge is called
return nil
}
func (d *windowsBadge) Shutdown() error {
if !d.initialized {
return nil
}
// Destroy the red badge icon
if d.redBadgeIcon != 0 {
destroyIcon(d.redBadgeIcon)
d.redBadgeIcon = 0
}
// Release the taskbar list interface
if d.taskbarList != nil {
syscall.Syscall(
d.taskbarList.Vtbl.Release,
1,
uintptr(unsafe.Pointer(d.taskbarList)),
0,
0)
d.taskbarList = nil
}
// Uninitialize COM
coUninitialize()
d.initialized = false
return nil
}
func (d *windowsBadge) SetBadge(label string) error {
// If not initialized, initialize
if !d.initialized {
err := d.Startup(context.Background(), application.ServiceOptions{})
if err != nil {
return err
}
}
// Ensure we have a window to work with
if d.mainWindow == nil {
d.mainWindow = application.Get().CurrentWindow()
if d.mainWindow == nil {
return fmt.Errorf("no window available for setting badge")
}
}
// Get the window handle using NativeWindowHandle() method
handle, err := d.mainWindow.NativeWindowHandle()
if err != nil {
return fmt.Errorf("failed to get window handle: %w", err)
}
hwnd := handle
// If empty value, remove the overlay
if label == "" {
hr, _, _ := syscall.SyscallN(
d.taskbarList.Vtbl.SetOverlayIcon,
4,
uintptr(unsafe.Pointer(d.taskbarList)),
hwnd,
0, // NULL icon handle
0, // NULL description
0, 0)
if hr != 0 {
return fmt.Errorf("failed to remove overlay icon: %d", hr)
}
if d.taskbar == nil {
return nil
}
// Set the overlay icon with a description
description, err := syscall.UTF16PtrFromString("New notification")
// Get the window handle when SetBadge is called, not during startup
app := application.Get()
if app == nil {
return nil // App not initialized yet
}
window := app.CurrentWindow()
if window == nil {
return nil // No window available yet
}
hwnd, err := window.NativeWindowHandle()
if err != nil {
return fmt.Errorf("failed to convert description: %w", err)
return err
}
hr, _, _ := syscall.SyscallN(
d.taskbarList.Vtbl.SetOverlayIcon,
4,
uintptr(unsafe.Pointer(d.taskbarList)),
hwnd,
uintptr(d.redBadgeIcon),
uintptr(unsafe.Pointer(description)),
0, 0)
if hr != 0 {
return fmt.Errorf("failed to set overlay icon: %d", hr)
if label == "" {
return d.taskbar.SetOverlayIcon(syscall.Handle(hwnd), 0, nil)
}
return nil
hicon, err := createBadgeIcon()
if err != nil {
return err
}
defer w32.DestroyIcon(hicon)
return d.taskbar.SetOverlayIcon(syscall.Handle(hwnd), syscall.Handle(hicon), nil)
}
// ICONINFO structure for creating icons
type ICONINFO struct {
FIcon uint32
XHotspot uint32
YHotspot uint32
HbmMask syscall.Handle
HbmColor syscall.Handle
}
// createRedBadgeIcon creates a simple red circle icon for the badge
func (d *windowsBadge) createRedBadgeIcon() (syscall.Handle, error) {
// Create a 16x16 pixel bitmap
hdc := getDC(0)
if hdc == 0 {
return 0, fmt.Errorf("failed to get DC")
}
defer releaseDC(0, hdc)
memDC := createCompatibleDC(hdc)
if memDC == 0 {
return 0, fmt.Errorf("failed to create compatible DC")
}
defer deleteObject(memDC)
// Create a bitmap
bmp := createCompatibleBitmap(hdc, 16, 16)
if bmp == 0 {
return 0, fmt.Errorf("failed to create bitmap")
func (d *windowsBadge) RemoveBadge() error {
if d.taskbar == nil {
return nil
}
oldBmp := selectObject(memDC, bmp)
// Create a solid red brush
redBrush := createSolidBrush(0x0000FF) // BGR format (blue=0, green=0, red=255)
defer deleteObject(redBrush)
// Fill the circle with red
selectObject(memDC, redBrush)
ellipse(memDC, 0, 0, 16, 16)
// Restore original bitmap
selectObject(memDC, oldBmp)
// Convert bitmap to icon
iconInfo := ICONINFO{
FIcon: 1, // TRUE for icon (vs. cursor)
XHotspot: 0,
YHotspot: 0,
HbmMask: bmp, // Use same bitmap for mask
HbmColor: bmp,
// Get the window handle when SetBadge is called, not during startup
app := application.Get()
if app == nil {
return nil // App not initialized yet
}
icon := createIconIndirect(&iconInfo)
if icon == 0 {
deleteObject(bmp)
return 0, fmt.Errorf("failed to create icon")
window := app.CurrentWindow()
if window == nil {
return nil // No window available yet
}
// Don't delete the bitmap here, as it's now owned by the icon
return icon, nil
}
// Define Windows API functions
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
ole32 = syscall.NewLazyDLL("ole32.dll")
procCoCreateInstance = ole32.NewProc("CoCreateInstance")
procCoInitialize = ole32.NewProc("CoInitialize")
procCoUninitialize = ole32.NewProc("CoUninitialize")
procGetDC = user32.NewProc("GetDC")
procReleaseDC = user32.NewProc("ReleaseDC")
procDestroyIcon = user32.NewProc("DestroyIcon")
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
procCreateCompatibleBitmap = gdi32.NewProc("CreateCompatibleBitmap")
procSelectObject = gdi32.NewProc("SelectObject")
procDeleteObject = gdi32.NewProc("DeleteObject")
procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush")
procEllipse = gdi32.NewProc("Ellipse")
)
// GDI function wrappers
func getDC(hwnd syscall.Handle) syscall.Handle {
ret, _, _ := procGetDC.Call(uintptr(hwnd))
return syscall.Handle(ret)
}
func releaseDC(hwnd, hdc syscall.Handle) bool {
ret, _, _ := procReleaseDC.Call(uintptr(hwnd), uintptr(hdc))
return ret != 0
}
func createCompatibleDC(hdc syscall.Handle) syscall.Handle {
ret, _, _ := procCreateCompatibleDC.Call(uintptr(hdc))
return syscall.Handle(ret)
}
func createCompatibleBitmap(hdc syscall.Handle, width, height int32) syscall.Handle {
ret, _, _ := procCreateCompatibleBitmap.Call(uintptr(hdc), uintptr(width), uintptr(height))
return syscall.Handle(ret)
}
func selectObject(hdc, hgdiobj syscall.Handle) syscall.Handle {
ret, _, _ := procSelectObject.Call(uintptr(hdc), uintptr(hgdiobj))
return syscall.Handle(ret)
}
func deleteObject(hObject syscall.Handle) bool {
ret, _, _ := procDeleteObject.Call(uintptr(hObject))
return ret != 0
}
func createSolidBrush(color int32) syscall.Handle {
ret, _, _ := procCreateSolidBrush.Call(uintptr(color))
return syscall.Handle(ret)
}
func ellipse(hdc syscall.Handle, left, top, right, bottom int32) bool {
ret, _, _ := procEllipse.Call(uintptr(hdc), uintptr(left), uintptr(top), uintptr(right), uintptr(bottom))
return ret != 0
}
func createIconIndirect(iconInfo *ICONINFO) syscall.Handle {
ret, _, _ := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(iconInfo)))
return syscall.Handle(ret)
}
func destroyIcon(hIcon syscall.Handle) bool {
ret, _, _ := procDestroyIcon.Call(uintptr(hIcon))
return ret != 0
}
func coInitialize() error {
hr, _, _ := procCoInitialize.Call(0)
if hr != 0 {
return fmt.Errorf("CoInitialize failed with code: %d", hr)
hwnd, err := window.NativeWindowHandle()
if err != nil {
return err
}
return nil
return d.taskbar.SetOverlayIcon(syscall.Handle(hwnd), 0, nil)
}
func coUninitialize() {
procCoUninitialize.Call()
func createBadgeIcon() (uintptr, error) {
const size = 32
img := image.NewRGBA(image.Rect(0, 0, size, size))
red := color.RGBA{255, 0, 0, 255}
radius := size / 2
centerX, centerY := radius, radius
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := float64(x - centerX)
dy := float64(y - centerY)
if dx*dx+dy*dy < float64(radius*radius) {
img.Set(x, y, red)
}
}
}
white := color.RGBA{255, 255, 255, 255}
innerRadius := size / 5
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := float64(x - centerX)
dy := float64(y - centerY)
if dx*dx+dy*dy < float64(innerRadius*innerRadius) {
img.Set(x, y, white)
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return 0, err
}
hicon, err := w32.CreateSmallHIconFromImage(buf.Bytes())
return uintptr(hicon), err
}