go-toast

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

commit 628a5756a2c0021a01faeb9102eadffc039cabf8
parent 4c3afc4582daa815adace1595f6e90f14a15af37
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 22 Mar 2023 12:10:44 +0800

all: export low level windows api

Rename package bind to wintoast, and export it so that
consumers can use it directly if desired - rather than
the higher level wrapper package.

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

Diffstat:
Dinternal/bind/bind.go | 84-------------------------------------------------------------------------------
Dinternal/bind/bind_noop.go | 20--------------------
Dinternal/bind/bind_test.go | 149-------------------------------------------------------------------------------
Dinternal/bind/bind_windows.go | 268-------------------------------------------------------------------------------
Dinternal/bind/impl.go | 176-------------------------------------------------------------------------------
Dinternal/bind/interfaces.go | 244-------------------------------------------------------------------------------
Dinternal/bind/procs.go | 67-------------------------------------------------------------------
Dinternal/bind/registry.go | 93-------------------------------------------------------------------------------
Mtoast.go | 28++++++++++++++++++++--------
Awintoast/bind.go | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/bind_noop.go | 19+++++++++++++++++++
Awintoast/bind_test.go | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/bind_windows.go | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/impl.go | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/interfaces.go | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/procs.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awintoast/registry.go | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 1119 insertions(+), 1109 deletions(-)

