DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two options on our desktop notification name three platforms between them, and not one of them is macOS

Notifio is a desktop app that watches rental search pages and tells you when a new listing appears. Telling you happens three times per find, and I have written before about how one find fans out into all three channels. That post was about the ordering. This one is about four keys in an object, two of which I now believe do nothing for anybody who has ever run the app.

The whole local channel is 83 lines. This is the part that matters:

const notification = new bits.Notification({
  title,
  body,
  urgency: 'critical',
  timeoutType: 'never',
});
notification.on('click', () => {
  bits.shell.openExternal(first.url).catch(() => {});
});
notification.show();
Enter fullscreen mode Exit fullscreen mode

title and body are the message. urgency and timeoutType are a product promise: this banner is important, and it must still be on screen when you come back from the kitchen. On a housing site, a banner you did not see is a room somebody else replied to first.

What our own dependency says about those two keys

Notifio is on Electron 43.1.1. Its bundled electron.d.ts is 26,000 lines of generated typings, and every platform-specific option carries a @platform tag. Here are ours, from NotificationConstructorOptions:

/**
 * The timeout duration of the notification. Can be 'default' or 'never'.
 *
 * @platform linux,win32
 */
timeoutType?: ('default' | 'never');

/**
 * The urgency level of the notification. Can be 'normal', 'critical', or 'low'.
 *
 * @platform linux,win32
 */
urgency?: ('normal' | 'critical' | 'low');
Enter fullscreen mode Exit fullscreen mode

Three platform tags between two options: linux, win32, linux, win32. No darwin.

And here is what we ship, from the build block in app/package.json:

"win": { "target": ["nsis"], "artifactName": "notifio-setup.exe" },
"mac": {
  "target": [{ "target": "dmg", "arch": ["arm64", "x64"] }],
  "artifactName": "notifio-${arch}.dmg"
}
Enter fullscreen mode Exit fullscreen mode

Two platforms, three installers, no Linux target. So one of the two options is tagged for a platform we have never built for, and both of them are untagged for the platform half our installers are made for. The macOS banner gets title and body, and the two keys that were supposed to make it persistent are, as far as the typings go, inert.

The typings disagree with themselves about one of them

While checking the above I read the same file twice, because urgency is described in two places. The constructor options interface at line 22293 says @platform linux,win32. The Notification class property at line 10271 says something else:

/**
 * A `string` property representing the urgency level of the notification. Can be
 * 'normal', 'critical', or 'low'.
 *
 * Default is 'low' - see NotifyUrgency for more information.
 *
 * @platform linux
 */
urgency: ('normal' | 'critical' | 'low');
Enter fullscreen mode Exit fullscreen mode

One interface says Linux and Windows, the other says Linux, and NotifyUrgency is a freedesktop notification spec concept, which is a hint about which of the two is older. I cannot settle it from this desk: I have no Linux build and the Windows machine is not the one I write on. What I can settle is the part that matters for the product, which is that neither reading includes darwin. We pass 'critical' against a documented default of 'low', which is a deliberate escalation that only Linux is documented to honour, on the one platform we have never built an installer for.

Dead configuration is worse than no configuration, because it reads as a decision. Anybody reading this file, me included six months from now, sees urgency: 'critical' and concludes that the persistence question has been handled.

What macOS actually does with the banner

It is the user's call, not ours. In System Settings, Notifications, every app gets a style: none, banners that disappear on their own, or alerts that stay until dismissed. Notifio asks for alert behaviour in code and gets whatever that setting says, which for a freshly installed app is banners. There is no Electron option that overrides it, and that is correct behaviour on Apple's part, it is just not what our code reads like.

So on macOS the honest statement of our local channel is: a banner appears, it may vanish after a few seconds, and the durable record is the alert email plus the recent finds list inside the app. The app keeps the last 200 finds on disk precisely so that "I missed the banner" is recoverable, which is the part of the design that is actually carrying the promise.

What the file gets right

Reading it again with the platform tags in mind, the rest of the 83 lines hold up better than the options object does.

One banner per search per cycle, not one per listing:

const title =
  finds.length === 1 ? `New listing on ${siteName}` : `${finds.length} new listings on ${siteName}`;
const body =
  finds.length === 1
    ? first.title || first.url
    : finds.slice(0, 3).map((f) => f.title || f.url).join('\n');
Enter fullscreen mode Exit fullscreen mode

Five banners for five rooms in one search is a notification people switch off, so the count goes in the title and three titles go in the body. The count in the title is what tells you the body is a sample rather than the whole list, which is the one thing that makes truncating to three acceptable.

Electron is required lazily, inside a try:

function electron(): ElectronBits | null {
  try {
    return require('electron') as ElectronBits;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The monitor runs outside Electron during development, and a missing notification must never fail a poll. The same thinking is why isSupported() is checked in its own try and why show() is wrapped so a failure logs a line instead of throwing into the cycle that found the listing.

And the channel is opt-out, not opt-in, which in the call site is one !==:

if (fresh.length > 0 && config.notifications?.desktop !== false) {
  notifyFinds(site.name, fresh, log);
}
Enter fullscreen mode Exit fullscreen mode

An absent key means on. For a config file that predates the feature, that is the right default: the people running an older config are exactly the people who have never been offered the switch.

What I would change, from the same file I was reading

The twelve platform-tagged options in NotificationConstructorOptions are not short of things we could use. Eight of them mention darwin. We set none of those, and two of the four that never mention it.

Three I would reach for first:

  • id (darwin, win32). A unique identifier that maps to UNNotificationRequest.identifier on macOS and the toast Tag on Windows. We already have a stable listing id, and with it Notification.remove() becomes possible, so a banner for a listing that has gone can be withdrawn instead of sitting there.
  • groupId (darwin, win32). Maps to the macOS threadIdentifier and the Windows toast Group. The natural value is the search, which means Notification Center would group "Kamernet, Leiden" separately from "Pararius, Rotterdam" instead of stacking everything under the app.
  • actions (darwin, win32). An "Open" action per banner, which matters because of the one real gap in the click handler: clicking opens first.url, the newest find. If the cycle turned up five listings, the other four are reachable only in the app or the email. The banner quietly decides for you which room is worth looking at.

The cheapest change is still the deletion. Drop urgency, leave timeoutType, and the file stops claiming to have solved something the operating system is not going to let it solve.

You can go and look at all of this from the outside: the installers are on the download page, the licence and the prices are on the pricing page, and the questions about leaving it running in the background are on the help page. Per-site setup pages are at notifio.app/alerts, including Kamernet and Pararius, and if you want the lifecycle story that sits underneath all of this, it is in closing our window does not quit our app.

Top comments (0)