go-toast

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

commit 1601fbeb01b6618a331c9acd40ef9e5541e3bd8d
parent 82e616a4b672e5265c685aec155f1fe590aa2fdf
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Thu, 16 Mar 2023 17:02:50 +0800

bind: pure-Go implementation

This commit overhauls the entire internals to use pure-Go
code to orchestrate the Windows Runtime and implement the COM
objects necessary for notification callbacks.

This code does "exactly" what the C code did, but the procedures
are fully implemented in Go, via the help of syscall.NewCallback.

This means we no longer need a C compiler, the Windows C compiler
or nor do we need to distribute a Dll blob to the user.

I've tried to thoroughly document and explain everything so that
no one else has to do the amount of research and tinkering that
I had to.

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

Diffstat:
Minternal/bind/bind.go | 182++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Ainternal/bind/impl.go | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/bind/interfaces.go | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/bind/objects.go | 147-------------------------------------------------------------------------------
Dinternal/bind/proc.go | 60------------------------------------------------------------
Ainternal/bind/procs.go | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/bind/registry.go | 4++--
Dinternal/c-lib/Makefile | 14--------------
Dinternal/c-lib/build.sh | 80-------------------------------------------------------------------------------
Dinternal/c-lib/toast.c | 270-------------------------------------------------------------------------------
Dtoast.dll | 0
Mtoast.go | 8+++++---
12 files changed, 582 insertions(+), 662 deletions(-)

