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:
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),