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 not this; this page is about the desktop's own banners.
The ladder
Like the file dialog's, four rungs, chosen for you:
| 1 | the backend's notification centre | The 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. |
| 2 | org.freedesktop.Notifications | The 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. |
| 3 | osascript | display 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. |
| 4 | notify-send | libnotify'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.
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 vocabulary is freedesktop's on every rung. 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 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
| rung | update in place | close | actions | what the user did |
|---|---|---|---|---|
| cocoa | yes | yes | yes | action, dismissal |
| win32 | no | no | no | nothing |
| dbus | yes (replaces_id) | yes | if the daemon can | action, close reason |
osascript | posts anew | nothing | no | nothing |
notify-send | with an id, yes | nothing | no | nothing |
On win32 the banner is a real toast in the action centre, but
fire-and-forget: buttons, replies and update-in-place would need an
AppUserModelID for the process, which is a packaging question
(windows.md §"Packaging and identity").
The shell-out rungs 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. To a daemon that cannot show
buttons (no actions in its GetCapabilities, read once per bus session)
they are dropped with a development warning rather than sent blind.
A refusal is not fallen through. On the cocoa backend the first post asks
the system's authorization prompt, once per bundle. If the user declined,
notify() rejects with NotificationsDeniedError rather than posting through
osascript.
A prompt nobody saw is not a refusal. A bundle macOS has not registered
(typically an ad-hoc signature in a temp directory) never gets the prompt and
stays notDetermined; the ladder then carries on to osascript. Only
backend: 'cocoa' turns that into an error, and it says notDetermined
rather than blaming the user.
So an app that reports progress should check handle.backend before it
decides how often to post. examples/notify.jsx (npm run examples:notify)
updates one banner in place where the rung can, and on osascript or
notify-send posts once at the end.
The bundle, on macOS
UNUserNotificationCenter attributes every banner to an app bundle — a
CFBundleIdentifier Launch Services can see — so a bare node process gets
available: false from the centre and notify() 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
| where | rung |
|---|---|
| macOS, the cocoa backend, as an app bundle | the centre |
macOS, the cocoa backend, bare node | osascript |
| macOS + XQuartz | osascript (a D-Bus daemon on a Mac is rare) |
| GNOME / KDE / any desktop with a daemon | org.freedesktop.Notifications |
Node 20 on Linux, where npm skips dbus-native | notify-send |
ssh, startx, a container, CI | NoNotificationServiceError — 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.
Design notes: design/notifications.md.