DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four ways a desktop notification can be unavailable, and none of them may fail a poll

Notifio is a desktop app that watches rental listing pages and tells you when a new one appears. On a housing site the gap between hearing about a room and somebody else having replied is the whole game, so the local notification is the fastest channel the app has: no network round trip, no inbox, no push service.

The module that sends it is 83 lines. Here is the honest accounting of what those lines do: four of them construct a notification, and about twenty exist because the notification might not be possible, and a poll cycle that found a listing must not fail just because it could not announce it.

The lazy require

interface ElectronBits {
  Notification: {
    new (options: Record<string, unknown>): {
      show(): void;
      on(event: string, listener: () => void): void;
    };
    isSupported(): boolean;
  };
  shell: { openExternal(url: string): Promise<void> };
}

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

A require in a try/catch, inside a function, called at send time rather than at module load. Three things going on:

It is a function, not a top-level import, because a top-level import { Notification } from 'electron' fails at module load, and a module that cannot load takes every importer down with it. Here the failure is a null return and the caller moves on.

It is require, not import, because the whole point is to evaluate it conditionally and keep a synchronous call site. await import('electron') would work and would make notifyFinds async, which pushes the question of what to do about a rejected promise up into the monitor, which is the one place that must not care.

The ElectronBits interface is hand-written rather than using Electron's own types. It names the four things this file touches: the Notification constructor, isSupported, the on and show methods, and shell.openExternal. That is a deliberate narrowing. The module's dependency on Electron is four members wide, it says so in the type, and the compiler enforces that nothing drifts into using a fifth.

Why does any of this matter? The comment says:

Electron is required lazily. The monitor can be run outside Electron for development, and a missing notification is never a reason to fail a poll.

The monitor is runnable as plain Node, outside the Electron shell, which is how most of the work on it gets done. In that process require('electron') throws. Without the try/catch, the choice would be to either not have notifications in the app or not be able to run the monitor from a terminal, and neither of those is a trade worth making for a banner.

The four gates

const bits = electron();
if (!bits?.Notification) return;
try {
  if (!bits.Notification.isSupported()) return;
} catch {
  return;
}
Enter fullscreen mode Exit fullscreen mode
  1. electron() returned null. Not running under Electron.
  2. It returned something, but there is no Notification on it. This is the paranoid one, and it exists because the require succeeded and therefore told us nothing about what we got.
  3. isSupported() returned false. The documented way to ask whether the OS will show a notification at all.
  4. isSupported() threw. Which is the gate people leave out.

That fourth one is the interesting line. Notification.isSupported() is a capability check, and capability checks feel like they cannot fail, because the whole job of the function is to answer a question instead of raising. But it is a main-process call that touches platform notification APIs, and there is no contract anywhere promising that a call into the OS notification subsystem cannot throw on a machine with a broken or unusual desktop environment. Wrapping it costs three lines and converts "the app crashed while trying to find out whether it could show a banner" into "no banner".

Then the construction, in its own try/catch:

try {
  const notification = new bits.Notification({ ... });
  notification.on('click', () => { ... });
  notification.show();
} catch (err) {
  logFn(`[notify] Desktop notification failed: ${err instanceof Error ? err.message : String(err)}`);
}
Enter fullscreen mode Exit fullscreen mode

Note this one logs and the four gates do not. That asymmetry is the design. The gates are expected states: running headless is normal, a machine without notification support is normal, and logging "could not notify" on every cycle of a dev run would be noise that teaches you to ignore the log. A throw from the constructor is not expected, so it gets a line. The log function is injected with a default:

logFn: (msg: string) => {} = () => {}
Enter fullscreen mode Exit fullscreen mode

so the module has no dependency on the app's logger and a test can pass nothing.

The two options that are not defaults

const notification = new bits.Notification({
  title,
  body,
  urgency: 'critical',
  timeoutType: 'never',
});
Enter fullscreen mode Exit fullscreen mode

timeoutType: 'never' means the banner does not auto-dismiss. For a notification about a build finishing, auto-dismiss is correct, because a missed one is no loss. For this one the entire value is that you act on it, and the normal case is that it fires while you are in another window, or making coffee, or looking at a different tab. A banner that showed itself for four seconds to an empty chair did not do its job.

urgency: 'critical' is the same argument in the other field: raise this above the ordinary queue.

Worth being precise about what these two actually reach, because it is less than it looks. Electron's NotificationConstructorOptions marks both of them Linux and Windows:

urgency string (optional) Linux Windows
timeoutType string (optional) Linux Windows

So on macOS, neither does anything. The banner behaves the way Notification Center decides, and whether it sticks around is a setting the user owns in System Settings rather than something the app can ask for. Both fields are set anyway: they cost nothing on the platform that ignores them and are correct on the two that read them, and the alternative is a per-platform branch that produces identical behaviour.

What clicking does

notification.on('click', () => {
  bits.shell.openExternal(first.url).catch(() => {});
});
Enter fullscreen mode Exit fullscreen mode

shell.openExternal, not a window inside the app. The listing opens in the user's real browser, where they are already signed in to the rental site, have their password manager, and can send a message without any involvement from us. An in-app window would be a second browser with none of that, which is strictly worse for the one action the notification exists to provoke.

.catch(() => {}) on the end. openExternal returns a promise, the click handler is not async, and an unhandled rejection from a user-initiated click is not worth a crash. If the URL will not open, the notification already did the useful part by telling you a room exists.

The grouping rule

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

One notification per search, never per listing, and at most three titles in the body. Five rooms appearing in one cycle on one site is one banner that says "5 new listings on Kamernet" with three of them named. Five separate banners is a notification setting the user switches off within a day, and then the product has no fast channel at all.

f.title || f.url twice, because a listing with no usable title is still worth telling you about, and a URL is an ugly but real answer. The alternative is a banner with an empty line in it.

And the first line of the function:

if (finds.length === 0) return;
Enter fullscreen mode Exit fullscreen mode

Before any of the Electron work. Most cycles find nothing, and the cheapest possible answer to "nothing happened" is to not go looking for a notification subsystem.

Where you can see it

The notifications are on by default and the switch for them sits on the monitor card, under a line of copy that promises exactly what the module above delivers: "A banner the moment a listing is found, before the email arrives." It is a real role="switch" with aria-checked rather than a styled checkbox, and it is there because some people want the email and nothing else. notifio.app/download has the builds, and the per-site pages like notifio.app/alerts/kamernet and notifio.app/alerts/roommatch describe what each portal's own alerting does first, which is the part that decides whether a local banner is worth anything to you at all.

Top comments (0)