Proper Windows Notifications on Electron
Tags: electron, typescript
Published: 2022-12-22 04:11:13

## Current state
Electron has a good [starter guide to notifications](https://www.electronjs.org/docs/latest/tutorial/notifications), but it misses a few important points and leaves out a lot of detail.

It's pretty easy to pop a notification, then handle when the user clicks the toast. It fails on some other basic scenarios:

* If the user clicks a notification in the Action Center when the app is not running, it does nothing.
* If the user has closed and reopened the app since the notification was sent, then clicks the notification, it doesn't work.
* If the user has dismissed the notification on a different device, nothing happens. It's frustrating to have to deal with a notification multiple times.

## NodeRT
We need something deeper than what Electron gives us out of the box, which means using [NodeRT](https://github.com/NodeRT/NodeRT).

There's [electron-windows-notifications](https://github.com/felixrieseberg/electron-windows-notifications), though it's wrapping a pretty old version of NodeRT, and it does not expose some of the APIs we'll need. I decided to directly use the generated NodeRT APIs.

```
@nodert-win10-rs4/windows.data.xml.dom
@nodert-win10-rs4/windows.ui.notifications
```

These are the bare minimum. You can also target other API levels like `@nodert-win10-au`, etc. You'll need to [set up node-gyp](https://github.com/nodejs/node-gyp) with Python and Windows Build Tools and install the Windows 10 SDK corresponding to the version of the NodeRT package you pulled in.

Note that you need to be using electron-rebuild for this to work. Electron Forge incorporates this automatically and is the easiest path. If you try to add these in a vanilla Electron app, the `NODE_MODULE_VERSION` of the built native add-on won't match up with the Electron runtime version. Since Electron uses a forked version of Node, these version numbers are staggered and never overlap, thus the need to rebuild the native modules specifically for Electron.

One more thing I noticed was that yarn seemed to work better than npm at handling these native Node modules. I'd always get errors about binding files being missing with `npm i` but yarn seemed to handle them just fine.

Lastly, NodeRT only works from the `main` process in electron, so you'll need to call it there and do IPC with the render process.

## Protocol activation
In the notification's toastXml, you can specify `activationType="protocol"`:

```xml
<toast launch="myapp:navigate?key=value" activationType="protocol">
  <visual>
    <binding template="ToastText01">
      <text id="1">Click to open the app</text>
    </binding>
  </visual>
  <actions>
    <action content="Action 1" activationType="protocol" arguments="myapp:action1" />
    <action content="Action 2" activationType="protocol" arguments="myapp:action2" />
  </actions>
</toast>
```

Note that `myapp:action1` is a complete protocol launch URI; `myapp` is the scheme and `action1` is the path. `//` is only used when specifying the optional authority section.

You can follow [the official guide to setting up your app to handle deep protocol links](https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app). But one confusing part of that is it shows under the Windows section a listener for the `open-url` event. But that event just doesn't get fired on Windows; it's only for Mac OS. For Windows, you need to check the process launch arguments in two places:

1) On app launch (if you got the single instance lock).
2) In the `second-instance` event handler, using the command line arguments passed to it.

The process launch argument can be in a different position when running in developer mode; I found it best to search all the given command line arguments for something that starts with `myapp:`.

Finally, I would recommend not adding direct activation callbacks to the notifications you are sending; to avoid double-handling with the protocol activations.

## Focus workaround
After implementing the protocol activation, I noticed the window wouldn't come to the foreground on clicking the notification, but instead just blink in the taskbar. Eventually I found that this was caused by a bug in Windows. Normally [you can call the native `AllowSetForegroundWindow` method to permit another application to take foreground focus](https://devblogs.microsoft.com/oldnewthing/20090220-00/?p=19083). But when an app is started from a Windows notification activation, this call fails with `ERROR_ACCESS_DENIED`. One of a [list of conditions has to be met](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-allowsetforegroundwindow) for the call to succeed.

