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:
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
+}