diff --git a/internal/bind/bind.go b/internal/bind/bind.go @@ -1,84 +0,0 @@ -// Package bind provides a pure-Go implementation of toast notifications on Windows. -package bind - -import "errors" - -// AppData describes the application to the Windows Runtime. -type AppData struct { - AppID string - ActivationExe string // optional - IconPath string // optional - IconBackgroundColor string // optional -} - -// 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 -} - -// Callback is a function that gets invoked when the notification is activated. -type Callback func(appUserModelId string, invokedArgs string, userData []UserData) - -// SetAppData teaches the Windows Runtime about our application and establishes the activation GUID -// so Windows will know how to invoke us back. -func SetAppData(data AppData) (err error) { - return setAppData(data) -} - -// 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 Callback) { - callback = cb -} - -// Push a notification described by the XML to the Windows Runtime. -// -// App data should be set first via a call to SetAppData before calling -// this function. -// -// If the powershell fallback is engaged, activation callbacks will not -// work as expected and the COM error will still be returned. -func Push(xml string, op ...option) error { - var opts options - for _, opt := range op { - opt(&opts) - } - if opts.PowershellPreferred { - return pushPowershell(xml) - } - if err := pushCOM(xml); err != nil { - if opts.PowershellFallback { - return errors.Join(err, pushPowershell(xml)) - } - return err - } - return nil -} - -type options struct { - PowershellFallback bool - PowershellPreferred bool -} - -type option func(*options) - -// PreferPowershell indicates to use the powershell method by default. -// COM will not be used. -func PreferPowershell(opt *options) { - opt.PowershellPreferred = true -} - -// PowershellFallback specifies to use the powershell method as a fallback -// if the COM api fails. -func PowershellFallback(opt *options) { - opt.PowershellFallback = true -} - -// callback is the global callback reference that is invoked by Activate. -// -// NOTE(jfm): synchronize access to this? -var callback Callback = func(model, args string, data []UserData) {} diff --git a/internal/bind/bind_noop.go b/internal/bind/bind_noop.go @@ -1,20 +0,0 @@ -//go:build !windows - -package bind - -func setAppData(data AppData) error { - return nil -} - -func generateToast(appID string, xml string) error { - return nil -} - -func pushPowershell(xml string) error { - return nil -} - -func pushCOM(xml string) error { - return nil -} - diff --git a/internal/bind/bind_test.go b/internal/bind/bind_test.go @@ -1,149 +0,0 @@ -//go:build windows - -package bind - -import ( - "fmt" - "path/filepath" - "reflect" - "testing" -) - -// TestSetAppData ensures correct control flow. -func TestSetAppData(t *testing.T) { - - t.Run("set data", func(t *testing.T) { - var didEarlyOut = true - - setAppData = func(data AppData) error { - didEarlyOut = false - return nil - } - - appData = AppData{} - - input := AppData{AppID: "test-id"} - - if err := SetAppData(input); err != nil { - t.Fatalf("error: %v", err) - } - - if appData != input { - t.Fatalf("want=%v, got %v", input, appData) - } - - if didEarlyOut { - t.Fatalf("expected to manipulate registry, instead early out") - } - }) - - t.Run("avoid setting empty data", func(t *testing.T) { - var didEarlyOut = true - - setAppData = func(data AppData) error { - didEarlyOut = false - return nil - } - - appData = AppData{AppID: "test-id"} - - input := AppData{} - - if err := SetAppData(input); err != nil { - t.Fatalf("error: %v", err) - } - - if appData == input { - t.Fatalf("want=%v, got %v", appData, input) - } - - if !didEarlyOut { - t.Fatal("expected early out, instead registry was manipulated") - } - }) - - t.Run("cancel on error", func(t *testing.T) { - setAppData = func(data AppData) error { - return fmt.Errorf("fake error") - } - - appData = AppData{} - - input := AppData{AppID: "test-id"} - - if err := SetAppData(input); err == nil { - t.Fatalf("expected error, got nil") - } - - if appData == input { - t.Fatalf("want=%v, got %v", appData, input) - } - }) - - t.Run("expect registry keys", func(t *testing.T) { - - t.Run("minimal keys", func(t *testing.T) { - setAppData = setAppDataImpl - - record := map[string]string{} - - // Capture what would be written out to the registry. - writeStringValue = func(path, name, value string) error { - record[filepath.Join(path, name)] = value - return nil - } - - input := AppData{ - AppID: "test-id", - } - - if err := SetAppData(input); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expect := map[string]string{ - filepath.Join(appKeyRoot, input.AppID, "CustomActivator"): GUID_ImplNotificationActivationCallback.String(), - filepath.Join(appKeyRoot, input.AppID, "DisplayName"): input.AppID, - } - - if !reflect.DeepEqual(expect, record) { - t.Fatalf("\nwant=%v \ngot =%v\n", expect, record) - } - }) - - t.Run("all keys", func(t *testing.T) { - setAppData = setAppDataImpl - - record := map[string]string{} - - // Capture what would be written out to the registry. - writeStringValue = func(path, name, value string) error { - record[filepath.Join(path, name)] = value - return nil - } - - input := AppData{ - AppID: "test-id", - IconPath: "path/to/icon.ico", - IconBackgroundColor: "#FFFFFF", - ActivationExe: "path/to/exe", - } - - if err := SetAppData(input); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expect := map[string]string{ - filepath.Join(appKeyRoot, input.AppID, "CustomActivator"): GUID_ImplNotificationActivationCallback.String(), - filepath.Join(appKeyRoot, input.AppID, "DisplayName"): input.AppID, - filepath.Join(appKeyRoot, input.AppID, "IconUri"): input.IconPath, - filepath.Join(appKeyRoot, input.AppID, "IconBackgroundColor"): input.IconBackgroundColor, - filepath.Join(activationKey): input.ActivationExe, - } - - if !reflect.DeepEqual(expect, record) { - t.Fatalf("\nwant=%v \ngot =%v\n", expect, record) - } - }) - }) -} diff --git a/internal/bind/bind_windows.go b/internal/bind/bind_windows.go @@ -1,268 +0,0 @@ -//go:build windows - -package bind - -import ( - "errors" - "fmt" - "io" - "os" - "os/exec" - "sync" - "syscall" - "unicode/utf16" - "unsafe" - - "git.sr.ht/~jackmordaunt/go-toast/tmpl" - "github.com/go-ole/go-ole" -) - -func pushPowershell(xml string) error { - f, err := os.CreateTemp("", "*.ps1") - if err != nil { - return fmt.Errorf("creating temporary script file: %w", err) - } - - defer func() { err = errors.Join(err, os.Remove(f.Name())) }() - - // This BOM ensures we can support non-ascii characters in the toast content. - bomUtf8 := []byte{0xef, 0xbb, 0xbf} - if _, err := f.Write(bomUtf8); err != nil { - return fmt.Errorf("writing utf8 byte marker: %w", err) - } - - if err := buildPowershell(xml, f); err != nil { - return fmt.Errorf("generating powershell script: %w", err) - } - - if err := f.Close(); err != nil { - return fmt.Errorf("closing script file: %w", err) - } - - cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", "-File", f.Name()) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("executing powershell: %q: %w", string(out), err) - } - - return nil -} - -func buildPowershell(xml string, w io.Writer) error { - type scriptData struct { - AppID string - XML string - } - return tmpl.ScriptTemplate.Execute(w, scriptData{AppID: appData.AppID, XML: xml}) -} - -func pushCOM(xml string) error { - if err := initialize(); err != nil { - 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) - } - - noti, err := newNotiFromXml(xml) - if err != nil { - return fmt.Errorf("building notification: %w", err) - } - - notifier, err := newNotifier(appData.AppID) - if err != nil { - return fmt.Errorf("building notifier: %w", err) - } - - if err := notifier.Show(noti); err != nil { - return fmt.Errorf("showing notification: %w", err) - } - - return nil -} - -func setAppData(data AppData) (err error) { - appDataMu.Lock() - defer appDataMu.Unlock() - - // Early out if we have already set this data. - // - // In the case the data is empty, we don't want to overrite - // all of the registry entries to empty. - // - // This allows the caller to either globally set the app data - // or provide it per notification. - if appData == data || data.AppID == "" { - return nil - } - - // Keep a copy of the saved data for later. - defer func() { - if err == nil { - appData = data - } - }() - - if err := setAppDataFunc(data); err != nil { - return err - } - - return nil -} - -var initLock sync.Mutex -var didInitialize bool - -// initialize attempts to initialize the Windows Runtime. -// Each invocation will retry RoInitialize until a successful initialization -// is achieved. Once initialized, we avoid invoking RoInitialize since subsequent -// reinitialization generates errors. -func initialize() (err error) { - initLock.Lock() - defer initLock.Unlock() - - if didInitialize { - return nil - } - - if err := ole.RoInitialize(1); err != nil { - return fmt.Errorf("RoInitialize: %w", err) - } - - didInitialize = true - - 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. - type layout struct { - Key unsafe.Pointer - Value unsafe.Pointer - } - - // Create a new slice with the appropriate length - out := make([]UserData, count) - - // Create a slice with the unsafe data layout. - tmp := unsafe.Slice((*layout)(ptr), count) - - // Convert the unsafe layout to safe strings. - for ii, it := range tmp { - out[ii] = UserData{ - Key: utf16PtrToString((*uint16)(it.Key)), - Value: utf16PtrToString((*uint16)(it.Value)), - } - } - - return out -} - -// utf16PtrToString builds a string out of a utf16 null terminated byte sequence. -// -// Copied from package syscall. -func utf16PtrToString(p *uint16) string { - if p == nil { - return "" - } - // Find NUL terminator. - end := unsafe.Pointer(p) - n := 0 - for *(*uint16)(end) != 0 { - end = unsafe.Pointer(uintptr(end) + unsafe.Sizeof(*p)) - n++ - } - // Turn *uint16 into []uint16. - s := unsafe.Slice(p, n) - // Decode []uint16 into string. - return string(utf16.Decode(s)) -} - diff --git a/internal/bind/impl.go b/internal/bind/impl.go @@ -1,176 +0,0 @@ -//go:build windows - -// 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 @@ -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 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 -} - -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/procs.go b/internal/bind/procs.go @@ -1,67 +0,0 @@ -//go:build windows - -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 { - // cookie is used as a handle to this class. It is used when calling CoRevokeClassObject - // which unregisters the class. We don't need it until we plan to revoke this registration - // for some reason. - 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 @@ -1,93 +0,0 @@ -//go:build windows - -// This file contains registry manipulation code. -// This logic is orthogonal to, but works in tandem with the COM code; since the -// Windows Runtime uses the registry as it's primary source of state. -package bind - -import ( - "fmt" - "path/filepath" - "sync" - - "golang.org/x/sys/windows/registry" -) - -var ( - // allows diffing the new call from the previous so that we can early-out, - // and avoid touching the registry more than necessary. - // It also allows empty app data to be supplied to the Notifcation type, - // without erasing the data that has been set via the global function. - appData AppData - appDataMu sync.Mutex -) - -// Overridden in testing. -var ( - writeStringValue = writeStringValueImpl - setAppDataFunc = setAppDataImpl -) - -var ( - // appKeyRoot is the root path for app metadata. - appKeyRoot = filepath.Join("SOFTWARE", "Classes", "AppUserModelId") - // activationKey is the root path to the activation executable. - activationKey = filepath.Join("SOFTWARE", "Classes", "CLSID", GUID_ImplNotificationActivationCallback.String(), "LocalServer32") -) - -// The Windows registry package uses empty string for the "(Default)" key. -const registryDefaultKey string = "" - -func setAppDataImpl(data AppData) error { - if data.AppID == "" { - return fmt.Errorf("empty app ID") - } - - appKey := filepath.Join(appKeyRoot, data.AppID) - - if err := writeStringValue(appKey, "DisplayName", data.AppID); err != nil { - return err - } - - // CustomActivator teaches Window what COM class to use as the callback when - // a toast notification is activated. - if err := writeStringValue(appKey, "CustomActivator", GUID_ImplNotificationActivationCallback.String()); err != nil { - return err - } - - if data.IconPath != "" { - if err := writeStringValue(appKey, "IconUri", data.IconPath); err != nil { - return err - } - } - - if data.IconBackgroundColor != "" { - if err := writeStringValue(appKey, "IconBackgroundColor", data.IconBackgroundColor); err != nil { - return err - } - } - - if data.ActivationExe != "" { - if err := writeStringValue(activationKey, registryDefaultKey, data.ActivationExe); err != nil { - return fmt.Errorf("setting activation executable: %w", err) - } - } - - return nil -} - -// writeStringValue writes a string value to the path, where name is the subkey and -// value is the literal value. -func writeStringValueImpl(path, name, value string) error { - key, _, err := registry.CreateKey(registry.CURRENT_USER, path, registry.SET_VALUE) - if err != nil { - return fmt.Errorf("opening registry key: %s: %w", path, err) - } - if err := key.SetStringValue(name, value); err != nil { - return fmt.Errorf("setting string value: (%s) %s=%s: %w", path, name, value, err) - } - if err := key.Close(); err != nil { - return fmt.Errorf("closing key: %s: %w", path, err) - } - return nil -} diff --git a/toast.go b/toast.go @@ -1,11 +1,22 @@ +// Package toast wraps the lower-level wintoast api and provides an easy way +// to send and respond to toast notifications on Windows. +// +// First, setup your AppData vis SetAppData function. This will install your +// application metadata into the Windows Registry. +// +// Then, if you want in-process callback to be invoked upon user interaction, +// invoke SetActivationCallback. +// +// Finally, generate your notification by instantiation a toast.Notification +// and pushing it with Push method. package toast import ( "bytes" "fmt" - "git.sr.ht/~jackmordaunt/go-toast/internal/bind" "git.sr.ht/~jackmordaunt/go-toast/tmpl" + "git.sr.ht/~jackmordaunt/go-toast/wintoast" ) // Notification @@ -18,9 +29,10 @@ import ( // // The AppID is shown beneath the toast message (in certain cases), and above the notification within the Action // Center - and is used to group your notifications together. It is recommended that you provide a "pretty" -// name for your app, and not something like "com.example.MyApp". +// name for your app, and not something like "com.example.MyApp". It can be ellided if the value has already +// been set via SetAppData. // -// If no Title is provided, but a Message is, the message will display as the toast notification's title - +// If no Title is provided, but a Body is, the body will display as the toast notification's title - // which is a slightly different font style (heavier). // // The Icon should be an absolute path to the icon (as the toast is invoked from a temporary path on the user's @@ -90,7 +102,7 @@ type Notification struct { // UserData contains user supplied data from the notification, such as text input // or a selection. -type UserData = bind.UserData +type UserData = wintoast.UserData // Input // @@ -151,7 +163,7 @@ func (n *Notification) Push() error { if err != nil { return err } - if err := bind.SetAppData(bind.AppData{ + if err := wintoast.SetAppData(wintoast.AppData{ AppID: n.AppID, IconPath: n.Icon, IconBackgroundColor: n.IconBackgroundColor, @@ -159,7 +171,7 @@ func (n *Notification) Push() error { }); err != nil { return fmt.Errorf("configuring registry: %w", err) } - return bind.Push(xml, bind.PowershellFallback) + return wintoast.Push(xml, wintoast.PowershellFallback) } func (n *Notification) applyDefaults() { @@ -197,7 +209,7 @@ func (n *Notification) buildXML() (string, error) { // // This will do nothing if the the powershell fallback is in-effect. func SetActivationCallback(cb func(args string, data []UserData)) { - bind.SetActivationCallback(func(appUserModelId, invokedArgs string, userData []bind.UserData) { + wintoast.SetActivationCallback(func(appUserModelId, invokedArgs string, userData []wintoast.UserData) { cb(invokedArgs, userData) }) } @@ -226,5 +238,5 @@ type AppData struct { // This is required to display the application name, as well as any branding. // Registry is global state, hence it makes sense to set it global. func SetAppData(data AppData) error { - return bind.SetAppData(bind.AppData(data)) + return wintoast.SetAppData(wintoast.AppData(data)) } diff --git a/wintoast/bind.go b/wintoast/bind.go @@ -0,0 +1,84 @@ +// Package wintoast provides a pure-Go implementation of toast notifications on Windows. +package wintoast + +import "errors" + +// AppData describes the application to the Windows Runtime. +type AppData struct { + AppID string + ActivationExe string // optional + IconPath string // optional + IconBackgroundColor string // optional +} + +// 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 +} + +// Callback is a function that gets invoked when the notification is activated. +type Callback func(appUserModelId string, invokedArgs string, userData []UserData) + +// SetAppData teaches the Windows Runtime about our application and establishes the activation GUID +// so Windows will know how to invoke us back. +func SetAppData(data AppData) (err error) { + return setAppData(data) +} + +// 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 Callback) { + callback = cb +} + +// Push a notification described by the XML to the Windows Runtime. +// +// App data should be set first via a call to SetAppData before calling +// this function. +// +// If the powershell fallback is engaged, activation callbacks will not +// work as expected and the COM error will still be returned. +func Push(xml string, op ...option) error { + var opts options + for _, opt := range op { + opt(&opts) + } + if opts.PowershellPreferred { + return pushPowershell(xml) + } + if err := pushCOM(xml); err != nil { + if opts.PowershellFallback { + return errors.Join(err, pushPowershell(xml)) + } + return err + } + return nil +} + +type options struct { + PowershellFallback bool + PowershellPreferred bool +} + +type option func(*options) + +// PreferPowershell indicates to use the powershell method by default. +// COM will not be used. +func PreferPowershell(opt *options) { + opt.PowershellPreferred = true +} + +// PowershellFallback specifies to use the powershell method as a fallback +// if the COM api fails. +func PowershellFallback(opt *options) { + opt.PowershellFallback = true +} + +// callback is the global callback reference that is invoked by Activate. +// +// NOTE(jfm): synchronize access to this? +var callback Callback = func(model, args string, data []UserData) {} diff --git a/wintoast/bind_noop.go b/wintoast/bind_noop.go @@ -0,0 +1,19 @@ +//go:build !windows + +package wintoast + +func setAppData(data AppData) error { + return nil +} + +func generateToast(appID string, xml string) error { + return nil +} + +func pushPowershell(xml string) error { + return nil +} + +func pushCOM(xml string) error { + return nil +} diff --git a/wintoast/bind_test.go b/wintoast/bind_test.go @@ -0,0 +1,149 @@ +//go:build windows + +package wintoast + +import ( + "fmt" + "path/filepath" + "reflect" + "testing" +) + +// TestSetAppData ensures correct control flow. +func TestSetAppData(t *testing.T) { + + t.Run("set data", func(t *testing.T) { + var didEarlyOut = true + + setAppDataFunc = func(data AppData) error { + didEarlyOut = false + return nil + } + + appData = AppData{} + + input := AppData{AppID: "test-id"} + + if err := SetAppData(input); err != nil { + t.Fatalf("error: %v", err) + } + + if appData != input { + t.Fatalf("want=%v, got %v", input, appData) + } + + if didEarlyOut { + t.Fatalf("expected to manipulate registry, instead early out") + } + }) + + t.Run("avoid setting empty data", func(t *testing.T) { + var didEarlyOut = true + + setAppDataFunc = func(data AppData) error { + didEarlyOut = false + return nil + } + + appData = AppData{AppID: "test-id"} + + input := AppData{} + + if err := SetAppData(input); err != nil { + t.Fatalf("error: %v", err) + } + + if appData == input { + t.Fatalf("want=%v, got %v", appData, input) + } + + if !didEarlyOut { + t.Fatal("expected early out, instead registry was manipulated") + } + }) + + t.Run("cancel on error", func(t *testing.T) { + setAppDataFunc = func(data AppData) error { + return fmt.Errorf("fake error") + } + + appData = AppData{} + + input := AppData{AppID: "test-id"} + + if err := SetAppData(input); err == nil { + t.Fatalf("expected error, got nil") + } + + if appData == input { + t.Fatalf("want=%v, got %v", appData, input) + } + }) + + t.Run("expect registry keys", func(t *testing.T) { + + t.Run("minimal keys", func(t *testing.T) { + setAppDataFunc = setAppDataImpl + + record := map[string]string{} + + // Capture what would be written out to the registry. + writeStringValue = func(path, name, value string) error { + record[filepath.Join(path, name)] = value + return nil + } + + input := AppData{ + AppID: "test-id", + } + + if err := SetAppData(input); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expect := map[string]string{ + filepath.Join(appKeyRoot, input.AppID, "CustomActivator"): GUID_ImplNotificationActivationCallback.String(), + filepath.Join(appKeyRoot, input.AppID, "DisplayName"): input.AppID, + } + + if !reflect.DeepEqual(expect, record) { + t.Fatalf("\nwant=%v \ngot =%v\n", expect, record) + } + }) + + t.Run("all keys", func(t *testing.T) { + setAppDataFunc = setAppDataImpl + + record := map[string]string{} + + // Capture what would be written out to the registry. + writeStringValue = func(path, name, value string) error { + record[filepath.Join(path, name)] = value + return nil + } + + input := AppData{ + AppID: "test-id", + IconPath: "path/to/icon.ico", + IconBackgroundColor: "#FFFFFF", + ActivationExe: "path/to/exe", + } + + if err := SetAppData(input); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expect := map[string]string{ + filepath.Join(appKeyRoot, input.AppID, "CustomActivator"): GUID_ImplNotificationActivationCallback.String(), + filepath.Join(appKeyRoot, input.AppID, "DisplayName"): input.AppID, + filepath.Join(appKeyRoot, input.AppID, "IconUri"): input.IconPath, + filepath.Join(appKeyRoot, input.AppID, "IconBackgroundColor"): input.IconBackgroundColor, + filepath.Join(activationKey): input.ActivationExe, + } + + if !reflect.DeepEqual(expect, record) { + t.Fatalf("\nwant=%v \ngot =%v\n", expect, record) + } + }) + }) +} diff --git a/wintoast/bind_windows.go b/wintoast/bind_windows.go @@ -0,0 +1,267 @@ +//go:build windows + +package wintoast + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "syscall" + "unicode/utf16" + "unsafe" + + "git.sr.ht/~jackmordaunt/go-toast/tmpl" + "github.com/go-ole/go-ole" +) + +func pushPowershell(xml string) error { + f, err := os.CreateTemp("", "*.ps1") + if err != nil { + return fmt.Errorf("creating temporary script file: %w", err) + } + + defer func() { err = errors.Join(err, os.Remove(f.Name())) }() + + // This BOM ensures we can support non-ascii characters in the toast content. + bomUtf8 := []byte{0xef, 0xbb, 0xbf} + if _, err := f.Write(bomUtf8); err != nil { + return fmt.Errorf("writing utf8 byte marker: %w", err) + } + + if err := buildPowershell(xml, f); err != nil { + return fmt.Errorf("generating powershell script: %w", err) + } + + if err := f.Close(); err != nil { + return fmt.Errorf("closing script file: %w", err) + } + + cmd := exec.Command("PowerShell", "-ExecutionPolicy", "Bypass", "-File", f.Name()) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("executing powershell: %q: %w", string(out), err) + } + + return nil +} + +func buildPowershell(xml string, w io.Writer) error { + type scriptData struct { + AppID string + XML string + } + return tmpl.ScriptTemplate.Execute(w, scriptData{AppID: appData.AppID, XML: xml}) +} + +func pushCOM(xml string) error { + if err := initialize(); err != nil { + 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) + } + + noti, err := newNotiFromXml(xml) + if err != nil { + return fmt.Errorf("building notification: %w", err) + } + + notifier, err := newNotifier(appData.AppID) + if err != nil { + return fmt.Errorf("building notifier: %w", err) + } + + if err := notifier.Show(noti); err != nil { + return fmt.Errorf("showing notification: %w", err) + } + + return nil +} + +func setAppData(data AppData) (err error) { + appDataMu.Lock() + defer appDataMu.Unlock() + + // Early out if we have already set this data. + // + // In the case the data is empty, we don't want to overrite + // all of the registry entries to empty. + // + // This allows the caller to either globally set the app data + // or provide it per notification. + if appData == data || data.AppID == "" { + return nil + } + + // Keep a copy of the saved data for later. + defer func() { + if err == nil { + appData = data + } + }() + + if err := setAppDataFunc(data); err != nil { + return err + } + + return nil +} + +var initLock sync.Mutex +var didInitialize bool + +// initialize attempts to initialize the Windows Runtime. +// Each invocation will retry RoInitialize until a successful initialization +// is achieved. Once initialized, we avoid invoking RoInitialize since subsequent +// reinitialization generates errors. +func initialize() (err error) { + initLock.Lock() + defer initLock.Unlock() + + if didInitialize { + return nil + } + + if err := ole.RoInitialize(1); err != nil { + return fmt.Errorf("RoInitialize: %w", err) + } + + didInitialize = true + + 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. + type layout struct { + Key unsafe.Pointer + Value unsafe.Pointer + } + + // Create a new slice with the appropriate length + out := make([]UserData, count) + + // Create a slice with the unsafe data layout. + tmp := unsafe.Slice((*layout)(ptr), count) + + // Convert the unsafe layout to safe strings. + for ii, it := range tmp { + out[ii] = UserData{ + Key: utf16PtrToString((*uint16)(it.Key)), + Value: utf16PtrToString((*uint16)(it.Value)), + } + } + + return out +} + +// utf16PtrToString builds a string out of a utf16 null terminated byte sequence. +// +// Copied from package syscall. +func utf16PtrToString(p *uint16) string { + if p == nil { + return "" + } + // Find NUL terminator. + end := unsafe.Pointer(p) + n := 0 + for *(*uint16)(end) != 0 { + end = unsafe.Pointer(uintptr(end) + unsafe.Sizeof(*p)) + n++ + } + // Turn *uint16 into []uint16. + s := unsafe.Slice(p, n) + // Decode []uint16 into string. + return string(utf16.Decode(s)) +} diff --git a/wintoast/impl.go b/wintoast/impl.go @@ -0,0 +1,176 @@ +//go:build windows + +// 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 wintoast + +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/wintoast/interfaces.go b/wintoast/interfaces.go @@ -0,0 +1,244 @@ +//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 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 @@ -0,0 +1,67 @@ +//go:build windows + +package wintoast + +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 { + // cookie is used as a handle to this class. It is used when calling CoRevokeClassObject + // which unregisters the class. We don't need it until we plan to revoke this registration + // for some reason. + 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/wintoast/registry.go b/wintoast/registry.go @@ -0,0 +1,93 @@ +//go:build windows + +// This file contains registry manipulation code. +// This logic is orthogonal to, but works in tandem with the COM code; since the +// Windows Runtime uses the registry as it's primary source of state. +package wintoast + +import ( + "fmt" + "path/filepath" + "sync" + + "golang.org/x/sys/windows/registry" +) + +var ( + // allows diffing the new call from the previous so that we can early-out, + // and avoid touching the registry more than necessary. + // It also allows empty app data to be supplied to the Notifcation type, + // without erasing the data that has been set via the global function. + appData AppData + appDataMu sync.Mutex +) + +// Overridden in testing. +var ( + writeStringValue = writeStringValueImpl + setAppDataFunc = setAppDataImpl +) + +var ( + // appKeyRoot is the root path for app metadata. + appKeyRoot = filepath.Join("SOFTWARE", "Classes", "AppUserModelId") + // activationKey is the root path to the activation executable. + activationKey = filepath.Join("SOFTWARE", "Classes", "CLSID", GUID_ImplNotificationActivationCallback.String(), "LocalServer32") +) + +// The Windows registry package uses empty string for the "(Default)" key. +const registryDefaultKey string = "" + +func setAppDataImpl(data AppData) error { + if data.AppID == "" { + return fmt.Errorf("empty app ID") + } + + appKey := filepath.Join(appKeyRoot, data.AppID) + + if err := writeStringValue(appKey, "DisplayName", data.AppID); err != nil { + return err + } + + // CustomActivator teaches Window what COM class to use as the callback when + // a toast notification is activated. + if err := writeStringValue(appKey, "CustomActivator", GUID_ImplNotificationActivationCallback.String()); err != nil { + return err + } + + if data.IconPath != "" { + if err := writeStringValue(appKey, "IconUri", data.IconPath); err != nil { + return err + } + } + + if data.IconBackgroundColor != "" { + if err := writeStringValue(appKey, "IconBackgroundColor", data.IconBackgroundColor); err != nil { + return err + } + } + + if data.ActivationExe != "" { + if err := writeStringValue(activationKey, registryDefaultKey, data.ActivationExe); err != nil { + return fmt.Errorf("setting activation executable: %w", err) + } + } + + return nil +} + +// writeStringValue writes a string value to the path, where name is the subkey and +// value is the literal value. +func writeStringValueImpl(path, name, value string) error { + key, _, err := registry.CreateKey(registry.CURRENT_USER, path, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening registry key: %s: %w", path, err) + } + if err := key.SetStringValue(name, value); err != nil { + return fmt.Errorf("setting string value: (%s) %s=%s: %w", path, name, value, err) + } + if err := key.Close(); err != nil { + return fmt.Errorf("closing key: %s: %w", path, err) + } + return nil +}