go-toast

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

commit 7a12f331c3603b35f823adc894a52b6841ff5307
parent ec826271a616ba89de863097420ee259bf987b09
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Tue, 14 Mar 2023 18:32:07 +0800

toast: build out toast api

- cmd/toast-cli for manually testing notification features
- orchestrate Registry manipulation from Go
- generate XML template from Go
- add TODO to track remaining work items
- add bind package to wrap the raw DLL calls in nicer APIs
- impl unsafe data conversions
- impl text input
- impl select input
- impl high-level API based on go-toast
- simplify C code with simple macros

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

Diffstat:
ATODO.md | 9+++++++++
Acmd/toast-cli/main.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconstants.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mgen.go | 2+-
Ainternal/bind/bind.go | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/c-lib/toast.c | 433++++++++++++++++++++++++++-----------------------------------------------------
Dmain.go | 37-------------------------------------
Atemplate.go | 17+++++++++++++++++
Atemplate.go.tmpl | 35+++++++++++++++++++++++++++++++++++
Mtoast.dll | 0
Atoast.go | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 719 insertions(+), 331 deletions(-)

diff --git a/TODO.md b/TODO.md @@ -0,0 +1,8 @@ +# TODO list + +- generate GUID dynamically, or return from DLL with function +- refine the callback api + - should callback be mapped to their action + - or should we use a single callback for the entire notification +- properly handle exe cold activation +- move all orchestration to Go side and use C for the static vtable definitions +\ No newline at end of file diff --git a/cmd/toast-cli/main.go b/cmd/toast-cli/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "flag" + "fmt" + "time" + "toast" +) + +func main() { + var ( + n toast.Notification + wait bool + showActions bool + ) + + flag.StringVar(&n.AppID, "app-id", "Windows Toast CLI", "Application ID that identifies this app") + flag.StringVar(&n.Title, "title", "title", "Title") + flag.StringVar(&n.Body, "body", "body", "Body") + flag.StringVar(&n.Icon, "icon", "", "Icon") + flag.StringVar(&n.ActivationType, "activation-type", "", "Activation Type [protocol, foreground, background]") + flag.StringVar(&n.ActivationArguments, "activation-args", "", "Activation Arguments") + flag.StringVar(&n.Audio, "audio", "", "Audio to play when displaying the toast") + flag.BoolVar(&n.Loop, "loop", false, "Loop audio") + flag.StringVar(&n.Duration, "duration", "short", "Audio duration") + + flag.BoolVar(&wait, "wait", false, "Wait for activation") + flag.BoolVar(&showActions, "demo-actions", false, "Display preconfigured actions for demonstration") + + flag.Parse() + + n.Inputs = append(n.Inputs, toast.Input{ + ID: "reply-to:john-doe", + Title: "Reply", + Placeholder: "Reply to John Doe", + }) + + n.Inputs = append(n.Inputs, toast.Input{ + ID: "select-action", + Title: "Selection Action", + Placeholder: "Pick an action to perform", + Selections: []toast.InputSelection{ + { + ID: "1", + Content: "do thing one", + }, + { + ID: "2", + Content: "do thing two", + }, + { + ID: "3", + Content: "do thing three", + }, + }, + }) + + n.Actions = append(n.Actions, toast.Action{ + Type: toast.Foreground, + Content: "Send", + Arguments: "send", + }) + n.Actions = append(n.Actions, toast.Action{ + Type: toast.Foreground, + Content: "Close", + Arguments: "close", + }) + + if wait { + // Dummy goroutine to stop the runtime from thinking we are deadlocked when + // waiting on C code to call us back. + go func() { + for range time.NewTicker(time.Second).C { + } + }() + + done := make(chan struct{}) + defer func() { + <-done + }() + + n.OnActivate = func() { + fmt.Printf("OnActivate\n") + done <- struct{}{} + } + + } else { + n.OnActivate = func() { + fmt.Printf("OnActivate\n") + } + } + + if err := n.Push(); err != nil { + fmt.Printf("error: %v\n", err) + } +} diff --git a/constants.go b/constants.go @@ -0,0 +1,58 @@ +package toast + +import "errors" + +var ( + ErrorInvalidAudio error = errors.New("toast: invalid audio") + ErrorInvalidDuration = errors.New("toast: invalid duration") +) + +// toastAudio identifies audio that Windows can play. +type toastAudio = string + +const ( + Default toastAudio = "ms-winsoundevent:Notification.Default" + IM toastAudio = "ms-winsoundevent:Notification.IM" + Mail toastAudio = "ms-winsoundevent:Notification.Mail" + Reminder toastAudio = "ms-winsoundevent:Notification.Reminder" + SMS toastAudio = "ms-winsoundevent:Notification.SMS" + LoopingAlarm toastAudio = "ms-winsoundevent:Notification.Looping.Alarm" + LoopingAlarm2 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm2" + LoopingAlarm3 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm3" + LoopingAlarm4 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm4" + LoopingAlarm5 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm5" + LoopingAlarm6 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm6" + LoopingAlarm7 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm7" + LoopingAlarm8 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm8" + LoopingAlarm9 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm9" + LoopingAlarm10 toastAudio = "ms-winsoundevent:Notification.Looping.Alarm10" + LoopingCall toastAudio = "ms-winsoundevent:Notification.Looping.Call" + LoopingCall2 toastAudio = "ms-winsoundevent:Notification.Looping.Call2" + LoopingCall3 toastAudio = "ms-winsoundevent:Notification.Looping.Call3" + LoopingCall4 toastAudio = "ms-winsoundevent:Notification.Looping.Call4" + LoopingCall5 toastAudio = "ms-winsoundevent:Notification.Looping.Call5" + LoopingCall6 toastAudio = "ms-winsoundevent:Notification.Looping.Call6" + LoopingCall7 toastAudio = "ms-winsoundevent:Notification.Looping.Call7" + LoopingCall8 toastAudio = "ms-winsoundevent:Notification.Looping.Call8" + LoopingCall9 toastAudio = "ms-winsoundevent:Notification.Looping.Call9" + LoopingCall10 toastAudio = "ms-winsoundevent:Notification.Looping.Call10" + Silent toastAudio = "silent" +) + +// toastduration identifies toast duration for audio playback. +type toastDuration = string + +const ( + Short toastDuration = "short" + Long toastDuration = "long" +) + +// ActivationType identifies the method that Windows Runtime will use to handle +// notification interactions. +type ActivationType = string + +const ( + Protocol ActivationType = "protocol" + Foreground ActivationType = "foreground" + Background ActivationType = "background" +) diff --git a/gen.go b/gen.go @@ -1,2 +1,2 @@ //go:generate gosh ./internal/c-lib/build.sh -package main +package toast diff --git a/internal/bind/bind.go b/internal/bind/bind.go @@ -0,0 +1,185 @@ +// Package bind wraps the raw DLL functions in safe Go. +package bind + +import ( + "errors" + "fmt" + "path/filepath" + "syscall" + "unicode/utf16" + "unsafe" + + "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 +// it from the app ID. +const iNotificationActivationCallbackGUID = "{0F82E845-CB89-4039-BDBF-67CA33254C76}" + +var ( + toast = windows.MustLoadDLL("toast.dll") + procGenerateToast = toast.MustFindProc("GenerateToast") + procSetActivationCallback = toast.MustFindProc("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 +} + +// GenerateToast notification via the specified xml content. +// +// 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 := ole.RoInitialize(1); err != nil { + return fmt.Errorf("RoInitialize: %w", err) + } + + xmlStr, err := syscall.UTF16PtrFromString(xml) + if err != nil { + return fmt.Errorf("allocating xml string: %w", err) + } + appIDStr, err := syscall.UTF16PtrFromString(appID) + if err != nil { + return fmt.Errorf("allocating appID string: %w", err) + } + + // NOTE(jfm): I tried to verify whether is safe to pass strings directly to the proc + // and it appears to be, and I couldn't find advice to the contrary. + // However I would have expected the string to still be garbage collected? It's possible + // the Go runtime knows how to handle this case? Need to look into this more. + hr, _, _ := procGenerateToast.Call( + uintptr(unsafe.Pointer(appIDStr)), + uintptr(unsafe.Pointer(xmlStr)), + ) + if hr != ole.S_OK { + return ole.NewError(hr) + } + + return nil +} + +// ActivationCallback is a function that gets invoked when the toast is activated. +type ActivationCallback func(appUserModelId string, invokedArgs string, userData []UserData) + +// 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) { + callback := syscall.NewCallback(func( + this, + appUserModelId, + invokedArgs, + data unsafe.Pointer, + count uint32, + ) (ret uintptr) { + cb( + utf16PtrToString((*uint16)(appUserModelId)), + utf16PtrToString((*uint16)(invokedArgs)), + sliceUserDataFromUnsafe(data, int(count)), + ) + return + }) + 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 +} + +// 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/c-lib/toast.c b/internal/c-lib/toast.c @@ -16,12 +16,6 @@ DWORD dwMainThreadId = 0; /* - * Our AUMID and argument that tells our app when it was launched by interacting with a toast notification. - */ -#define APP_ID L"ToastActivatorPureC" -#define TOAST_ACTIVATED_LAUNCH_ARG "-ToastActivated" - -/* * The GUID that we associate with our factory that produces our INotificationActivationCallback interface. */ #define GUID_Impl_INotificationActivationCallback_Textual "0F82E845-CB89-4039-BDBF-67CA33254C76" @@ -29,27 +23,6 @@ DEFINE_GUID(GUID_Impl_INotificationActivationCallback, 0xf82e845, 0xcb89, 0x4039, 0xbd, 0xbf, 0x67, 0xca, 0x33, 0x25, 0x4c, 0x76); /* - * The XML that describes the notification that will be shown. Of course, this can be built at runtime, - * and more can be done with it, but for this basic example, this will suffice. - */ -const wchar_t wszBannerText[] = -L"<toast scenario=\"reminder\" " -L"activationType=\"foreground\" launch=\"action=mainContent\" duration=\"short\">\r\n" -L" <visual>\r\n" -L" <binding template=\"ToastGeneric\">\r\n" -L" <text><![CDATA[This is a demo notification]]></text>\r\n" -L" <text><![CDATA[It contains 2 lines of text]]></text>\r\n" -L" </binding>\r\n" -L" </visual>\r\n" -L" <actions>\r\n" -L" <input id=\"tbReply\" type=\"text\" placeHolderContent=\"Send a message to the app\"/>\r\n" -L" <action content=\"Send\" activationType=\"foreground\" arguments=\"action=reply\"/>\r\n" -L" <action content=\"Close app\" activationType=\"foreground\" arguments=\"action=closeApp\"/>\r\n" -L" </actions>\r\n" -L" <audio src=\"ms-winsoundevent:Notification.Default\" loop=\"false\" silent=\"false\"/>\r\n" -L"</toast>\r\n"; - -/* * IIDs of other interfaces we use throughout this example. */ DEFINE_GUID(IID_IToastNotificationManagerStatics, @@ -74,12 +47,18 @@ typedef struct Impl_IGeneric LONG64 dwRefCount; } Impl_IGeneric; -static ULONG STDMETHODCALLTYPE Impl_IGeneric_AddRef(Impl_IGeneric* _this) +static +ULONG +STDMETHODCALLTYPE +Impl_IGeneric_AddRef(Impl_IGeneric* _this) { return InterlockedIncrement64(&(_this->dwRefCount)); } -static ULONG STDMETHODCALLTYPE Impl_IGeneric_Release(Impl_IGeneric* _this) +static +ULONG +STDMETHODCALLTYPE +Impl_IGeneric_Release(Impl_IGeneric* _this) { LONG64 dwNewRefCount = InterlockedDecrement64(&(_this->dwRefCount)); if (!dwNewRefCount) free(_this); @@ -107,6 +86,24 @@ Impl_INotificationActivationCallback_QueryInterface( return S_OK; } +// ActivationCallback defines the function that will receive the activation data +// when the toast notification is activated (clicked). +typedef void (__stdcall *ActivationCallback)( + INotificationActivationCallback* _this, + LPCWSTR appUserModelId, + LPCWSTR invokedArgs, + const NOTIFICATION_USER_INPUT_DATA* data, + ULONG count +); + +ActivationCallback _activation_callback; + +// SetActivationCallback to use when the notification +__declspec(dllexport) +void SetActivationCallback(ActivationCallback cb) { + _activation_callback = cb; +} + /* * This is where the magic happens when someone interacts with our notification: this method will be called * (on another thread !!!). @@ -121,20 +118,8 @@ Impl_INotificationActivationCallback_Activate( const NOTIFICATION_USER_INPUT_DATA* data, ULONG count ) { - wprintf(L"Interacted with notification from AUMID \"%s\" with arguments: \"%s\". User input count: %d.\n", appUserModelId, invokedArgs, count); - if (!_wcsicmp(invokedArgs, L"action=closeApp")) - { - PostThreadMessageW(dwMainThreadId, WM_QUIT, 0, 0); - } - else if (!_wcsicmp(invokedArgs, L"action=reply")) - { - for (unsigned int i = 0; i < count; ++i) - { - if (!_wcsicmp(data[i].Key, L"tbReply")) - { - wprintf(L"Reply was \"%s\".\n", data[i].Value); - } - } + if (_activation_callback) { + _activation_callback(_this, appUserModelId, invokedArgs, data, count); } return S_OK; } @@ -218,33 +203,44 @@ static const IClassFactoryVtbl Impl_IClassFactory_Vtbl = { .CreateInstance = Impl_IClassFactory_CreateInstance }; +// Must checks the HRESULT value and jumps to cleanup not S_OK. +// +// This macro assumes the variable `HRESULT hr;` is in scope so +// it can set the failed value before returning. +#define Must(hresult) \ + if (FAILED(hresult)) { \ + hr = hresult; \ + goto cleanup; \ + } + +// Release invokes Release on the vtable of the supplied COM object. +#define Release(ident) \ + if (ident != NULL) { \ + ident->lpVtbl->Release(ident); \ + } + +// DeleteString invokes DeleteString on the provided HSTRING. +#define DeleteString(ident) \ + if (ident) { \ + WindowsDeleteString(ident); \ + } + +// Generate a Toast notification with the provided xml content. +// Register a callback with SetActivationCallback to handle notificaton interactions. +// +// TODO(jfm): We could move all the orchestration to Go and just have the Vtables (statically) +// defined in C. __declspec(dllexport) -void -GenerateToast() -{ +HRESULT +GenerateToast( + wchar_t* app_id, + wchar_t* xml +) { HRESULT hr = S_OK; Impl_IGeneric* pClassFactory = NULL; BOOL bOk = FALSE; dwMainThreadId = GetCurrentThreadId(); - BOOL bInvokedFromToast = FALSE; - - /* - * Initialize COM and Windows Runtime on this thread. Make sure that the threading models of the two match. - */ - if (SUCCEEDED(hr)) - { - hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); - } else { - printf("failed to"); - } - - if (SUCCEEDED(hr)) - { - hr = RoInitialize(RO_INIT_MULTITHREADED); - } else { - printf("failed to roinitialize\n"); - } - + /* * Allocate class factory. This factory produces our implementation of the INotificationActivationCallback interface. * This interface has an ::Activate member method that gets called when someone interacts with the toast notification. @@ -258,7 +254,7 @@ GenerateToast() pClassFactory->dwRefCount = 1; } } else { - printf("failed to allocate our IGeneric class factory\n"); + goto cleanup; } /* @@ -266,262 +262,114 @@ GenerateToast() * we associate our GUID with the class factory that provides our INotificationActivationCallback interface. */ DWORD dwCookie = 0; - if (SUCCEEDED(hr)) - { - hr = CoRegisterClassObject(&GUID_Impl_INotificationActivationCallback, pClassFactory, CLSCTX_LOCAL_SERVER, REGCLS_MULTIPLEUSE, &dwCookie); - } else { - printf("failed to register notification activation callback\n"); - } - - /* - * Construct the path to our EXE that will be used to launch it when something requests our interface. - * As said above, registration is dynamic - as long as this app runs, COM knows about the fact that this - * app implements our INotificationActivationCallback interface. The info here is used when this app has - * closed and someone clicks the toast notification for example; in that case, since our app is not - * running, thus CoRegisterClassObject was not called, COM needs info on what EXE contains the implementation - * of the interface, and we specify that here; without setting this, clicking on notifications will do nothing - */ - wchar_t wszExePath[MAX_PATH + 100]; - ZeroMemory(wszExePath, MAX_PATH + 100); - if (SUCCEEDED(hr)) - { - hr = (GetModuleFileNameW(NULL, wszExePath + 1, MAX_PATH) != 0 ? S_OK : E_FAIL); - } else { - printf("failed to get module file\n"); - } - - if (SUCCEEDED(hr)) - { - wszExePath[0] = L'"'; - wcscat_s(wszExePath, MAX_PATH + 100, L"\" " _T(TOAST_ACTIVATED_LAUNCH_ARG)); - } + Must(CoRegisterClassObject( + &GUID_Impl_INotificationActivationCallback, + pClassFactory, + CLSCTX_LOCAL_SERVER, + REGCLS_MULTIPLEUSE, + &dwCookie + )); - if (SUCCEEDED(hr)) - { - hr = HRESULT_FROM_WIN32(RegSetValueW(HKEY_CURRENT_USER, L"SOFTWARE\\Classes\\CLSID\\{" _T(GUID_Impl_INotificationActivationCallback_Textual) L"}\\LocalServer32", REG_SZ, wszExePath, wcslen(wszExePath) + 1)); - } else { - printf("failed to set registery value CLSID\n"); - } - - /* - * Here we set some info about our app and associate our AUMID with the GUID from above - * (the one that is associated with our class factory which produces our INotificationActivationCallback interface) - */ - if (SUCCEEDED(hr)) - { - hr = HRESULT_FROM_WIN32(RegSetKeyValueW(HKEY_CURRENT_USER, L"SOFTWARE\\Classes\\AppUserModelId\\" APP_ID, L"DisplayName", REG_SZ, L"Toast Activator Pure C Example", 31 * sizeof(wchar_t))); - } else { - printf("failed to set registery value AppUserModelId DisplayName\n"); - } - - if (SUCCEEDED(hr)) - { - hr = HRESULT_FROM_WIN32(RegSetKeyValueW(HKEY_CURRENT_USER, L"SOFTWARE\\Classes\\AppUserModelId\\" APP_ID, L"IconBackgroundColor", REG_SZ, L"FF00FF00", 9 * sizeof(wchar_t))); - } else { - printf("failed to set registery value AppUserModelId IconBackgroundColor\n"); - } - - if (SUCCEEDED(hr)) - { - hr = HRESULT_FROM_WIN32(RegSetKeyValueW(HKEY_CURRENT_USER, L"SOFTWARE\\Classes\\AppUserModelId\\" APP_ID, L"CustomActivator", REG_SZ, L"{" _T(GUID_Impl_INotificationActivationCallback_Textual) L"}", 39 * sizeof(wchar_t))); - } else { - printf("failed to set registery value AppUserModelId CustomActivator\n"); - } - - /* - * We will display a notification only when this app is launched standalone (not by interacting with a notification) - */ HSTRING_HEADER hshAppId; HSTRING hsAppId = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = WindowsCreateStringReference(APP_ID, wcslen(APP_ID), &hshAppId, &hsAppId); - } else { - printf("failed to create string reference for app id\n"); - } + + Must(WindowsCreateStringReference(app_id, wcslen(app_id), &hshAppId, &hsAppId)); HSTRING_HEADER hshToastNotificationManager; HSTRING hsToastNotificationManager = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = WindowsCreateStringReference( - RuntimeClass_Windows_UI_Notifications_ToastNotificationManager, - (UINT32)wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotificationManager), - &hshToastNotificationManager, &hsToastNotificationManager - ); - } else { - printf("failed to create string reference for ToastNotificationManager runtime class: %x\n", hr); - } + + Must(WindowsCreateStringReference( + RuntimeClass_Windows_UI_Notifications_ToastNotificationManager, + (UINT32)wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotificationManager), + &hshToastNotificationManager, &hsToastNotificationManager + )); __x_ABI_CWindows_CUI_CNotifications_CIToastNotificationManagerStatics* pToastNotificationManager = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = RoGetActivationFactory(hsToastNotificationManager, &IID_IToastNotificationManagerStatics, (LPVOID*)&pToastNotificationManager); - } else { - printf("failed to RoGetActivcationFactory for ToastNotificationManagerStatics\n"); - } + + Must(RoGetActivationFactory( + hsToastNotificationManager, + &IID_IToastNotificationManagerStatics, + (LPVOID*)&pToastNotificationManager + )); __x_ABI_CWindows_CUI_CNotifications_CIToastNotifier* pToastNotifier = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pToastNotificationManager->lpVtbl->CreateToastNotifierWithId(pToastNotificationManager, hsAppId, &pToastNotifier); - } else { - printf("failed to CreateToastNotifierWithId\n"); - } + + Must(pToastNotificationManager->lpVtbl->CreateToastNotifierWithId( + pToastNotificationManager, + hsAppId, + &pToastNotifier + )); HSTRING_HEADER hshToastNotification; HSTRING hsToastNotification = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = WindowsCreateStringReference(RuntimeClass_Windows_UI_Notifications_ToastNotification, (UINT32)wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotification), &hshToastNotification, &hsToastNotification); - } else { - printf("failed to create string reference for ToastNotification\n"); - } + + Must(WindowsCreateStringReference( + RuntimeClass_Windows_UI_Notifications_ToastNotification, + (UINT32)wcslen(RuntimeClass_Windows_UI_Notifications_ToastNotification), + &hshToastNotification, + &hsToastNotification + )); __x_ABI_CWindows_CUI_CNotifications_CIToastNotificationFactory* pNotificationFactory = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = RoGetActivationFactory(hsToastNotification, &IID_IToastNotificationFactory, (LPVOID*)&pNotificationFactory); - } else { - printf("failed to RoGetActivationFactory ToastNotification\n"); - } + + Must(RoGetActivationFactory( + hsToastNotification, + &IID_IToastNotificationFactory, + (LPVOID*)&pNotificationFactory + )); HSTRING_HEADER hshXmlDocument; HSTRING hsXmlDocument = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = WindowsCreateStringReference(RuntimeClass_Windows_Data_Xml_Dom_XmlDocument, (UINT32)wcslen(RuntimeClass_Windows_Data_Xml_Dom_XmlDocument), &hshXmlDocument, &hsXmlDocument); - } + + Must(WindowsCreateStringReference( + RuntimeClass_Windows_Data_Xml_Dom_XmlDocument, + (UINT32)wcslen(RuntimeClass_Windows_Data_Xml_Dom_XmlDocument), + &hshXmlDocument, + &hsXmlDocument + )); HSTRING_HEADER hshBanner; HSTRING hsBanner = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = WindowsCreateStringReference(wszBannerText, (UINT32)wcslen(wszBannerText), &hshBanner, &hsBanner); - } + + Must(WindowsCreateStringReference( + xml, + (UINT32)wcslen(xml), + &hshBanner, + &hsBanner + )); IInspectable* pInspectable = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = RoActivateInstance(hsXmlDocument, &pInspectable); - } + Must(RoActivateInstance(hsXmlDocument, &pInspectable)); __x_ABI_CWindows_CData_CXml_CDom_CIXmlDocument* pXmlDocument = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pInspectable->lpVtbl->QueryInterface(pInspectable, &IID_IXmlDocument, &pXmlDocument); - } + Must(pInspectable->lpVtbl->QueryInterface(pInspectable, &IID_IXmlDocument, &pXmlDocument)); __x_ABI_CWindows_CData_CXml_CDom_CIXmlDocumentIO* pXmlDocumentIO = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pXmlDocument->lpVtbl->QueryInterface(pXmlDocument, &IID_IXmlDocumentIO, &pXmlDocumentIO); - } + Must(pXmlDocument->lpVtbl->QueryInterface(pXmlDocument, &IID_IXmlDocumentIO, &pXmlDocumentIO)); - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pXmlDocumentIO->lpVtbl->LoadXml(pXmlDocumentIO, hsBanner); - } + Must(pXmlDocumentIO->lpVtbl->LoadXml(pXmlDocumentIO, hsBanner)); __x_ABI_CWindows_CUI_CNotifications_CIToastNotification* pToastNotification = NULL; - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pNotificationFactory->lpVtbl->CreateToastNotification(pNotificationFactory, pXmlDocument, &pToastNotification); - } - - if (SUCCEEDED(hr) && !bInvokedFromToast) - { - hr = pToastNotifier->lpVtbl->Show(pToastNotifier, pToastNotification); - } + Must(pNotificationFactory->lpVtbl->CreateToastNotification(pNotificationFactory, pXmlDocument, &pToastNotification)); - if (SUCCEEDED(hr)) - { - MSG msg; - while (GetMessageW(&msg, NULL, 0, 0) > 0) - { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - } - - if (pToastNotification) - { - pToastNotification->lpVtbl->Release(pToastNotification); - } - if (pXmlDocumentIO) - { - pXmlDocumentIO->lpVtbl->Release(pXmlDocumentIO); - } - if (pXmlDocument) - { - pXmlDocument->lpVtbl->Release(pXmlDocument); - } - if (pInspectable) - { - pInspectable->lpVtbl->Release(pInspectable); - } - if (hsBanner) - { - WindowsDeleteString(hsBanner); - } - if (hsXmlDocument) - { - WindowsDeleteString(hsXmlDocument); - } - if (pNotificationFactory) - { - pNotificationFactory->lpVtbl->Release(pNotificationFactory); - } - if (hsToastNotification) - { - WindowsDeleteString(hsToastNotification); - } - if (pToastNotifier) - { - pToastNotifier->lpVtbl->Release(pToastNotifier); - } - if (pToastNotificationManager) - { - pToastNotificationManager->lpVtbl->Release(pToastNotificationManager); - } - if (hsToastNotificationManager) - { - WindowsDeleteString(hsToastNotificationManager); - } - if (hsAppId) - { - WindowsDeleteString(hsAppId); - } - if (dwCookie) - { - CoRevokeClassObject(dwCookie); - } - if (pClassFactory) - { - pClassFactory->lpVtbl->Release(pClassFactory); - } - RoUninitialize(); - CoUninitialize(); - - return; -} - - -// These are just testing the callback functionality. -// We can provide go callbacks to the DLL! - -typedef int (__stdcall *GoCallback)(int data); - -GoCallback _cb; - -__declspec(dllexport) -void Set(GoCallback cb) { - _cb = cb; -} + Must(pToastNotifier->lpVtbl->Show(pToastNotifier, pToastNotification)); + +cleanup: + + Release(pToastNotification); + Release(pXmlDocumentIO); + Release(pXmlDocument); + Release(pInspectable); + DeleteString(hsBanner); + DeleteString(hsXmlDocument); + Release(pNotificationFactory); + DeleteString(hsToastNotification); + Release(pToastNotifier); + Release(pToastNotificationManager); + DeleteString(hsToastNotificationManager); + DeleteString(hsAppId); -__declspec(dllexport) -void Fire(void) { - _cb(1); + return hr; } // Init exports RoInitialize. For go-ole we can use the ole provided function instead. @@ -531,10 +379,9 @@ HRESULT Init(RO_INIT_TYPE roInit) { return RoInitialize(roInit); } -// Uninit exports RoUninitialize. For go-ole we can use the ole provided function instead. +// Deinit exports RoUninitialize. For go-ole we can use the ole provided function instead. // This allows the consumer to handle uninitialization without relying on a third-party. __declspec(dllexport) -void Uninit() { +void Deinit() { return RoUninitialize(); } - diff --git a/main.go b/main.go @@ -1,37 +0,0 @@ -package main - -import "C" - -import ( - "fmt" - "syscall" - - "golang.org/x/sys/windows" -) - -var ( - toast = windows.NewLazyDLL("toast.dll") - generateToast = toast.NewProc("GenerateToast") - setCallback = toast.NewProc("Set") - fireCallback = toast.NewProc("Fire") - // This is testing whether we can use the RoInitialize wrapper when the ole.RoInitialize function doesn't work. - // And it appears to work just fine! - roinit = toast.NewProc("Wrap_RoInitialize") -) - -func main() { - generateToast.Call(uintptr(0)) - - fmt.Printf("building callback\n") - callback := syscall.NewCallback(func(data int) (ret uintptr) { - fmt.Printf("from callback: %v\n", data) - return - }) - - fmt.Printf("setting callback\n") - setCallback.Call(callback) - - fmt.Printf("firing callback\n") - fireCallback.Call() - -} diff --git a/template.go b/template.go @@ -0,0 +1,17 @@ +package toast + +import ( + _ "embed" + "text/template" +) + +//go:embed template.go.tmpl +var tmpl string + +// For more information about the schema: +// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root +var toastTemplate = func() *template.Template { + t := template.New("toast") + template.Must(t.Parse(tmpl)) + return t +}() diff --git a/template.go.tmpl b/template.go.tmpl @@ -0,0 +1,34 @@ +<toast activationType="{{.ActivationType}}" launch="{{.ActivationArguments}}" duration="{{.Duration}}"> + <visual> + <binding template="ToastGeneric"> + {{if .Icon}} + <image placement="appLogoOverride" src="{{.Icon}}" /> + {{end}} + {{if .Title}} + <text><![CDATA[{{.Title}}]]></text> + {{end}} + {{if .Body}} + <text><![CDATA[{{.Body}}]]></text> + {{end}} + </binding> + </visual> + {{if ne .Audio "silent"}} + <audio src="{{.Audio}}" loop="{{.Loop}}" /> + {{else}} + <audio silent="true" /> + {{end}} + {{if .Actions}} + <actions> + {{range .Inputs}} + <input id="{{.ID}}" title="{{.Title}}" placeHolderContent="{{.Placeholder}}" {{if .Selections}} type="selection" {{else}} type="text" {{end}}> + {{range .Selections}} + <selection id="{{.ID}}" content="{{.Content}}" /> + {{end}} + </input> + {{end}} + {{range .Actions}} + <action activationType="{{.Type}}" content="{{.Content}}" arguments="{{.Arguments}}" /> + {{end}} + </actions> + {{end}} +</toast> +\ No newline at end of file diff --git a/toast.dll b/toast.dll Binary files differ. diff --git a/toast.go b/toast.go @@ -0,0 +1,178 @@ +package toast + +import ( + "bytes" + "fmt" + "toast/internal/bind" +) + +// Notification +// +// The toast notification data. The following fields are strongly recommended; +// - AppID +// - Title +// +// If no toastAudio is provided, then the toast notification will be silent. +// You can set the toast to have a default audio by setting "Audio" to "toast.Default", or if your go app takes +// user-provided input for audio, call the "toast.Audio(name)" func. +// +// 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". +// +// If no Title is provided, but a Message is, the message 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 +// system, not the working directory). +// +// If you would like the toast to call an external process/open a webpage, then you can set ActivationArguments +// to the uri you would like to trigger when the toast is clicked. For example: "https://google.com" would open +// the Google homepage when the user clicks the toast notification. +// By default, clicking the toast just hides/dismisses it. +// +// The following would show a notification to the user letting them know they received an email, and opens +// gmail.com when they click the notification. It also makes the Windows 10 "mail" sound effect. +// +// toast := toast.Notification{ +// AppID: "Google Mail", +// Title: email.Subject, +// Message: email.Preview, +// Icon: "C:/Program Files/Google Mail/icons/logo.png", +// ActivationArguments: "https://gmail.com", +// Audio: toast.Mail, +// } +// +// err := toast.Push() +type Notification struct { + // The name of your app. This value shows up in Windows 10's Action Centre, so make it + // something readable for your users. It can contain spaces, however special characters + // (eg. é) are not supported. + AppID string + + // The main title/heading for the toast notification. + Title string + + // The single/multi line message to display for the toast notification. + Body string + + // An optional path to an image on the OS to display to the left of the title & message. + Icon string + + // The type of notification level action (like toast.Action) + ActivationType ActivationType + + // The activation/action arguments (invoked when the user clicks the notification) + ActivationArguments string + + // Optional text input to display before the actions. + Inputs []Input + + // Optional action buttons to display below the notification title & message. + Actions []Action + + // The audio to play when displaying the toast + Audio toastAudio + + // Whether to loop the audio (default false) + Loop bool + + // How long the toast should show up for (short/long) + Duration toastDuration + + OnActivate func() +} + +// Input +// +// Defines an input element, generally a text input. +// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-input for more info. +// +// Inputs are by default textual, however if selections are supplied the input will be rendered +// as a select input. +type Input struct { + ID string + Title string + Placeholder string + Selections []InputSelection +} + +// InputSelection +// +// Defines an input selection for use with select inputs. +// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-selection for more info. +type InputSelection struct { + ID string + Content string +} + +// Action +// +// Defines an actionable button. +// See https://msdn.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-adaptive-interactive-toasts for more info. +// +// toast.Action{"protocol", "Open Maps", "bingmaps:?q=sushi"} +// +// TODO(jfm): we can likely support an activation callback directly in the Action. +type Action struct { + Type ActivationType + Content string + Arguments string +} + +func (n *Notification) applyDefaults() { + if n.ActivationType == "" { + n.ActivationType = Protocol + } + if n.Duration == "" { + n.Duration = Short + } + if n.Audio == "" { + n.Audio = Default + } +} + +func (n *Notification) buildXML() (string, error) { + var out bytes.Buffer + err := toastTemplate.Execute(&out, n) + if err != nil { + return "", err + } + return out.String(), nil +} + +// Push the notification to the Windows Runtime via the COM API. +// +// notification := toast.Notification{ +// AppID: "Example App", +// Title: "My notification", +// Message: "Some message about how important something is...", +// Icon: "go.png", +// Actions: []toast.Action{ +// {"protocol", "I'm a button", ""}, +// {"protocol", "Me too!", ""}, +// }, +// } +// err := notification.Push() +// if err != nil { +// log.Fatalln(err) +// } +func (n *Notification) Push() error { + n.applyDefaults() + xml, err := n.buildXML() + if err != nil { + return err + } + if n.OnActivate != nil { + bind.SetActivationCallback(func(appUserModelId, invokedArgs string, userData []bind.UserData) { + fmt.Printf("appUserModelId: %q\n", appUserModelId) + fmt.Printf("invokedArgs: %q\n", invokedArgs) + fmt.Printf("userData: %q\n", userData) + n.OnActivate() + }) + } + if err := bind.ConfigureRegistry(n.AppID, n.AppID, "FFFFFFFF", ""); err != nil { + return fmt.Errorf("configuring registry: %w", err) + } + return bind.GenerateToast(n.AppID, xml) +}