I saw that Chromium had [worked around this by sending a key press event](https://bugs.chromium.org/p/chromium/issues/detail?id=837796) that satisfies this check and lets `AllowSetForegroundWindow` succeed.

I published [a Node package to work around this](https://www.npmjs.com/package/windows-dummy-keystroke), which you can use like so:

```
import { sendDummyKeystroke } from 'windows-dummy-keystroke';
sendDummyKeystroke();
```

Call it before `requestSingleInstanceLock()` so the `AllowSetForegroundWindow` call it's making will succeed.

## Attribution

Windows needs a way to attribute a notification to a particular app, which it uses to group notifications and show them with the app's name and icon.

It does this through the Application User Model ID or AUMID. You are expected to pass your app's AUMID on the `ToastNotificationManager.createToastNotifier()` call.

If you read [the documentation on the `name` property for the Windows Squirrel maker config](https://js.electronforge.io/interfaces/_electron_forge_maker_squirrel.InternalOptions.Options.html#name), you might be confused:

> Windows Application Model ID (appId).
> Defaults to the name field in your app's package.json file.

This is not true! This `name` turns out to just be a single component of the generated AUMID, which is in the form `com.squirrel.<makerConfigName>.<packageName>` . `<packageName>` comes from package.json. So you should end up with something like this:

```typescript
const notifier = ToastNotificationManager.createToastNotifier('com.squirrel.MyApp.MyApp');
notifier.show(toast);
```

The mechanism for the attribution is shortcut files. Windows will look for a shortcut in the Start Menu that has the `System.AppUserModel.ID` property. It uses the name and icon from the matching shortcut. Squirrel automatically adds `System.AppUserModel.ID` on the shortcut it generates. You can check this with the [lnk-parser tool](https://code.google.com/archive/p/lnk-parser/). Your shortcuts can be found in `%appdata%\Microsoft\Windows\Start Menu`.

## Example app
I've got a [minimal example app](https://github.com/RandomEngy/electron-protocol-notification) to demonstrate the end-to-end protocol launch setup. I don't have this working for Mac OS yet but I think all it needs is an `open-url` event listener as per the docs.

## Recalling notifications
Recalling a notification that you've already sent but is no longer relevant is a really useful feature. It's so annoying when obsolete notifications just languish and force you to clean them up; this is the major reason I went to all this extra trouble with NodeRT.

To be able to recall your notification, you need to set a "tag" and a "group" on the notification when you send it.

```typescript
const toast = new ToastNotification(xmlDocument);

toast.tag = 'MyTag';
toast.group = 'default';

notifier.show(toast);
```

The tag you can use to target a specific notification to recall. The group can be any string; we have to specify it because you need to use [this specific override of ToastNotifictionHistory.Remove()](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotificationhistory.remove?view=winrt-22621#windows-ui-notifications-toastnotificationhistory-remove(system-string-system-string-system-string)).

```typescript
ToastNotificationManager.history.remove(
  'MyTag',
  'default',
  'com.squirrel.MyApp.MyApp');
```

It's important to use the overload that specifies the AUMID, otherwise the call will be rejected with an "Element not found" error indicating that you don't have permissions to mess with notification history.

## Scheduling notifications

The [ToastNotifier.AddToSchedule() official docs](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotifier.addtoschedule?view=winrt-22621) claim that apps not built entirely on WinRT can't call it. But it actually seems to work just fine:

```typescript
const date = new Date();
date.setMinutes(date.getMinutes() + 10);
const scheduledToast = new ScheduledToastNotification(xmlDocument, date);

notifier.addToSchedule(scheduledToast);
```

This was handy in my case where I needed to send reminder notifications at specific times but did not want to have to require the app to be running at all times.

There is the risk that Microsoft considers this a bug and "fixes" it to match the documentation, though they've [been aware of it since 2019](https://github.com/MicrosoftDocs/winrt-api/issues/1249) and have not done anything.

## Limitations / Going further
### Interactive notifications via COM server
Windows also lets you type directly into a notification, which is something that a pure protocol approach doesn't handle. [electron-windows-interactive-notifications](https://github.com/felixrieseberg/electron-windows-interactive-notifications) ostensibly handles this but it hasn't been updated in a while and the author is not responding to issues. I dove in here for a bit, implemented two workarounds, invented a third but never successfully got my app to launch. My app doesn't really need the interactive notifications, so I abandoned this approach. If you want to try and tackle this, Chromium's [notification_helper.exe](https://chromium-review.googlesource.com/c/chromium/src/+/868750) might be a helpful reference.

### Tracking dismissal
In an ideal world we would be able to track when a user has dismissed a Windows notification then update other devices. But the "dismissed" callback function is invoked _when the toast message disappears_. It fires whether or not the user interacts with it, and there is no indication of whether it was an activation, an explicit dismissal from the user, or just it going away on its own. I dug in the event but found nothing that would tell me.

On top of that, the user might dismiss the notification in the Action center, which does not invoke the dismiss callback at all. I dug around in the WinRT APIs and apparently the correct way to track this is to register a background task with a ToastNotificationHistoryChanged trigger. But there appeared to be no straightforward way to register this from NodeRT and I couldn't find anyone who's actually done this yet.