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