Skip to main content

Notifications

A banner outside the app's own windows — "export finished", "update ready" — on whatever the machine has.

import { useNotifier } from 'react-x11';

function Exporter() {
const notifier = useNotifier();

const run = async () => {
const file = await exportReport();
if (!notifier.available) return;
await notifier.notify({
summary: 'Export finished',
body: file.name,
actions: [{ key: 'open', label: 'Open' }],
onAction: (key) => key === 'open' && reveal(file),
});
};
return <Button label="Export" onPress={run} />;
}

An in-app toast is deliberately not this. That is layout, and it belongs to whoever wants it; this page is about the desktop's own banners.

The ladder​

The file dialog's shape again, four rungs:

1the backend's notification centreThe cocoa backend: UNUserNotificationCenter, the system's own banners, list and action buttons. Delivers only for a code-signed app bundle with a bundle id — see below. On win32: a tray balloon, which the shell shows as a toast and keeps in the action centre.
2org.freedesktop.NotificationsThe desktop's daemon over D-Bus: updating in place, the daemon's capabilities, and the two signals that say what the user did. What a Linux desktop should get.
3osascriptdisplay notification, on a Mac with neither of the above — the X11 backend under XQuartz, or an unbundled cocoa app. Posted under Script Editor's identity; no actions, no update, no report back.
4notify-sendlibnotify's CLI, for a Linux box where the bus transport is missing (Node 20) but a daemon runs. The same limits, though a new enough one prints an id, which gives update() back.

notificationBackend() reports which one this machine lands on without posting anything. Where none answers, notify() rejects with a typed NoNotificationServiceError — the signal to fall back to your own UI, not a crash — and useNotifier().available is that branch as render state.

The top rung is never chosen by naming a backend: notify() asks the connection the tree renders through whether it has a centre, and whether that centre can deliver.

notify() and the handle​

const banner = await notify({
summary, // required — the one line every daemon shows
body?, icon?, urgency?: 'low' | 'normal' | 'critical', timeout?,
actions?: [{ key, label }], onAction?(key), onClose?(reason),
subtitle?, silent?, category?, resident?, appName?, appId?, userInfo?,
backend?, app?,
});

banner.id; // the daemon's id / the centre's identifier / null
banner.backend; // which rung
await banner.update({ body }); // in place, where the rung can
await banner.close(); // take it down, where the rung can

The daemon's vocabulary is the API's. urgency is low | normal | critical, an action is { key, label }, a click on the banner itself is the action keyed 'default', and a banner that went away reports 'expired' | 'dismissed' | 'closed' | 'unknown' — the freedesktop words, because it is a published protocol with many daemons and this is one of many clients. The cocoa rung translates into them.

Attribution is filled in for you. On Linux the desktop-entry hint is the appId registerApplication() established, which is what makes the banner show up under your app in the shell's own list of them; appId overrides it. On macOS the bundle is the attribution, and there is nothing to pass.

What each rung cannot do​

rungupdate in placecloseactionswhat the user did
cocoayesyesyesaction, dismissal
win32nonononothing
dbusyes (replaces_id)yesif the daemon canaction, close reason
osascriptposts anewnothingnonothing
notify-sendwith an id, yesnothingnonothing

win32 sits with the shell-out rungs on everything but where it is shown: a Shell_NotifyIcon balloon is a real toast in a real action centre, and also a fire-and-forget one. Buttons, replies and a lifetime the app controls want the Windows App SDK's notification manager, which will not show anything at all until the system knows an AppUserModelID for the process — a Start-menu shortcut carrying one, or a registry registration. That is a packaging question (windows.md §"Packaging and identity"), and a balloon is what can be shown without asking the user to install anything.

The shell-out rungs are the crude floor: they show a banner and know nothing afterwards. handle.backend says which rung answered, so an app that cares can say "open the app to see it" there rather than promise a button it cannot deliver.

Actions on the bus are gated by the daemon. Whether it can show buttons is one of its GetCapabilities, and an app that sends actions to a daemon without actions has silently produced a notification nobody can act on — so they are dropped with a development warning rather than sent blind. Capabilities are read once per bus session.

A refusal is not fallen through. On the cocoa backend the first post asks the system's authorization prompt, once per bundle. A user who declined the app's notifications has said something, and notify() rejects with the platform's error (NotificationsDeniedError) rather than posting through osascript by a side door.

A refusal is an answer; a prompt nobody saw is not. The two look identical from the call — the authorization request comes back refused either way — so the rung reads the status again afterwards, because only a prompt moves it off notDetermined. A bundle macOS has not registered (an ad-hoc signature in a temp directory is the usual way to meet this) never gets the prompt, and reporting that as a denial would tell the user they declined something they were never shown. Nobody has turned anything off, so the ladder carries on to osascript; only backend: 'cocoa' turns it into an error, and that one says notDetermined rather than blaming the user.

Which is why an app that counts progress should ask before it decides. examples/notify.jsx (npm run examples:notify) is a download manager built around exactly that: it posts one banner and updates it in place where the rung can, and on osascript or notify-send stays quiet and posts once at the end, because posting per step there would be four banners for one download.

The bundle, on macOS​

UNUserNotificationCenter attributes every banner to an app bundle — a CFBundleIdentifier Launch Services can see — and raises in a process that is none, which is what a bare node is. The bridge probes it once and reports available: false; notify() then takes the osascript rung, whose banners are Script Editor's. To get the real thing, run as a bundle: the executable in Name.app/Contents/MacOS/, an Info.plist that names it and carries CFBundleIdentifier, and a signature (codesign --force --sign - Name.app is enough locally). See packaging.md.

useNotifier()​

const { notify, available, backend } = useNotifier(defaults?);

notify is the bare function bound to the tree's connection; available settles once the ladder has been probed (one bus round trip, or one read of the centre) and is false where notify() would reject; backend is the rung. Options given to the hook are defaults for every call; options given to a call win.

Running it where there is no daemon​

whererung
macOS, the cocoa backend, as an app bundlethe centre
macOS, the cocoa backend, bare nodeosascript
macOS + XQuartzosascript (a D-Bus daemon on a Mac is rare)
GNOME / KDE / any desktop with a daemonorg.freedesktop.Notifications
Node 20 on Linux, where npm skips dbus-nativenotify-send
ssh, startx, a container, CINoNotificationServiceError — draw your own toast

The bus rung holds one session-bus reference while any banner is live and releases it when the last one closes, so an app that closed its banners on the way out is free to exit.