diff --git a/internal/bind/bind.go b/internal/bind/bind.go @@ -1,28 +1,36 @@ -// Package bind wraps the raw DLL functions in safe Go. +// Package bind provides a pure-Go implementation of toast notifications on Windows. package bind import ( "fmt" "sync" - "syscall" "unicode/utf16" "unsafe" "github.com/go-ole/go-ole" ) -// This GUID matches the one defined in the C code. This coincidentally matches that -// value because I don't want to import C when we don't need to. -// -// We could keep them more in sync by exporting a DLL function that returns a copy of the string. +// UserData contains Key:Value pairs generated within the notification, based +// on the XML content of the notification. Specifically, all inputs within +// the XML will generate a corresponding UserData struct. +type UserData struct { + Key string + Value string +} + +// ActivationCallback is a function that gets invoked when the toast is activated. +type ActivationCallback func(appUserModelId string, invokedArgs string, userData []UserData) + +// callback is the global callback reference that is invoked by Activate. // -// It's also possible that we could dynamically generate a new GUID per app run, or derive -// it from the app ID. -const iNotificationActivationCallbackGUID = "{0F82E845-CB89-4039-BDBF-67CA33254C76}" +// NOTE(jfm): synchronize access to this? +var callback ActivationCallback = func(model, args string, data []UserData) {} -// IsDLLAvailable returns true if the DLL can be loaded. -func IsDLLAvailable() bool { - return toast.Load() == nil +// SetActivationCallback establishes the callback `cb` to be invoked when +// the toast notification is activated. This callback instance should handle +// being activated from any available toast notification. +func SetActivationCallback(cb ActivationCallback) { + callback = cb } // GenerateToast notification via the specified xml content. @@ -34,9 +42,9 @@ func GenerateToast(appID, xml string) error { return err } - // 1. Allocate ClassFactory implementation. - // 2. register callback implementation. - // 3. load noti statics + // 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 @@ -47,55 +55,19 @@ func GenerateToast(appID, xml string) error { if classFactory == nil { return fmt.Errorf("could not allocate class factory") } - defer free(classFactory) if err := registerClassFactory(classFactory); err != nil { return fmt.Errorf("registering class factory: %w", err) } - inspectable, err := ole.RoGetActivationFactory(CLSID_ToastNotificationManager, IID_ToastNotificationManagerStatics) - if err != nil { - return fmt.Errorf("getting activation factory: %w", err) - } - - itns := (*IToastNotificationManager)(unsafe.Pointer(inspectable)) - - notifier, err := itns.CreateToastNotifierWithID(appID) + noti, err := newNotiFromXml(xml) if err != nil { - return fmt.Errorf("creating toast notifier: %w", err) + return fmt.Errorf("building notification: %w", err) } - inspectable, err = ole.RoGetActivationFactory(CLSID_ToastNotification, IID_ToastNotificationFactory) + notifier, err := newNotifier(appID) if err != nil { - return fmt.Errorf("getting activation factory: %w", err) - } - - factory := (*IToastNotificationFactory)(unsafe.Pointer(inspectable)) - - inspectable, err = ole.RoActivateInstance(CLSID_XMLDocument) - if err != nil { - return err - } - - xmlDispatch, err := inspectable.QueryInterface(IID_IXmlDocument) - if err != nil { - return err - } - - xmlIODispatch, err := xmlDispatch.QueryInterface(IID_IXmlDocumentIO) - if err != nil { - return err - } - - xmlIO := (*IXMLDocumentIO)(unsafe.Pointer(xmlIODispatch)) - - if err := xmlIO.LoadXml(xml); err != nil { - return err - } - - noti, err := factory.CreateToastNotification(xmlDispatch) - if err != nil { - return fmt.Errorf("creating toast notification: %w", err) + return fmt.Errorf("building notifier: %w", err) } if err := notifier.Show(noti); err != nil { @@ -105,37 +77,6 @@ func GenerateToast(appID, xml string) error { return nil } -// UserData contains Key:Value pairs generated within the notification, based -// on the XML content of the notification. -type UserData struct { - Key string - Value string -} - -// ActivationCallback is a function that gets invoked when the toast is activated. -type ActivationCallback func(appUserModelId string, invokedArgs string, userData []UserData) - -// SetActivationCallback establishes the callback `cb` to be invoked when -// the toast notification is activated. This callback instance should handle -// being activated from any available toast notification. -func SetActivationCallback(cb ActivationCallback) { - callback := syscall.NewCallback(func( - this, - appUserModelId, - invokedArgs, - data unsafe.Pointer, - count uint32, - ) (ret uintptr) { - cb( - utf16PtrToString((*uint16)(appUserModelId)), - utf16PtrToString((*uint16)(invokedArgs)), - sliceUserDataFromUnsafe(data, int(count)), - ) - return - }) - procSetActivationCallback.Call(callback) -} - var initLock sync.Mutex var didInitialize bool @@ -160,6 +101,75 @@ 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 { diff --git a/internal/bind/impl.go b/internal/bind/impl.go @@ -0,0 +1,174 @@ +// This file contains our pure-Go implementations of two COM objects that we need +// to render toast notifications: IClassFactory and INotificationActivationCallback. +// +// More specifically we allocate the C callable functions that can be used to populate +// the vtable at runtime. +// +// Unfortunately these functions have to be declared as var not const because the callbacks +// 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. +package bind + +import ( + "sync" + "syscall" + "unsafe" + + "github.com/go-ole/go-ole" +) + +// 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 + + IClassFactory_AddRef = syscall.NewCallback(func(this *IClassFactory) (re uintptr) { + factoryLock.Lock() + defer factoryLock.Unlock() + this.RefCount += 1 + return uintptr(this.RefCount) + }) + + 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) + }) + + IClassFactory_QueryInterface = syscall.NewCallback(func(this *IClassFactory, riid *ole.GUID, out unsafe.Pointer) (re uintptr) { + if !ole.IsEqualGUID(riid, IID_IClassFactory) && + !ole.IsEqualGUID(riid, ole.IID_IUnknown) { + return ole.E_NOINTERFACE + } + *(**IClassFactory)(out) = this + this.AddRef() + return uintptr(ole.S_OK) + }) + + IClassFactory_LockServer = syscall.NewCallback(func(this *IClassFactory, flock uintptr) (ret uintptr) { + return ole.S_OK + }) + + IClassFactory_CreateInstance = syscall.NewCallback(func(this *IClassFactory, punkOuter *ole.IUnknown, riid *ole.GUID, out unsafe.Pointer) (re uintptr) { + if punkOuter != nil { + // Should be CLASS_E_NOAGGREGATION but ole doesn't define this. + return ole.E_NOINTERFACE + } + object := newNotificationActivationCallback() + if object == nil { + return ole.E_OUTOFMEMORY + } + object.RefCount = 1 + hr := object.QueryInterface(riid, out) + object.Release() + return uintptr(hr) + }) +) + +// 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 + + INotificationActivationCallback_AddRef = syscall.NewCallback(func(this *INotificationActivationCallback) (re uintptr) { + callbackLock.Lock() + defer callbackLock.Unlock() + this.RefCount += 1 + return uintptr(this.RefCount) + }) + + 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) + }) + + INotificationActivationCallback_QueryInterface = syscall.NewCallback(func(this *INotificationActivationCallback, riid *ole.GUID, out unsafe.Pointer) (re uintptr) { + if !ole.IsEqualGUID(riid, IID_INotificationActivationCallback) && + !ole.IsEqualGUID(riid, ole.IID_IUnknown) { + return ole.E_NOINTERFACE + } + *(**INotificationActivationCallback)(out) = this + this.AddRef() + return uintptr(ole.S_OK) + }) + + INotificationActivationCallback_Activate = syscall.NewCallback(func( + this, + appUserModelId, + invokedArgs, + data unsafe.Pointer, + count uint32, + ) (ret uintptr) { + callback( + utf16PtrToString((*uint16)(appUserModelId)), + utf16PtrToString((*uint16)(invokedArgs)), + sliceUserDataFromUnsafe(data, int(count)), + ) + 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/internal/bind/interfaces.go b/internal/bind/interfaces.go @@ -0,0 +1,243 @@ +// 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 bind + +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 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 +} + +// IToastNotificationVtbl is empty because we don't care, as of now, about calling its methods. +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/internal/bind/objects.go b/internal/bind/objects.go @@ -1,147 +0,0 @@ -// This file contains the implementations of the various COM objects we need to call. -// THESE TYPES ARE NOT COMPLETE. Only the behaviour that is needed is implemented. -// The definitions are derived from `<windows.ui.notifications.h>`. -package bind - -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. -var ( - 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_ToastNotificationManagerStatics = 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}") -) - -type IToastNotification struct { - lpVtbl *IToastNotificationVtbl -} - -// IToastNotificationVtbl is empty because we don't care, as of now, about calling its methods. -type IToastNotificationVtbl struct { - ole.IInspectableVtbl -} - -type IToastNotificationManager struct { - lpVtbl *IToastNotificationManagerVtbl -} - -type IToastNotificationManagerVtbl struct { - ole.IInspectableVtbl - - CreateToastNotifier uintptr - CreateToastNotifierWithID uintptr - GetTemplateContent uintptr -} - -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 -} - -type IToastNotifier struct { - lpVtbl *IToastNotifierVtbl -} - -type IToastNotifierVtbl struct { - ole.IInspectableVtbl - - Show uintptr - Hide uintptr - GetSetting uintptr - AddToSchedule uintptr - RemoveFromSchedule uintptr - GetScheduledToastNotifications uintptr -} - -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 -} - -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 -} - -type IXMLDocumentIO struct { - lpVtbl *IXMLDocumentIOVtbl -} - -type IXMLDocumentIOVtbl struct { - ole.IInspectableVtbl - - LoadXml uintptr - LoadXmlWithSettings uintptr - SaveToFileAsync uintptr -} - -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 -} diff --git a/internal/bind/proc.go b/internal/bind/proc.go @@ -1,60 +0,0 @@ -package bind - -import ( - "unsafe" - - "github.com/go-ole/go-ole" - "golang.org/x/sys/windows" -) - -var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - procMalloc = kernel32.NewProc("GlobalAlloc") - procFree = kernel32.NewProc("GlobalFree") - - modcombase = windows.NewLazySystemDLL("combase.dll") - procRegisterClassObject = modcombase.NewProc("RoRegisterClassObject") - - toast = windows.NewLazyDLL("toast.dll") - procSetActivationCallback = toast.NewProc("SetActivationCallback") - procNewClassFactory = toast.NewProc("NewClassFactory") - procRegisterClassFactory = toast.NewProc("RegisterClassFactory") -) - -// malloc allocates raw memory. -func malloc(size int) unsafe.Pointer { - hr, _, _ := procMalloc.Call(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)) -} - -// registerClassObject associates an in memory object with a GUID at runtime. -func registerClassObject(clsid *ole.GUID, factory unsafe.Pointer) error { - hr, _, _ := procRegisterClassObject.Call(uintptr(unsafe.Pointer(clsid)), uintptr(factory)) - if hr != ole.S_OK { - return ole.NewError(hr) - } - return nil -} - -// newClassFactory allocates our ClassFactory that can build our NotificationActivationCallback object. -// Deallocate with free. -func newClassFactory() unsafe.Pointer { - object, _, _ := procNewClassFactory.Call() - return unsafe.Pointer(object) -} - -func registerClassFactory(factory unsafe.Pointer) error { - hr, _, _ := procRegisterClassFactory.Call(uintptr(factory)) - if hr != ole.S_OK { - return ole.NewError(hr) - } - return nil -} diff --git a/internal/bind/procs.go b/internal/bind/procs.go @@ -0,0 +1,62 @@ +package bind + +import ( + "unsafe" + + "github.com/go-ole/go-ole" + "golang.org/x/sys/windows" +) + +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 { + var cookie int64 + hr, _, _ := procRegisterClassObject.Call( + uintptr(unsafe.Pointer(GUID_ImplNotificationActivationCallback)), + uintptr(unsafe.Pointer(factory)), + uintptr(ole.CLSCTX_LOCAL_SERVER), + uintptr(1), /* REGCLS_MULTIPLEUSE */ + uintptr(unsafe.Pointer(&cookie)), + ) + if hr != ole.S_OK { + return ole.NewError(hr) + } + return nil +} diff --git a/internal/bind/registry.go b/internal/bind/registry.go @@ -41,7 +41,7 @@ func SetAppData(data AppData) error { } // CustomActivator teaches Window what COM class to use as the callback when // a toast notification is activated. - if err := appIDKey.SetStringValue("CustomActivator", iNotificationActivationCallbackGUID); err != nil { + if err := appIDKey.SetStringValue("CustomActivator", GUID_ImplNotificationActivationCallback.String()); err != nil { return fmt.Errorf("setting CustomActivator: %w", err) } if data.IconPath != "" { @@ -66,7 +66,7 @@ const registryDefaultKey string = "" // setActivationExecutable registers the given executable path with the CLSID. // Windows will invoke this executable for cold starts, eg when the application is not running. func setActivationExecutable(exe string) error { - clsidKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "CLSID", iNotificationActivationCallbackGUID, "LocalServer32"), registry.SET_VALUE) + clsidKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "CLSID", GUID_ImplNotificationActivationCallback.String(), "LocalServer32"), registry.SET_VALUE) if err != nil { return fmt.Errorf("setting the exe path for LocalServer reponse: %w", err) } diff --git a/internal/c-lib/Makefile b/internal/c-lib/Makefile @@ -1,14 +0,0 @@ -.PHONY: clean -.PHONY: gosh - -toast.dll: toast.c - gosh build.sh - -gosh: - # Grab the gosh shell interpreter. Requires a Go toolchain. - go install github.com/mvdan/sh/cmd/gosh@latest - -clean: - rm *.exe ; rm *.dll ; rm *.exp ; rm *.lib ; rm *.obj - - diff --git a/internal/c-lib/build.sh b/internal/c-lib/build.sh @@ -1,79 +0,0 @@ -# This build script captures the method used to build the MSVC compiled DLL. -# -# Make sure you have installed the Widows C++ build tools using the Visual -# Studio Installer (https://visualstudio.microsoft.com/downloads). Activate -# the "Desktop development with C++" component and check "MSVC C++ build tools". -# -# The latest version at the time of this script (10/03/2023) is MSVC v143. -# -# Finally, make sure you install the Windows SDK. This script is specialized -# to version 10.0.22000.0, however that is probably an unnecessary constraint. -# -# After installation make sure you add the MSVC binaries to your system path. -# In this case, that path is: -# -# "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.35.32215\bin\Hostx64\x64" -# -# Keep in mind the exact path will be determined by which version of the build -# you have installed. -# -# On Windows, this script can be invoked via https://github.com/mvdan/sh. -# -# go install github.com/mvdan/sh/cmd/gosh@latest && gosh build.sh -# -# I'm not an expert in Windows build systems by any means, so there might be -# more correct ways to do things in this script. -# -# This script could probably be parameterized by SDK version, OS version, -# build tools version and CPU architecture. -# -# It may also be possible to extract the .lib files and bundle them directly -# to avoid differences between versions of the various components. - -# Host Details -DRIVE="C:" -WINDOWS_VERSION="10" -SDK_VERSION="10.0.22000.0" -BUILD_TOOLS_VERSION="14.35.32215" -ARCH="x64" - -# Windows SDK -SDK_ROOT="$DRIVE\\Program Files (x86)\\Windows Kits\\$WINDOWS_VERSION" -SDK_LIB="$SDK_ROOT\\Lib\\$SDK_VERSION" -SDK_INCLUDE="$SDK_ROOT\\Include\\$SDK_VERSION" - -# MSVC Build Tools -BUILD_TOOLS_ROOT="$DRIVE\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\MSVC\\$BUILD_TOOLS_VERSION" -BUILD_TOOLS_LIB="$BUILD_TOOLS_ROOT\\lib" -BUILD_TOOLS_INCLUDE="$BUILD_TOOLS_ROOT\\include" - -# Make this script invocable from parent directories by resolving -# the source against the script path. -SOURCE="$0/../toast.c" - -# `cl` must be available on system path. -cl.exe \ - /I "$SDK_INCLUDE\\um" \ - /I "$SDK_INCLUDE\\ucrt" \ - /I "$SDK_INCLUDE\\winrt" \ - /I "$SDK_INCLUDE\\shared" \ - /I "$BUILD_TOOLS_INCLUDE" \ - /LD $SOURCE \ - /link \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\Uuid.Lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\Ole32.lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\oleaut32.lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\Advapi32.lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\User32.lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\kernel32.lib" \ - /LIBPATH "$SDK_LIB\\um\\$ARCH\\runtimeobject.lib" \ - /LIBPATH "$SDK_LIB\\ucrt\\$ARCH\\libucrt.lib" \ - /LIBPATH "$BUILD_TOOLS_LIB\\$ARCH\\libcmt.lib" \ - /LIBPATH "$BUILD_TOOLS_LIB\\$ARCH\\oldnames.lib" \ - /LIBPATH "$BUILD_TOOLS_LIB\\$ARCH\\libvcruntime.lib" - -# These artefacts are noisy and not needed for our purposes; so we -# can clean them up. -rm toast.exp -rm toast.lib -rm toast.obj -\ No newline at end of file diff --git a/internal/c-lib/toast.c b/internal/c-lib/toast.c @@ -1,269 +0,0 @@ -#include <Windows.h> -#include <Windows.ui.notifications.h> -#include <notificationactivationcallback.h> -#include <initguid.h> -#include <roapi.h> -#include <tchar.h> -#include <stdio.h> -#include <string.h> - -#pragma comment(lib, "runtimeobject.lib") -#pragma comment(lib, "ole32.lib") -#pragma comment(lib, "oleaut32.lib") -#pragma comment(lib, "Advapi32.lib") -#pragma comment(lib, "User32.lib") - -DWORD dwMainThreadId = 0; - -/* - * The GUID that we associate with our factory that produces our INotificationActivationCallback interface. - */ -#define GUID_Impl_INotificationActivationCallback_Textual "0F82E845-CB89-4039-BDBF-67CA33254C76" -DEFINE_GUID(GUID_Impl_INotificationActivationCallback, - 0xf82e845, 0xcb89, 0x4039, 0xbd, 0xbf, 0x67, 0xca, 0x33, 0x25, 0x4c, 0x76); - -/* - * All the objects we allocate in this example (our class factory and our INotificationActivationCallback - * implementation) have this memory layout, and all inherit from IUnknown. - */ -typedef struct Impl_IGeneric -{ - IUnknownVtbl* lpVtbl; - LONG64 dwRefCount; -} Impl_IGeneric; - -static -ULONG -STDMETHODCALLTYPE -Impl_IGeneric_AddRef(Impl_IGeneric* _this) -{ - return InterlockedIncrement64(&(_this->dwRefCount)); -} - -static -ULONG -STDMETHODCALLTYPE -Impl_IGeneric_Release(Impl_IGeneric* _this) -{ - LONG64 dwNewRefCount = InterlockedDecrement64(&(_this->dwRefCount)); - if (!dwNewRefCount) GlobalFree(_this); - return dwNewRefCount; -} - -/* - * Our INotificationActivationCallback implementation. - */ -static -HRESULT -STDMETHODCALLTYPE -Impl_INotificationActivationCallback_QueryInterface( - Impl_IGeneric* _this, - REFIID riid, - void** ppvObject -) { - if (!IsEqualIID(riid, &IID_INotificationActivationCallback) && !IsEqualIID(riid, &IID_IUnknown)) - { - *ppvObject = NULL; - return E_NOINTERFACE; - } - *ppvObject = _this; - _this->lpVtbl->AddRef(_this); - return S_OK; -} - -// ActivationCallback defines the function that will receive the activation data -// when the toast notification is activated (clicked). -typedef void (__stdcall *ActivationCallback)( - INotificationActivationCallback* _this, - LPCWSTR appUserModelId, - LPCWSTR invokedArgs, - const NOTIFICATION_USER_INPUT_DATA* data, - ULONG count -); - -ActivationCallback _activation_callback; - -/* - * This is where the magic happens when someone interacts with our notification: this method will be called - * (on another thread !!!). - */ -static -HRESULT -STDMETHODCALLTYPE -Impl_INotificationActivationCallback_Activate( - INotificationActivationCallback* _this, - LPCWSTR appUserModelId, - LPCWSTR invokedArgs, - const NOTIFICATION_USER_INPUT_DATA* data, - ULONG count -) { - if (_activation_callback) { - _activation_callback(_this, appUserModelId, invokedArgs, data, count); - } - return S_OK; -} - -static INotificationActivationCallbackVtbl Impl_INotificationActivationCallback_Vtbl = { - .QueryInterface = Impl_INotificationActivationCallback_QueryInterface, - .AddRef = Impl_IGeneric_AddRef, - .Release = Impl_IGeneric_Release, - .Activate = Impl_INotificationActivationCallback_Activate -}; - -/* - * Our IClassFactory implementation. - */ -static -HRESULT -STDMETHODCALLTYPE -Impl_IClassFactory_QueryInterface( - Impl_IGeneric* _this, - REFIID riid, - void** ppvObject -) { - if (!IsEqualIID(riid, &IID_IClassFactory) && !IsEqualIID(riid, &IID_IUnknown)) - { - *ppvObject = NULL; - return E_NOINTERFACE; - } - *ppvObject = _this; - _this->lpVtbl->AddRef(_this); - return S_OK; -} - -static HRESULT STDMETHODCALLTYPE Impl_IClassFactory_LockServer(IClassFactory* _this, BOOL flock) -{ - return S_OK; -} - -static -HRESULT -STDMETHODCALLTYPE -Impl_IClassFactory_CreateInstance( - IClassFactory* _this, - IUnknown* punkOuter, - REFIID vTableGuid, - void** ppv -) { - HRESULT hr = E_NOINTERFACE; - Impl_IGeneric* thisobj = NULL; - *ppv = 0; - - if (punkOuter) hr = CLASS_E_NOAGGREGATION; - else - { - BOOL bOk = FALSE; - if (!(thisobj = GlobalAlloc(GMEM_FIXED|GMEM_ZEROINIT, sizeof(Impl_IGeneric)))) hr = E_OUTOFMEMORY; - else - { - thisobj->lpVtbl = &Impl_INotificationActivationCallback_Vtbl; - bOk = TRUE; - } - if (bOk) - { - thisobj->dwRefCount = 1; - hr = thisobj->lpVtbl->QueryInterface(thisobj, vTableGuid, ppv); - thisobj->lpVtbl->Release(thisobj); - } - else - { - return hr; - } - } - - return hr; -} - -static IClassFactoryVtbl Impl_IClassFactory_Vtbl = { - .QueryInterface = Impl_IClassFactory_QueryInterface, - .AddRef = Impl_IGeneric_AddRef, - .Release = Impl_IGeneric_Release, - .LockServer = Impl_IClassFactory_LockServer, - .CreateInstance = Impl_IClassFactory_CreateInstance -}; - -// Must checks the HRESULT value and jumps to cleanup not S_OK. -// -// This macro assumes the variable `HRESULT hr;` is in scope so -// it can set the failed value before returning. -#define Must(hresult) \ - if (FAILED(hresult)) { \ - hr = hresult; \ - goto cleanup; \ - } - -// Release invokes Release on the vtable of the supplied COM object. -#define Release(ident) \ - if (ident != NULL) { \ - ident->lpVtbl->Release(ident); \ - } - -// DeleteString invokes DeleteString on the provided HSTRING. -#define DeleteString(ident) \ - if (ident) { \ - WindowsDeleteString(ident); \ - } - -// Init exports RoInitialize. For go-ole we can use the ole provided function instead. -// This allows the consumer to handle initialization without relying on a third-party. -__declspec(dllexport) -HRESULT Init(RO_INIT_TYPE roInit) { - return RoInitialize(roInit); -} - -// Deinit exports RoUninitialize. For go-ole we can use the ole provided function instead. -// This allows the consumer to handle uninitialization without relying on a third-party. -__declspec(dllexport) -void Deinit() { - RoUninitialize(); -} - -// Malloc exports memory allocation. This allows the caller to get C memory without importing C. -__declspec(dllexport) -void* -Malloc(size_t size) { - return GlobalAlloc(GMEM_FIXED|GMEM_ZEROINIT, size); -} - -// Free exports memory deallocation. This allows the caller to free C memory without importing C. -__declspec(dllexport) -void -Free(void* object) { - GlobalFree(object); -} - -// NewClassFactory allocates a ClassFactory. -// If the pointer is NULL we are out of memory. -__declspec(dllexport) -Impl_IGeneric* -NewClassFactory() { - Impl_IGeneric* pClassFactory; - pClassFactory = GlobalAlloc(GMEM_FIXED|GMEM_ZEROINIT, sizeof(Impl_IGeneric)); - if (pClassFactory == NULL) { - return NULL; - } - pClassFactory->lpVtbl = &Impl_IClassFactory_Vtbl; - pClassFactory->dwRefCount = 1; - return pClassFactory; -} - -// RegisterClassFactory associates the object that can build our INotificationActivationCallback -// with that interface so the runtime can build our objects. -__declspec(dllexport) -HRESULT -RegisterClassFactory(Impl_IGeneric* pClassFactory) { - DWORD dwCookie = 0; - return CoRegisterClassObject( - &GUID_Impl_INotificationActivationCallback, - pClassFactory, - CLSCTX_LOCAL_SERVER, - REGCLS_MULTIPLEUSE, - &dwCookie - ); -} - -// SetActivationCallback to use when the notification -__declspec(dllexport) -void SetActivationCallback(ActivationCallback cb) { - _activation_callback = cb; -} -\ No newline at end of file diff --git a/toast.dll b/toast.dll Binary files differ. diff --git a/toast.go b/toast.go @@ -165,10 +165,12 @@ func (n *Notification) Push() error { }); err != nil { return fmt.Errorf("configuring registry: %w", err) } - if bind.IsDLLAvailable() { - return n.pushCOM(xml) + if err := n.pushCOM(xml); err != nil { + // If COM api fails attempt the powershell fallback and + // report the error. + return errors.Join(err, n.pushPowershell(xml)) } - return n.pushPowershell(xml) + return nil } func (n *Notification) applyDefaults() {