go-toast

Send toast notifications in Windows
Log | Files | Refs | README | LICENSE

commit 96c735044b149648fbe6051b9137b6db2dd3c4ef
parent eb13cd60ae70d0644ffb07df9a7b7b3c65a8c57e
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Tue,  5 Dec 2023 18:42:52 +0800

wintoast: use generated COM definitions

This commit removes the hand-written COM definitions as much
as possible.

However we are stuck with defining the _undocumented_ INotificationActivationCallback
and implementing it by hand.

The implemetnations get a cleanup here. The lifetime of our COM objects
are static, so we can remove all the locking and reference counting.

In addition, we can totally eschew Malloc/Free and just pin the objects
using [runtime.Pinner], deferring allocation to the Go runtime.

The end result is dramatically simplified.

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>

Diffstat:
Mwintoast/bind_windows.go | 118++++++++++++++++++-------------------------------------------------------------
Mwintoast/impl.go | 194++++++++++++++++++++++++++++++++++++++++---------------------------------------
Dwintoast/interfaces.go | 244-------------------------------------------------------------------------------
Mwintoast/procs.go | 30------------------------------
4 files changed, 124 insertions(+), 462 deletions(-)

diff --git a/wintoast/bind_windows.go b/wintoast/bind_windows.go @@ -13,6 +13,8 @@ import ( "unicode/utf16" "unsafe" + "git.sr.ht/~jackmordaunt/go-toast/internal/winrt/data/xml/dom" + "git.sr.ht/~jackmordaunt/go-toast/internal/winrt/ui/notifications" "git.sr.ht/~jackmordaunt/go-toast/tmpl" "github.com/go-ole/go-ole" ) @@ -61,36 +63,36 @@ func pushCOM(xml string) error { return err } - // 1. allocate ClassFactory implementation. - // 2. register ClassFactory implementation (provides our ActivationCallback to the runtime) - // 3. load noti manager (statics impl) - // 4. load noti factory - // 5. create xml - // 6. create xmlIO - // 7. load xml - // 8. create noti - - classFactory := newClassFactory() - if classFactory == nil { - return fmt.Errorf("could not allocate class factory") + if err := registerClassFactory(ClassFactory); err != nil { + return fmt.Errorf("registering class factory: %w", err) } - if err := registerClassFactory(classFactory); err != nil { - return fmt.Errorf("registering class factory: %w", err) + doc, err := dom.NewXmlDocument() + if err != nil { + return fmt.Errorf("dom.NewXmlDocument(): %w", err) + } + + if err := doc.LoadXml(xml); err != nil { + return fmt.Errorf("doc.LoadXml(tmpl): %w", err) + } + + manager, err := notifications.GetDefault() + if err != nil { + return fmt.Errorf("notifications.GetDefault(): %w", err) } - noti, err := newNotiFromXml(xml) + notifier, err := manager.CreateToastNotifierWithId(appData.AppID) if err != nil { - return fmt.Errorf("building notification: %w", err) + return fmt.Errorf("manager.CreateToastNotifier(): %w", err) } - notifier, err := newNotifier(appData.AppID) + toast, err := notifications.CreateToastNotification(doc) if err != nil { - return fmt.Errorf("building notifier: %w", err) + return fmt.Errorf("notifications.CreateToastNotification(doc): %w", err) } - if err := notifier.Show(noti); err != nil { - return fmt.Errorf("showing notification: %w", err) + if err := notifier.Show(toast); err != nil { + return fmt.Errorf("notifier.Show(): %w", err) } return nil @@ -129,8 +131,10 @@ func setAppData(data AppData) (err error) { return nil } -var initLock sync.Mutex -var didInitialize bool +var ( + initLock sync.Mutex + didInitialize bool +) // initialize attempts to initialize the Windows Runtime. // Each invocation will retry RoInitialize until a successful initialization @@ -153,78 +157,8 @@ func initialize() (err error) { return nil } -// newNotifier builds an IToastNotifier instance using the given appID. -func newNotifier(appID string) (*IToastNotifier, error) { - managerObject, err := ole.RoGetActivationFactory(CLSID_ToastNotificationManager, IID_ToastNotificationManager) - if err != nil { - return nil, fmt.Errorf("getting activation factory: %w", err) - } - - // Get access to the manager vtable. - manager := (*IToastNotificationManager)(unsafe.Pointer(managerObject)) - - notifier, err := manager.CreateToastNotifierWithID(appID) - if err != nil { - return nil, fmt.Errorf("creating toast notifier: %w", err) - } - - return notifier, nil -} - -// newNotiFromXml builds an IToastNotification instance from the given xml content. -func newNotiFromXml(xml string) (*IToastNotification, error) { - factoryObject, err := ole.RoGetActivationFactory(CLSID_ToastNotification, IID_ToastNotificationFactory) - if err != nil { - return nil, fmt.Errorf("getting activation factory: %w", err) - } - - // Get access to the factory vtable. - factory := (*IToastNotificationFactory)(unsafe.Pointer(factoryObject)) - - xmlDoc, err := loadXML(xml) - if err != nil { - return nil, fmt.Errorf("loading xml: %w", err) - } - - noti, err := factory.CreateToastNotification(xmlDoc) - if err != nil { - return nil, fmt.Errorf("creating toast notification: %w", err) - } - - return noti, nil -} - -// loadXML allocates an XML document object (returned as IDispatch because we don't care -// about representing it's vtable). -func loadXML(xml string) (*ole.IDispatch, error) { - xmlDocObject, err := ole.RoActivateInstance(CLSID_XMLDocument) - if err != nil { - return nil, fmt.Errorf("RoActivateInstance: %w", err) - } - - xmlDoc, err := xmlDocObject.QueryInterface(IID_IXmlDocument) - if err != nil { - return nil, fmt.Errorf("querying IID_IXmlDocument: %w", err) - } - - xmlDocIO, err := xmlDoc.QueryInterface(IID_IXmlDocumentIO) - if err != nil { - return nil, fmt.Errorf("querying interface IID_IXmlDocumentIO: %w", err) - } - - // Get access to the IO vtable. - xmlIO := (*IXMLDocumentIO)(unsafe.Pointer(xmlDocIO)) - - if err := xmlIO.LoadXml(xml); err != nil { - return nil, fmt.Errorf("IXmlDocumentIO.LoadXml: %w", err) - } - - return xmlDoc, nil -} - // sliceUserDataFromUnsafe builds a slice of UserData out of an unsafe pointer. func sliceUserDataFromUnsafe(ptr unsafe.Pointer, count int) []UserData { - // Layout mirrors the memory layout of the C struct that contains this data. // I'm not sure if there's special alignment or packing - though I don't notice // anything in the definition to indicate as such. diff --git a/wintoast/impl.go b/wintoast/impl.go @@ -10,38 +10,97 @@ // are built at runtime. They are declared globally because `syscall.NewCallback` never // releases the memory it allocates for the functions thus causing an unsolvable memory // leak if we were to allocate these per-notification. +// +// The other COM interfaces we are interacting with are auto-generated from metadata. +// However the INotificationActivationCallback is undocumented, so we have to define +// it entirely ourselves. +// +// The definitions are derived from: +// - <combase.h> +// - <NotificationActivationCallback.h> package wintoast import ( - "sync" + "runtime" "syscall" "unsafe" "github.com/go-ole/go-ole" ) +// Interface GUIDS. These GUIDS are predefined by the Windows Runtime, identifying the various +// interfaces we want to make use of. +var ( + IID_IClassFactory = ole.NewGUID("{00000001-0000-0000-C000-000000000046}") + IID_INotificationActivationCallback = ole.NewGUID("{53E31837-6600-4A81-9395-75CFFE746F94}") +) + +// This default GUID is for our implementation. +// This was generated and should not collide with any other GUID. +// It's preferable for the application to override this value with its own generated GUID. +var GUID_ImplNotificationActivationCallback = ole.NewGUID("{0F82E845-CB89-4039-BDBF-67CA33254C76}") + +type ( + // IClassFactory defines the factory that builds our INotificationActivationCallback instance. + // Windows Runtime loves factories. + IClassFactory struct { + VTable *IClassFactoryVtbl + } + + IClassFactoryVtbl struct { + ole.IUnknownVtbl + CreateInstance uintptr + LockServer uintptr + } +) + +type ( + // INotificationActivationCallback receives activations from toast notifications. + INotificationActivationCallback struct { + VTable *INotificationActivationCallbackVtbl + } + + INotificationActivationCallbackVtbl struct { + ole.IUnknownVtbl + Activate uintptr + } +) + +/* + Strictly speaking we shouldn't need to pin the static objects. They + are package-globals and wont be garabge collected. No harm in being + extra careful, though. +*/ + +var pinner runtime.Pinner + +func init() { + pinner.Pin(ClassFactory) + pinner.Pin(ClassFactory.VTable) + pinner.Pin(NotificationActivationCallback) + pinner.Pin(NotificationActivationCallback.VTable) +} + // Static implementations for the IClassFactory. -// syscall.NewCallback never releases its memory, so we pay that price once. var ( - // factoryLock protects the IClassFactory reference counting. - factoryLock sync.Mutex + ClassFactory = &IClassFactory{ + VTable: &IClassFactoryVtbl{ + IUnknownVtbl: ole.IUnknownVtbl{ + QueryInterface: IClassFactory_QueryInterface, + AddRef: IClassFactory_AddRef, + Release: IClassFactory_Release, + }, + LockServer: IClassFactory_LockServer, + CreateInstance: IClassFactory_CreateInstance, + }, + } IClassFactory_AddRef = syscall.NewCallback(func(this *IClassFactory) (re uintptr) { - factoryLock.Lock() - defer factoryLock.Unlock() - this.RefCount += 1 - return uintptr(this.RefCount) + return uintptr(1) }) IClassFactory_Release = syscall.NewCallback(func(this *IClassFactory) (re uintptr) { - factoryLock.Lock() - defer factoryLock.Unlock() - this.RefCount -= 1 - if this.RefCount == 0 { - free(unsafe.Pointer(this.lpVtbl)) - free(unsafe.Pointer(this)) - } - return uintptr(this.RefCount) + return uintptr(1) }) IClassFactory_QueryInterface = syscall.NewCallback(func(this *IClassFactory, riid *ole.GUID, out unsafe.Pointer) (re uintptr) { @@ -50,8 +109,7 @@ var ( return ole.E_NOINTERFACE } *(**IClassFactory)(out) = this - this.AddRef() - return uintptr(ole.S_OK) + return ole.S_OK }) IClassFactory_LockServer = syscall.NewCallback(func(this *IClassFactory, flock uintptr) (ret uintptr) { @@ -63,39 +121,34 @@ var ( // Should be CLASS_E_NOAGGREGATION but ole doesn't define this. return ole.E_NOINTERFACE } - object := newNotificationActivationCallback() - if object == nil { - return ole.E_OUTOFMEMORY + if !ole.IsEqualGUID(riid, IID_INotificationActivationCallback) && + !ole.IsEqualGUID(riid, ole.IID_IUnknown) { + return ole.E_NOINTERFACE } - object.RefCount = 1 - hr := object.QueryInterface(riid, out) - object.Release() - return uintptr(hr) + *(**INotificationActivationCallback)(out) = NotificationActivationCallback + return ole.S_OK }) ) // Static implementations for the INotificationActivationCallback. -// syscall.NewCallback never releases its memory, so we pay that price once. var ( - // callbackLock protects the ActivationCallback reference counting. - callbackLock sync.Mutex + NotificationActivationCallback = &INotificationActivationCallback{ + VTable: &INotificationActivationCallbackVtbl{ + IUnknownVtbl: ole.IUnknownVtbl{ + QueryInterface: INotificationActivationCallback_QueryInterface, + AddRef: INotificationActivationCallback_AddRef, + Release: INotificationActivationCallback_Release, + }, + Activate: INotificationActivationCallback_Activate, + }, + } INotificationActivationCallback_AddRef = syscall.NewCallback(func(this *INotificationActivationCallback) (re uintptr) { - callbackLock.Lock() - defer callbackLock.Unlock() - this.RefCount += 1 - return uintptr(this.RefCount) + return uintptr(1) }) INotificationActivationCallback_Release = syscall.NewCallback(func(this *INotificationActivationCallback) (re uintptr) { - callbackLock.Lock() - defer callbackLock.Unlock() - this.RefCount -= 1 - if this.RefCount == 0 { - free(unsafe.Pointer(this.lpVtbl)) - free(unsafe.Pointer(this)) - } - return uintptr(this.RefCount) + return uintptr(1) }) INotificationActivationCallback_QueryInterface = syscall.NewCallback(func(this *INotificationActivationCallback, riid *ole.GUID, out unsafe.Pointer) (re uintptr) { @@ -104,14 +157,14 @@ var ( return ole.E_NOINTERFACE } *(**INotificationActivationCallback)(out) = this - this.AddRef() - return uintptr(ole.S_OK) + return ole.S_OK }) + // Activate is our re-entrance into Go from Windows. This is the magic. INotificationActivationCallback_Activate = syscall.NewCallback(func( - this, - appUserModelId, - invokedArgs, + this unsafe.Pointer, + appUserModelId unsafe.Pointer, + invokedArgs unsafe.Pointer, data unsafe.Pointer, count uint32, ) (ret uintptr) { @@ -123,54 +176,3 @@ var ( return }) ) - -// newClassFactory allocates our ClassFactory that can build our NotificationActivationCallback object. -func newClassFactory() *IClassFactory { - - // Allocate the object and its vtable. - - v := (*IClassFactory)(malloc(unsafe.Sizeof(IClassFactory{}))) - if v == nil { - return nil - } - - v.lpVtbl = (*IClassFactoryVtbl)(malloc(unsafe.Sizeof(IClassFactoryVtbl{}))) - if v.lpVtbl == nil { - return nil - } - - // Provide function implementations in the Vtable. - - v.lpVtbl.AddRef = IClassFactory_AddRef - v.lpVtbl.Release = IClassFactory_Release - v.lpVtbl.QueryInterface = IClassFactory_QueryInterface - v.lpVtbl.LockServer = IClassFactory_LockServer - v.lpVtbl.CreateInstance = IClassFactory_CreateInstance - - return v -} - -// newNotificationActivationCallback allocates our implementation of the INotificationActivationCallback. -func newNotificationActivationCallback() *INotificationActivationCallback { - - // Allocate the object and its vtable. - - v := (*INotificationActivationCallback)(malloc(unsafe.Sizeof(INotificationActivationCallback{}))) - if v == nil { - return nil - } - - v.lpVtbl = (*INotificationActivationCallbackVtbl)(malloc(unsafe.Sizeof(INotificationActivationCallbackVtbl{}))) - if v.lpVtbl == nil { - return nil - } - - // Provide function implementations in the vtable. - - v.lpVtbl.AddRef = INotificationActivationCallback_AddRef - v.lpVtbl.Release = INotificationActivationCallback_Release - v.lpVtbl.QueryInterface = INotificationActivationCallback_QueryInterface - v.lpVtbl.Activate = INotificationActivationCallback_Activate - - return v -} diff --git a/wintoast/interfaces.go b/wintoast/interfaces.go @@ -1,244 +0,0 @@ -//go:build windows - -// This file contains the various COM interfaces we need to call. -// Only the methods we need to call have wrappers. All methods -// that don't have a corresponding Go wrapper method are marked -// as such. -// -// The definitions are derived from: -// - <combase.h> -// - <windows.ui.notifications.h -// - <NotificationActivationCallback.h> -package wintoast - -import ( - "fmt" - "syscall" - "unsafe" - - "github.com/go-ole/go-ole" -) - -// Runtime class names. These correspond to normal COM GUIDs, however the Windows Runtime likes -// to use string identifiers and derives the GUIDs under the hood. -const ( - CLSID_ToastNotificationManager = "Windows.UI.Notifications.ToastNotificationManager" - CLSID_ToastNotification = "Windows.UI.Notifications.ToastNotification" - CLSID_XMLDocument = "Windows.Data.Xml.Dom.XmlDocument" -) - -// Interface GUIDS. These GUIDS are predefined by the Windows Runtime, identifying the various -// interfaces we want to make use of. -var ( - IID_IClassFactory = ole.NewGUID("{00000001-0000-0000-C000-000000000046}") - IID_INotificationActivationCallback = ole.NewGUID("{53E31837-6600-4A81-9395-75CFFE746F94}") - IID_ToastNotificationManager = ole.NewGUID("{50AC103F-D235-4598-BBEF-98FE4D1A3AD4}") - IID_ToastNotificationFactory = ole.NewGUID("{04124B20-82C6-4229-B109-FD9ED4662B53}") - IID_IXmlDocument = ole.NewGUID("{F7F3A506-1E87-42D6-BCFB-B8C809FA5494}") - IID_IXmlDocumentIO = ole.NewGUID("{6CD0E74E-EE65-4489-9EBF-CA43E87BA637}") -) - -// This default GUID is for our implementation. -// This was generated and should not collide with any other GUID. -var GUID_ImplNotificationActivationCallback = ole.NewGUID("{0F82E845-CB89-4039-BDBF-67CA33254C76}") - -// HRESULT represents a COM return value. -type HRESULT uintptr - -// IToastNotification represents a single toast notification instance. -type IToastNotification struct { - lpVtbl *IToastNotificationVtbl -} - -type IToastNotificationVtbl struct { - ole.IInspectableVtbl -} - -// IToastNotificationManager can create toast notifier objects. -type IToastNotificationManager struct { - lpVtbl *IToastNotificationManagerVtbl - - GetContent uintptr // not wrapped - PutExpirationTime uintptr // not wrapped - GetExpirationTime uintptr // not wrapped - AddDismissed uintptr // not wrapped - RemoveDismissed uintptr // not wrapped - AddActivated uintptr // not wrapped - RemoveActivated uintptr // not wrapped - AddFailed uintptr // not wrapped - RemoveFailed uintptr // not wrapped -} - -type IToastNotificationManagerVtbl struct { - ole.IInspectableVtbl - - CreateToastNotifier uintptr // not wrapped - CreateToastNotifierWithID uintptr - GetTemplateContent uintptr // not wrapped -} - -func (v *IToastNotificationManager) CreateToastNotifierWithID(appID string) (ret *IToastNotifier, err error) { - hsAppID, err := ole.NewHString(appID) - if err != nil { - return nil, fmt.Errorf("allocating string: %w", err) - } - defer ole.DeleteHString(hsAppID) - hr, _, _ := syscall.SyscallN( - v.lpVtbl.CreateToastNotifierWithID, - uintptr(unsafe.Pointer(v)), - uintptr(hsAppID), - uintptr(unsafe.Pointer(&ret)), - ) - if hr != ole.S_OK { - return nil, ole.NewError(hr) - } - return ret, nil -} - -// IToastNotifier can push notification objects to the runtime. -type IToastNotifier struct { - lpVtbl *IToastNotifierVtbl -} - -type IToastNotifierVtbl struct { - ole.IInspectableVtbl - - Show uintptr - Hide uintptr // not wrapped - GetSetting uintptr // not wrapped - AddToSchedule uintptr // not wrapped - RemoveFromSchedule uintptr // not wrapped - GetScheduledToastNotifications uintptr // not wrapped -} - -func (v *IToastNotifier) Show(noti *IToastNotification) (err error) { - hr, _, _ := syscall.SyscallN( - v.lpVtbl.Show, - uintptr(unsafe.Pointer(v)), - uintptr(unsafe.Pointer(noti)), - ) - if hr != ole.S_OK { - return ole.NewError(hr) - } - return nil -} - -// IToastNotificationFactory can create toast notification objects. -type IToastNotificationFactory struct { - lpVtbl *IToastNotificationFactoryVtbl -} - -type IToastNotificationFactoryVtbl struct { - ole.IInspectableVtbl - - CreateToastNotification uintptr -} - -func (v *IToastNotificationFactory) CreateToastNotification(xmlDispatch *ole.IDispatch) (ret *IToastNotification, err error) { - hr, _, _ := syscall.SyscallN( - v.lpVtbl.CreateToastNotification, - uintptr(unsafe.Pointer(v)), - uintptr(unsafe.Pointer(xmlDispatch)), - uintptr(unsafe.Pointer(&ret)), - ) - if hr != ole.S_OK { - return nil, ole.NewError(hr) - } - return ret, nil -} - -// IXMLDocumentIO implements IO for XML documents. -type IXMLDocumentIO struct { - lpVtbl *IXMLDocumentIOVtbl -} - -type IXMLDocumentIOVtbl struct { - ole.IInspectableVtbl - - LoadXml uintptr - LoadXmlWithSettings uintptr // not wrapped - SaveToFileAsync uintptr // not wrapped -} - -func (v *IXMLDocumentIO) LoadXml(xml string) (err error) { - hsXML, err := ole.NewHString(xml) - if err != nil { - return err - } - defer ole.DeleteHString(hsXML) - hr, _, _ := syscall.SyscallN( - v.lpVtbl.LoadXml, - uintptr(unsafe.Pointer(v)), - uintptr(hsXML), - ) - if hr != ole.S_OK { - return ole.NewError(hr) - } - return nil -} - -// IClassFactory is used to build other classes. We will use this to build our implementation -// of the INotificationActivationCallback interface. -type IClassFactory struct { - lpVtbl *IClassFactoryVtbl - RefCount int64 -} - -type IClassFactoryVtbl struct { - ole.IUnknownVtbl - CreateInstance uintptr // not wrapped - LockServer uintptr // not wrapped -} - -func (v *IClassFactory) AddRef() int32 { - count, _, _ := syscall.SyscallN( - v.lpVtbl.AddRef, - uintptr(unsafe.Pointer(v)), - ) - return int32(count) -} - -func (v *IClassFactory) Release() int32 { - count, _, _ := syscall.SyscallN( - v.lpVtbl.Release, - uintptr(unsafe.Pointer(v)), - ) - return int32(count) -} - -// INotificationActivationCallback receives activations from toast notifications. -type INotificationActivationCallback struct { - lpVtbl *INotificationActivationCallbackVtbl - RefCount int64 -} - -type INotificationActivationCallbackVtbl struct { - ole.IUnknownVtbl - Activate uintptr -} - -func (v *INotificationActivationCallback) QueryInterface(riid *ole.GUID, out unsafe.Pointer) HRESULT { - ret, _, _ := syscall.SyscallN( - v.lpVtbl.QueryInterface, - uintptr(unsafe.Pointer(v)), - uintptr(unsafe.Pointer(riid)), - uintptr(out), - ) - return HRESULT(ret) -} - -func (v *INotificationActivationCallback) AddRef() int32 { - count, _, _ := syscall.SyscallN( - v.lpVtbl.AddRef, - uintptr(unsafe.Pointer(v)), - ) - return int32(count) -} - -func (v *INotificationActivationCallback) Release() int32 { - count, _, _ := syscall.SyscallN( - v.lpVtbl.Release, - uintptr(unsafe.Pointer(v)), - ) - return int32(count) -} diff --git a/wintoast/procs.go b/wintoast/procs.go @@ -10,42 +10,12 @@ import ( ) var ( - // Define memory allocation procs. This is how we get a hold of unmanaged memory. - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - procMalloc = kernel32.NewProc("GlobalAlloc") - procFree = kernel32.NewProc("GlobalFree") - // Define procs that go-ole doesn't provide. This is how we register our Go-implemented // COM objects. modcombase = windows.NewLazySystemDLL("combase.dll") procRegisterClassObject = modcombase.NewProc("CoRegisterClassObject") ) -// Allocation flags we need. -// There are more, these are just the ones we use. -// See https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc -const ( - GMEM_FIXED = 0x0000 - GMEM_ZEROINIT = 0x0040 -) - -// malloc allocates raw memory using the Windows kernel. -// In case of out of memory, the returned pointer will be nil. -// The memory is zeroed out to make sure we don't get garbage that looks like -// valid Go data types. -func malloc(size uintptr) unsafe.Pointer { - hr, _, _ := procMalloc.Call(uintptr(GMEM_FIXED|GMEM_ZEROINIT), uintptr(size)) - if hr == 0 { - return nil - } - return unsafe.Pointer(hr) -} - -// free deallocates raw memory allocated by malloc. -func free(object unsafe.Pointer) { - procFree.Call(uintptr(object)) -} - // registerClassFactory teaches the Windows Runtime about our factory that can allocate // instances of our ActivationCallback. func registerClassFactory(factory *IClassFactory) error {