go-toast

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

commit 4c3afc4582daa815adace1595f6e90f14a15af37
parent 53f523e3d4798c91592239eb1da3366ee09fba15
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 22 Mar 2023 12:04:28 +0800

all: allow package bind to be compiled on non windows

Use build flags to define non-windows function stubs.

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

Diffstat:
Minternal/bind/bind.go | 242+++++++++++++++++--------------------------------------------------------------
Ainternal/bind/bind_noop.go | 20++++++++++++++++++++
Minternal/bind/bind_test.go | 2++
Ainternal/bind/bind_windows.go | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/bind/impl.go | 2++
Minternal/bind/interfaces.go | 2++
Minternal/bind/procs.go | 2++
Minternal/bind/registry.go | 43+++----------------------------------------
Mtoast.go | 59++---------------------------------------------------------
9 files changed, 351 insertions(+), 289 deletions(-)

diff --git a/internal/bind/bind.go b/internal/bind/bind.go @@ -1,14 +1,15 @@ // Package bind provides a pure-Go implementation of toast notifications on Windows. package bind -import ( - "fmt" - "sync" - "unicode/utf16" - "unsafe" - - "github.com/go-ole/go-ole" -) +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 @@ -18,209 +19,66 @@ type UserData struct { 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 a function that gets invoked when the notification is activated. +type Callback func(appUserModelId string, invokedArgs string, userData []UserData) -// callback is the global callback reference that is invoked by Activate. -// -// NOTE(jfm): synchronize access to this? -var callback ActivationCallback = func(model, args string, data []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 ActivationCallback) { +func SetActivationCallback(cb Callback) { callback = cb } -// GenerateToast notification via the specified xml content. +// 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. // -// No validation is performed on this xml content and the caller assumes responsibility -// for ensuring it's validity. -func GenerateToast(appID, xml string) error { - if err := initialize(); err != nil { +// 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 } - - // If appID is already set via SetAppData, use that. - // It's a bit of a side-channel hack, however it's necessary for some of - // the API flexibility that's being employed. - if appID == "" { - appID = appData.AppID - } - - // 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(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 } -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 +type options struct { + PowershellFallback bool + PowershellPreferred bool } -// 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) - } +type option func(*options) - return notifier, nil +// PreferPowershell indicates to use the powershell method by default. +// COM will not be used. +func PreferPowershell(opt *options) { + opt.PowershellPreferred = true } -// 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 +// PowershellFallback specifies to use the powershell method as a fallback +// if the COM api fails. +func PowershellFallback(opt *options) { + opt.PowershellFallback = true } -// 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. +// callback is the global callback reference that is invoked by Activate. // -// 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)) -} +// 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 @@ -0,0 +1,20 @@ +//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,3 +1,5 @@ +//go:build windows + package bind import ( diff --git a/internal/bind/bind_windows.go b/internal/bind/bind_windows.go @@ -0,0 +1,268 @@ +//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,3 +1,5 @@ +//go:build windows + // This file contains our pure-Go implementations of two COM objects that we need // to render toast notifications: IClassFactory and INotificationActivationCallback. // diff --git a/internal/bind/interfaces.go b/internal/bind/interfaces.go @@ -1,3 +1,5 @@ +//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 diff --git a/internal/bind/procs.go b/internal/bind/procs.go @@ -1,3 +1,5 @@ +//go:build windows + package bind import ( diff --git a/internal/bind/registry.go b/internal/bind/registry.go @@ -1,3 +1,5 @@ +//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. @@ -20,49 +22,10 @@ var ( appDataMu sync.Mutex ) -// AppData describes the application to the Windows Runtime. -type AppData struct { - AppID string - ActivationExe string // optional - IconPath string // optional - IconBackgroundColor string // optional -} - -// 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) { - 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 := setAppData(data); err != nil { - return err - } - - return nil -} - // Overridden in testing. var ( writeStringValue = writeStringValueImpl - setAppData = setAppDataImpl + setAppDataFunc = setAppDataImpl ) var ( diff --git a/toast.go b/toast.go @@ -2,14 +2,10 @@ package toast import ( "bytes" - "errors" "fmt" + "git.sr.ht/~jackmordaunt/go-toast/internal/bind" "git.sr.ht/~jackmordaunt/go-toast/tmpl" - "io" - "os" - "os/exec" - "syscall" ) // Notification @@ -163,12 +159,7 @@ func (n *Notification) Push() error { }); err != nil { return fmt.Errorf("configuring registry: %w", err) } - 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 nil + return bind.Push(xml, bind.PowershellFallback) } func (n *Notification) applyDefaults() { @@ -192,52 +183,6 @@ func (n *Notification) buildXML() (string, error) { return out.String(), nil } -func (n *Notification) buildPowerShell(xml string, w io.Writer) error { - type scriptData struct { - AppID string - XML string - } - return tmpl.ScriptTemplate.Execute(w, scriptData{AppID: n.AppID, XML: xml}) -} - -// pushCOM pushes the notification using the COM interface. -func (n *Notification) pushCOM(xml string) error { - return bind.GenerateToast(n.AppID, xml) -} - -// pushPowershell pushes the notification using a temporary powershell script. -// This method does not support the in-process activation callback. -func (n *Notification) 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 := n.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 -} - // SetActivationCallback sets the global activation callback. // // The first argument contains application defined data (embedded within the xml),