go-toast

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

commit 24043fcd0c1ee0d52cf31599ca74ec2c33d3cf95
parent bc9200482b415b309b45e1da61817f1fa8798c28
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 15 Mar 2023 18:29:50 +0800

bind: extract registry logic to file

This separates the registry manipulation from the DLL interaction.
Also enhanced the documentation.

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

Diffstat:
Minternal/bind/bind.go | 106+++++++++++++++++++++++--------------------------------------------------------
Ainternal/bind/registry.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoast.go | 8+++++++-
3 files changed, 114 insertions(+), 77 deletions(-)

diff --git a/internal/bind/bind.go b/internal/bind/bind.go @@ -2,9 +2,7 @@ package bind import ( - "errors" "fmt" - "path/filepath" "sync" "syscall" "unicode/utf16" @@ -12,11 +10,11 @@ import ( "github.com/go-ole/go-ole" "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" ) // This GUID matches the one defined in the C code. This coincidentally matches that // value because I don't want to import C when we don't need to. +// // We could keep them more in sync by exporting a DLL function that returns a copy of the string. // // It's also possible that we could dynamically generate a new GUID per app run, or derive @@ -29,74 +27,6 @@ var ( procSetActivationCallback = toast.NewProc("SetActivationCallback") ) -// ConfigureRegistry installs various keys that tell the Windows Runtime details about our -// application. This is where we install a path to the CustomActivator, which is a binary -// that Windows will invoke when the toast notification is activated and our process is not -// running. This can be used to cold start the application from a notification interaction. -func ConfigureRegistry(appID, appDisplayName, backgroundColor, exePath string) error { - - // This first key establishes the declared CLSID GUID within the class registry. - // We set the LocalServer32 to the exePath. This is the exe that will be invoked - // by the Windows Runtime when the app is closed; allowing us to do cold starts. - clsidKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "CLSID", iNotificationActivationCallbackGUID, "LocalServer32"), registry.SET_VALUE) - if err != nil { - return fmt.Errorf("setting the exe path for LocalServer reponse: %w", err) - } - if err := clsidKey.SetStringValue("", exePath); err != nil { - return fmt.Errorf("seting LocalServer32: %w", err) - } - if err := clsidKey.Close(); err != nil { - return fmt.Errorf("closing CLSID key: %w", err) - } - - // This next key establishes details about our application. This is where we map the prior CLSID - // to be the CustomActivator for our application among other things. - // - // Toast Activation -> CustomActivator (get CLSID) -> LocalServer32 (activate exe for CLSID) - appIDKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "AppUserModelId", appID), registry.SET_VALUE) - if err != nil { - return fmt.Errorf("opening registry: %w", err) - } - defer func() { - err = errors.Join(err, appIDKey.Close()) - }() - if err := appIDKey.SetStringValue("DisplayName", appDisplayName); err != nil { - return fmt.Errorf("setting DisplayName: %w", err) - } - if err := appIDKey.SetStringValue("IconBackgroundColor", backgroundColor); err != nil { - return fmt.Errorf("setting IconBackgroundColor: %w", err) - } - if err := appIDKey.SetStringValue("CustomActivator", iNotificationActivationCallbackGUID); err != nil { - return fmt.Errorf("setting CustomActivator: %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 -} - // GenerateToast notification via the specified xml content. // // No validation is performed on this xml content and the caller assumes responsibility @@ -130,6 +60,13 @@ func GenerateToast(appID, xml string) error { return nil } +// UserData contains Key:Value pairs generated within the notification, based +// on the XML content of the notification. +type UserData struct { + Key string + Value string +} + // ActivationCallback is a function that gets invoked when the toast is activated. type ActivationCallback func(appUserModelId string, invokedArgs string, userData []UserData) @@ -154,11 +91,28 @@ func SetActivationCallback(cb ActivationCallback) { procSetActivationCallback.Call(callback) } -// UserData contains Key:Value pairs generated within the notification, based -// on the XML content of the notification. -type UserData struct { - Key string - Value string +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 } // sliceUserDataFromUnsafe builds a slice of UserData out of an unsafe pointer. diff --git a/internal/bind/registry.go b/internal/bind/registry.go @@ -0,0 +1,77 @@ +package bind + +import ( + "errors" + "fmt" + "path/filepath" + + "golang.org/x/sys/windows/registry" +) + +// AppData describes the application to the Windows Runtime. +type AppData struct { + ID string + DisplayName string // optional, if empty the ID will be used + ExePath 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) error { + if data.ID == "" { + return fmt.Errorf("empty app ID") + } + if data.DisplayName == "" { + data.DisplayName = data.ID + } + appIDKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "AppUserModelId", data.ID), registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening registry: %w", err) + } + defer func() { + err = errors.Join(err, appIDKey.Close()) + }() + if err := appIDKey.SetStringValue("DisplayName", data.DisplayName); err != nil { + return fmt.Errorf("setting DisplayName: %w", err) + } + // CustomActivator teaches Window what COM class to use as the callback when + // a toast notification is activated. + if err := appIDKey.SetStringValue("CustomActivator", iNotificationActivationCallbackGUID); err != nil { + return fmt.Errorf("setting CustomActivator: %w", err) + } + if data.IconPath != "" { + if err := appIDKey.SetStringValue("IconUri", data.IconPath); err != nil { + return fmt.Errorf("setting IconUri: %w", err) + } + if err := appIDKey.SetStringValue("IconBackgroundColor", data.IconBackgroundColor); err != nil { + return fmt.Errorf("setting IconBackgroundColor: %w", err) + } + } + if data.ExePath != "" { + if err := setActivationExecutable(data.ExePath); err != nil { + return fmt.Errorf("setting activation executable: %w", err) + } + } + return nil +} + +// The Windows registry package uses empty string for the "(Default)" key. +const registryDefaultKey string = "" + +// setActivationExecutable registers the given executable path with the CLSID. +// Windows will invoke this executable for cold starts, eg when the application is not running. +func setActivationExecutable(exe string) error { + clsidKey, _, err := registry.CreateKey(registry.CURRENT_USER, filepath.Join("SOFTWARE", "Classes", "CLSID", iNotificationActivationCallbackGUID, "LocalServer32"), registry.SET_VALUE) + if err != nil { + return fmt.Errorf("setting the exe path for LocalServer reponse: %w", err) + } + defer func() { + err = errors.Join(err, clsidKey.Close()) + }() + if err := clsidKey.SetStringValue(registryDefaultKey, exe); err != nil { + return fmt.Errorf("seting LocalServer32: %w", err) + } + return nil +} diff --git a/toast.go b/toast.go @@ -175,7 +175,13 @@ func (n *Notification) Push() error { if err != nil { return err } - if err := bind.ConfigureRegistry(n.AppID, n.AppID, n.IconBackgroundColor, n.ActivationExe); err != nil { + if err := bind.SetAppData(bind.AppData{ + ID: n.AppID, + DisplayName: n.AppID, + IconPath: n.Icon, + IconBackgroundColor: n.IconBackgroundColor, + ExePath: n.ActivationExe, + }); err != nil { return fmt.Errorf("configuring registry: %w", err) } return bind.GenerateToast(n.AppID, xml)