react-x11 2.6.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +317 -1
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +347 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +190 -2
  18. package/src/cocoa/statusitem.js +112 -0
  19. package/src/cocoa/window.js +85 -4
  20. package/src/components/Button.js +20 -1
  21. package/src/components/Checkbox.js +17 -2
  22. package/src/components/Menu.js +108 -38
  23. package/src/components/Radio.js +17 -2
  24. package/src/components/Select.js +159 -27
  25. package/src/components/Switch.js +8 -1
  26. package/src/components/native.js +99 -0
  27. package/src/components/theme.js +37 -20
  28. package/src/desktopsettings.js +34 -2
  29. package/src/dnd.js +92 -3
  30. package/src/errors.js +6 -3
  31. package/src/filedialog.js +81 -16
  32. package/src/index.d.ts +17 -1
  33. package/src/index.js +17 -0
  34. package/src/launcher.js +170 -0
  35. package/src/launcherhooks.js +81 -0
  36. package/src/nodes.js +553 -35
  37. package/src/notificationhooks.js +56 -0
  38. package/src/notifications.js +558 -0
  39. package/src/palette.js +144 -8
  40. package/src/permissionhooks.js +89 -0
  41. package/src/permissions.js +196 -0
  42. package/src/style.d.ts +10 -4
  43. package/src/style.js +1 -0
  44. package/src/styles.js +161 -15
  45. package/src/textselection.js +1 -4
  46. package/src/trayhooks.js +90 -0
  47. package/src/types/appearance.d.ts +24 -0
  48. package/src/types/components.d.ts +10 -0
  49. package/src/types/elements.d.ts +14 -0
  50. package/src/types/events.d.ts +14 -0
  51. package/src/types/filedialog.d.ts +18 -7
  52. package/src/types/launcher.d.ts +43 -0
  53. package/src/types/notifications.d.ts +113 -0
  54. package/src/types/permissions.d.ts +100 -0
  55. package/src/types/style.d.ts +30 -2
  56. package/src/types/system.d.ts +5 -3
  57. package/src/types/tray.d.ts +54 -0
  58. package/src/windowid.js +23 -0
@@ -0,0 +1,56 @@
1
+ // `useNotifier()` — notifications as a component sees them: `notify` bound
2
+ // to the tree's connection, and `available` for whether this machine can
3
+ // show one at all.
4
+ //
5
+ // The bare `notify()` is complete; what a component wants on top is the
6
+ // binding and the answer to "should I show the toggle". There is no rung
7
+ // to draw — a banner outside the window is the one thing the app cannot
8
+ // draw itself — so unlike `useFileDialog()` this adds nothing to the ladder.
9
+
10
+ import { useCallback, useEffect, useMemo, useState } from 'react';
11
+
12
+ import { useAppOrNull } from './appcontext.js';
13
+ import { notificationBackend, notify } from './notifications.js';
14
+
15
+ /**
16
+ * ```jsx
17
+ * const notifier = useNotifier();
18
+ *
19
+ * onExportDone(async (file) => {
20
+ * if (!notifier.available) return;
21
+ * await notifier.notify({ summary: 'Export finished', body: file });
22
+ * });
23
+ * ```
24
+ *
25
+ * `available` settles once the ladder has been probed (one bus round trip,
26
+ * or one read of the app's centre) and is false where `notify()` would
27
+ * reject; `backend` says which rung — useful for saying "actions are not
28
+ * supported here" on the shell-out ones.
29
+ */
30
+ export function useNotifier(defaults = {}) {
31
+ const app = useAppOrNull();
32
+ const [backend, setBackend] = useState(null);
33
+ const forced = defaults.backend;
34
+
35
+ useEffect(() => {
36
+ let alive = true;
37
+ notificationBackend({ app, backend: forced }).then(
38
+ (rung) => alive && setBackend(rung),
39
+ () => alive && setBackend(null),
40
+ );
41
+ return () => {
42
+ alive = false;
43
+ };
44
+ }, [app, forced]);
45
+
46
+ const send = useCallback(
47
+ (options = {}) => notify({ ...defaults, ...options, app }),
48
+ // `defaults` is read at call time on purpose, the useFileDialog rule
49
+ [app],
50
+ );
51
+
52
+ return useMemo(
53
+ () => ({ notify: send, available: backend !== null, backend }),
54
+ [send, backend],
55
+ );
56
+ }
@@ -0,0 +1,558 @@
1
+ // Desktop notifications — a banner outside the app's own windows, through
2
+ // whatever this machine actually has.
3
+ //
4
+ // The file dialog's ladder again (docs/filedialog.md), four rungs:
5
+ //
6
+ // 1. **the app's own notification centre** — the cocoa backend, where the
7
+ // bridge posts through `UNUserNotificationCenter` (src/cocoa/
8
+ // notifications.js). Found by the app carrying `notifications`, never
9
+ // by naming a backend. The centre only delivers for a code-signed app
10
+ // bundle with a bundle id, so a bare `node` process reports itself
11
+ // unavailable and the ladder moves on.
12
+ // 2. **`org.freedesktop.Notifications`** over the session bus — the
13
+ // desktop's daemon, with `replaces_id` for updating a banner in place,
14
+ // `GetCapabilities` for what it can show, and the two signals that say
15
+ // what the user did with it. What a Linux desktop should get.
16
+ // 3. **`osascript`** — `display notification`, on a Mac with neither of
17
+ // the above (the X11 backend under XQuartz, or an unbundled cocoa
18
+ // app). Posted under Script Editor's identity, with no actions, no
19
+ // update and no report back.
20
+ // 4. **`notify-send`** — libnotify's CLI, for a Linux box where the bus
21
+ // transport is missing (Node 20) but a daemon is running. The same
22
+ // limits as `osascript`, though a new enough one prints an id, which
23
+ // gives `update()` back.
24
+ //
25
+ // Nothing to draw at the floor: a banner outside the window is precisely
26
+ // what an app cannot draw itself, so where none of these answers the
27
+ // rejection is **typed** — `NoNotificationServiceError`, the
28
+ // `NoFileDialogError` rule — and an in-app toast is the app's own layout.
29
+ //
30
+ // ## What the handle promises, and where it cannot keep it
31
+ //
32
+ // `notify()` resolves to a handle: `update(patch)` replaces the banner in
33
+ // place, `close()` takes it down, and `onAction`/`onClose` report what the
34
+ // user did. The first two rungs keep all of it. The shell-out rungs cannot:
35
+ // their `update` posts a fresh banner (`notify-send` with an id excepted),
36
+ // their `close` does nothing, and their callbacks never fire — which is
37
+ // documented in `handle.backend` rather than hidden, so an app that cares
38
+ // can say "Open the app to see it" on those.
39
+ //
40
+ // ## The daemon's vocabulary is the API's
41
+ //
42
+ // `urgency` is `low | normal | critical`, actions are `{ key, label }` and
43
+ // a click on the banner is the action keyed `default`, a closed banner
44
+ // reports `expired | dismissed | closed | unknown` — the freedesktop words,
45
+ // because it is a published protocol with many daemons and we are one of
46
+ // many clients. The cocoa rung translates into them.
47
+
48
+ import { currentRegistration } from './application.js';
49
+ import { sessionBus } from './bus.js';
50
+ import { hasService } from './portal.js';
51
+ import { liveApps } from './trace-registry.js';
52
+
53
+ export const NOTIFICATIONS_NAME = 'org.freedesktop.Notifications';
54
+ export const NOTIFICATIONS_PATH = '/org/freedesktop/Notifications';
55
+
56
+ const URGENCY = Object.freeze({ low: 0, normal: 1, critical: 2 });
57
+ const CLOSE_REASONS = Object.freeze({
58
+ 1: 'expired',
59
+ 2: 'dismissed',
60
+ 3: 'closed',
61
+ 4: 'unknown',
62
+ });
63
+
64
+ /**
65
+ * Nothing on this machine can show a notification. A **typed** rejection:
66
+ * a caller falls back to its own UI rather than crashing, and
67
+ * `useNotifier().available` is that branch as render state.
68
+ */
69
+ export class NoNotificationServiceError extends Error {
70
+ constructor(message, cause) {
71
+ super(
72
+ `react-x11: ${
73
+ message ??
74
+ 'no way to show a notification here — no notification centre on ' +
75
+ 'this backend, no org.freedesktop.Notifications daemon on the ' +
76
+ 'session bus, and neither osascript nor notify-send to shell out to.'
77
+ }`,
78
+ { cause },
79
+ );
80
+ this.name = 'NoNotificationServiceError';
81
+ }
82
+ }
83
+
84
+ function checkOptions(options) {
85
+ if (!options || typeof options !== 'object') {
86
+ throw new TypeError('react-x11: notify() takes an options object.');
87
+ }
88
+ if (typeof options.summary !== 'string' || !options.summary) {
89
+ throw new TypeError(
90
+ 'react-x11: notify({ summary }) — a notification needs a summary, ' +
91
+ 'the one line every daemon shows.',
92
+ );
93
+ }
94
+ if (options.urgency != null && !(options.urgency in URGENCY)) {
95
+ throw new TypeError(
96
+ `react-x11: notify({ urgency }) is low, normal or critical — got ` +
97
+ `${JSON.stringify(options.urgency)}.`,
98
+ );
99
+ }
100
+ for (const action of options.actions ?? []) {
101
+ if (!action || typeof action.key !== 'string' || !action.key) {
102
+ throw new TypeError(
103
+ 'react-x11: notify({ actions }) — every action is { key, label }.',
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ /** The app whose centre to post through when the caller did not say. */
110
+ function soleApp() {
111
+ const apps = liveApps();
112
+ if (apps.length <= 1) return apps[0] ?? null;
113
+ const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
114
+ return showing.length === 1 ? showing[0] : null;
115
+ }
116
+
117
+ // --------------------------------------------------------------------------
118
+ // Rung 2: the freedesktop daemon
119
+ // --------------------------------------------------------------------------
120
+
121
+ /**
122
+ * One session per process: the bus ref held while any banner is live, the
123
+ * daemon's capabilities, and the signal subscription that routes what the
124
+ * user did back to the handle it concerns.
125
+ */
126
+ let session = null;
127
+
128
+ async function busSession() {
129
+ if (session) return session;
130
+ const ref = await sessionBus();
131
+ if (!ref) return null;
132
+ if (!(await hasService(NOTIFICATIONS_NAME, ref))) {
133
+ await ref.release();
134
+ return null;
135
+ }
136
+ const { bus } = ref;
137
+ const handles = new Map();
138
+ let capabilities = null;
139
+ try {
140
+ const caps = await bus.invoke(
141
+ {
142
+ destination: NOTIFICATIONS_NAME,
143
+ path: NOTIFICATIONS_PATH,
144
+ interface: NOTIFICATIONS_NAME,
145
+ member: 'GetCapabilities',
146
+ signature: '',
147
+ body: [],
148
+ },
149
+ { timeout: 5_000 },
150
+ );
151
+ capabilities = new Set(Array.isArray(caps) ? caps : []);
152
+ } catch {
153
+ capabilities = new Set();
154
+ }
155
+ // The signals, subscribed once and matched by id: `ActionInvoked` and
156
+ // `NotificationClosed` name the banner they concern, and a banner closed
157
+ // by the user or the clock is the handle's last event.
158
+ let subscription = null;
159
+ try {
160
+ subscription = await bus.watch(
161
+ `type='signal',interface='${NOTIFICATIONS_NAME}'`,
162
+ );
163
+ } catch {
164
+ subscription = null;
165
+ }
166
+ const onClosed = (body) => {
167
+ const [id, reason] = body ?? [];
168
+ const handle = handles.get(id >>> 0);
169
+ if (!handle) return;
170
+ handles.delete(id >>> 0);
171
+ handle._closed(CLOSE_REASONS[reason] ?? 'unknown');
172
+ maybeRelease();
173
+ };
174
+ const onAction = (body) => {
175
+ const [id, key] = body ?? [];
176
+ handles.get(id >>> 0)?._action(String(key));
177
+ };
178
+ const keys = {
179
+ closed: bus.mangle(
180
+ NOTIFICATIONS_PATH,
181
+ NOTIFICATIONS_NAME,
182
+ 'NotificationClosed',
183
+ ),
184
+ action: bus.mangle(NOTIFICATIONS_PATH, NOTIFICATIONS_NAME, 'ActionInvoked'),
185
+ };
186
+ bus.signals.on(keys.closed, onClosed);
187
+ bus.signals.on(keys.action, onAction);
188
+
189
+ const maybeRelease = () => {
190
+ if (handles.size > 0 || session !== current) return;
191
+ void endSession();
192
+ };
193
+ const current = {
194
+ ref,
195
+ bus,
196
+ handles,
197
+ capabilities,
198
+ subscription,
199
+ keys,
200
+ listeners: { closed: onClosed, action: onAction },
201
+ };
202
+ session = current;
203
+ return current;
204
+ }
205
+
206
+ async function endSession() {
207
+ const held = session;
208
+ session = null;
209
+ if (!held) return;
210
+ held.bus.signals.removeListener(held.keys.closed, held.listeners.closed);
211
+ held.bus.signals.removeListener(held.keys.action, held.listeners.action);
212
+ try {
213
+ await held.subscription?.remove?.();
214
+ } catch {
215
+ // the connection is what we are about to let go of anyway
216
+ }
217
+ await held.ref.release();
218
+ }
219
+
220
+ /** The daemon's `a{sv}` hints, and the `app_icon` beside them. */
221
+ function busHints(options, dbus) {
222
+ const V = (sig, value) => [sig, value];
223
+ const hints = [];
224
+ const urgency = URGENCY[options.urgency ?? 'normal'];
225
+ hints.push(['urgency', V('y', urgency)]);
226
+ // What ties the banner to the app in the shell's own list of them — the
227
+ // identity `registerApplication` established, filled in for the caller.
228
+ const appId = options.appId ?? currentRegistration()?.appId;
229
+ if (appId) hints.push(['desktop-entry', V('s', appId)]);
230
+ let appIcon = '';
231
+ if (typeof options.icon === 'string') {
232
+ if (options.icon.startsWith('/') || options.icon.startsWith('file:')) {
233
+ hints.push(['image-path', V('s', options.icon)]);
234
+ } else {
235
+ appIcon = options.icon;
236
+ }
237
+ }
238
+ if (options.category) hints.push(['category', V('s', options.category)]);
239
+ if (options.resident) hints.push(['resident', V('b', true)]);
240
+ void dbus;
241
+ return { hints, appIcon };
242
+ }
243
+
244
+ class BusHandle {
245
+ constructor(sess, options) {
246
+ this.backend = 'dbus';
247
+ this.id = 0;
248
+ this._session = sess;
249
+ this._options = options;
250
+ this._done = false;
251
+ }
252
+
253
+ _post(patch) {
254
+ const sess = this._session;
255
+ const options = { ...this._options, ...patch };
256
+ this._options = options;
257
+ const wantsActions = (options.actions ?? []).length > 0;
258
+ const canAct = sess.capabilities.has('actions');
259
+ if (wantsActions && !canAct && process.env.NODE_ENV !== 'production') {
260
+ console.warn(
261
+ 'react-x11: notify() — this notification daemon has no "actions" ' +
262
+ 'capability, so the actions were dropped rather than sent blind.',
263
+ );
264
+ }
265
+ const actions = canAct
266
+ ? (options.actions ?? []).flatMap((a) => [a.key, a.label ?? a.key])
267
+ : [];
268
+ const { hints, appIcon } = busHints(options);
269
+ const timeout =
270
+ typeof options.timeout === 'number' ? Math.trunc(options.timeout) : -1;
271
+ return sess.bus
272
+ .invoke(
273
+ {
274
+ destination: NOTIFICATIONS_NAME,
275
+ path: NOTIFICATIONS_PATH,
276
+ interface: NOTIFICATIONS_NAME,
277
+ member: 'Notify',
278
+ signature: 'susssasa{sv}i',
279
+ body: [
280
+ options.appName ?? process.title ?? 'react-x11',
281
+ this.id >>> 0,
282
+ appIcon,
283
+ options.summary,
284
+ options.body ?? '',
285
+ actions,
286
+ hints,
287
+ timeout,
288
+ ],
289
+ },
290
+ { timeout: 5_000 },
291
+ )
292
+ .then((id) => {
293
+ this.id = Number(id) >>> 0;
294
+ sess.handles.set(this.id, this);
295
+ return this;
296
+ });
297
+ }
298
+
299
+ update(patch = {}) {
300
+ if (this._done) return Promise.resolve(this);
301
+ return this._post(patch);
302
+ }
303
+
304
+ async close() {
305
+ if (this._done || !this.id) return;
306
+ try {
307
+ await this._session.bus.invoke(
308
+ {
309
+ destination: NOTIFICATIONS_NAME,
310
+ path: NOTIFICATIONS_PATH,
311
+ interface: NOTIFICATIONS_NAME,
312
+ member: 'CloseNotification',
313
+ signature: 'u',
314
+ body: [this.id],
315
+ },
316
+ { timeout: 5_000 },
317
+ );
318
+ } catch {
319
+ // gone already; the daemon's NotificationClosed will not come either
320
+ this._closed('closed');
321
+ }
322
+ }
323
+
324
+ _action(key) {
325
+ try {
326
+ this._options.onAction?.(key);
327
+ } catch (err) {
328
+ console.error('react-x11: a notification action handler threw', err);
329
+ }
330
+ }
331
+
332
+ _closed(reason) {
333
+ if (this._done) return;
334
+ this._done = true;
335
+ this._session.handles.delete(this.id);
336
+ try {
337
+ this._options.onClose?.(reason);
338
+ } catch (err) {
339
+ console.error('react-x11: a notification close handler threw', err);
340
+ }
341
+ }
342
+ }
343
+
344
+ async function busNotify(options) {
345
+ const sess = await busSession();
346
+ if (!sess) return null;
347
+ return new BusHandle(sess, options)._post({});
348
+ }
349
+
350
+ // --------------------------------------------------------------------------
351
+ // Rungs 3 and 4: the shell-outs
352
+ // --------------------------------------------------------------------------
353
+
354
+ /** AppleScript string literal. */
355
+ const as = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
356
+
357
+ /** The `-e` lines of `display notification`. Exported for the tests. */
358
+ export function osascriptNotificationLines(options) {
359
+ let line = `display notification ${as(options.body ?? '')} with title ${as(
360
+ options.summary,
361
+ )}`;
362
+ if (options.subtitle) line += ` subtitle ${as(options.subtitle)}`;
363
+ return ['-e', line];
364
+ }
365
+
366
+ /** `notify-send`'s arguments. `-p` asks for the id back (libnotify ≥ 0.7.9),
367
+ * which is what makes `update()` possible on this rung. Exported for the
368
+ * tests. */
369
+ export function notifySendArgs(options, replacesId = null) {
370
+ const args = ['-p', '-u', options.urgency ?? 'normal'];
371
+ if (typeof options.timeout === 'number') {
372
+ args.push('-t', String(Math.trunc(options.timeout)));
373
+ }
374
+ if (typeof options.icon === 'string') args.push('-i', options.icon);
375
+ if (options.appName) args.push('-a', options.appName);
376
+ if (replacesId) args.push('-r', String(replacesId));
377
+ args.push('--', options.summary, options.body ?? '');
378
+ return args;
379
+ }
380
+
381
+ class ShellHandle {
382
+ constructor(backend, options) {
383
+ this.backend = backend;
384
+ this.id = null;
385
+ this._options = options;
386
+ }
387
+
388
+ async update(patch = {}) {
389
+ this._options = { ...this._options, ...patch };
390
+ await shellNotify(this.backend, this._options, this);
391
+ return this;
392
+ }
393
+
394
+ async close() {
395
+ // nothing a shell-out can take down
396
+ }
397
+ }
398
+
399
+ async function run(file, args) {
400
+ const { execFile } = await import('node:child_process');
401
+ return new Promise((resolve, reject) => {
402
+ execFile(file, args, { encoding: 'utf8' }, (error, stdout) =>
403
+ error ? reject(error) : resolve(String(stdout ?? '')),
404
+ );
405
+ });
406
+ }
407
+
408
+ async function shellNotify(backend, options, handle = null) {
409
+ const out = handle ?? new ShellHandle(backend, options);
410
+ if (backend === 'osascript') {
411
+ await run('osascript', osascriptNotificationLines(options));
412
+ } else {
413
+ const stdout = await run(
414
+ 'notify-send',
415
+ notifySendArgs(options, out.id ?? null),
416
+ );
417
+ const id = Number.parseInt(stdout.trim(), 10);
418
+ out.id = Number.isFinite(id) && id > 0 ? id : out.id;
419
+ }
420
+ return out;
421
+ }
422
+
423
+ // --------------------------------------------------------------------------
424
+ // The ladder
425
+ // --------------------------------------------------------------------------
426
+
427
+ function centreFor(options) {
428
+ const app = options.app ?? soleApp();
429
+ return app?.notifications ?? null;
430
+ }
431
+
432
+ /**
433
+ * Which rung this machine lands on, without posting anything.
434
+ *
435
+ * `'cocoa'` needs the app's centre to be *available* — a bundle id, see
436
+ * docs/notifications.md — so this asks it, which is one asynchronous read.
437
+ * The bus probe acquires a reference and releases it. `null` means
438
+ * {@link notify} would reject.
439
+ *
440
+ * @returns {Promise<'cocoa'|'dbus'|'osascript'|'notify-send'|null>}
441
+ */
442
+ export async function notificationBackend(options = {}) {
443
+ const backend = options.backend;
444
+ const want = (rung) => !backend || backend === rung;
445
+ if (want('cocoa')) {
446
+ const centre = centreFor(options);
447
+ if (centre && (await centre.available())) return 'cocoa';
448
+ if (backend === 'cocoa') return null;
449
+ }
450
+ if (want('dbus')) {
451
+ const ref = await sessionBus();
452
+ if (ref) {
453
+ try {
454
+ if (await hasService(NOTIFICATIONS_NAME, ref)) return 'dbus';
455
+ } finally {
456
+ await ref.release();
457
+ }
458
+ }
459
+ if (backend === 'dbus') return null;
460
+ }
461
+ if (want('osascript') && (process.platform === 'darwin' || backend)) {
462
+ return 'osascript';
463
+ }
464
+ if (want('notify-send') && (process.platform !== 'darwin' || backend)) {
465
+ return 'notify-send';
466
+ }
467
+ return null;
468
+ }
469
+
470
+ /**
471
+ * Show a notification, on the best rung this machine has.
472
+ *
473
+ * ```js
474
+ * const banner = await notify({
475
+ * summary: 'Export finished',
476
+ * body: 'report.pdf — 2.4 MB',
477
+ * actions: [{ key: 'open', label: 'Open' }],
478
+ * onAction: (key) => key === 'open' && reveal(path),
479
+ * onClose: (reason) => {},
480
+ * });
481
+ * await banner.update({ body: 'opened' }); // in place, where the rung can
482
+ * await banner.close();
483
+ * ```
484
+ *
485
+ * Resolves to a handle — `id`, `backend`, `update(patch)`, `close()`.
486
+ * Rejects with {@link NoNotificationServiceError} where nothing can show
487
+ * one, and with the platform's own error where the centre exists and
488
+ * **refused** (the user denied the app's notifications): a refusal is not
489
+ * fallen through, because a banner the user turned off must not come back
490
+ * through a side door.
491
+ *
492
+ * `app` names the connection when there are several — `useNotifier()`
493
+ * passes the tree's.
494
+ */
495
+ export async function notify(options = {}) {
496
+ checkOptions(options);
497
+ const backend = options.backend;
498
+ const want = (rung) => !backend || backend === rung;
499
+
500
+ if (want('cocoa')) {
501
+ const centre = centreFor(options);
502
+ if (centre && (await centre.available())) {
503
+ const handle = await centre.post(options);
504
+ if (handle) return handle;
505
+ // The bundle is right and the centre is there, but the system never
506
+ // put the prompt in front of anybody, so nobody declined: not the
507
+ // refusal above, and no reason to stop. The ladder moves on.
508
+ if (backend === 'cocoa') {
509
+ throw new NoNotificationServiceError(
510
+ "backend: 'cocoa' — the centre could not ask for authorization " +
511
+ '(the status is still notDetermined). A bundle macOS has not ' +
512
+ 'registered never gets the prompt (docs/notifications.md).',
513
+ );
514
+ }
515
+ } else if (backend === 'cocoa') {
516
+ throw new NoNotificationServiceError(
517
+ "backend: 'cocoa' — no notification centre here: the tree is not " +
518
+ 'on the cocoa backend, the bridge is older than 0.5, or this ' +
519
+ 'process is not an app bundle (docs/notifications.md).',
520
+ );
521
+ }
522
+ }
523
+
524
+ if (want('dbus')) {
525
+ const handle = await busNotify(options);
526
+ if (handle) return handle;
527
+ if (backend === 'dbus') {
528
+ throw new NoNotificationServiceError(
529
+ `backend: 'dbus' — no ${NOTIFICATIONS_NAME} on the session bus.`,
530
+ );
531
+ }
532
+ }
533
+
534
+ const shell =
535
+ want('osascript') && (process.platform === 'darwin' || backend)
536
+ ? 'osascript'
537
+ : want('notify-send') && (process.platform !== 'darwin' || backend)
538
+ ? 'notify-send'
539
+ : null;
540
+ if (shell) {
541
+ try {
542
+ return await shellNotify(shell, options);
543
+ } catch (err) {
544
+ if (err?.code === 'ENOENT')
545
+ throw new NoNotificationServiceError(undefined, err);
546
+ throw new Error(
547
+ `react-x11: ${shell} could not show the notification — ${err.message}`,
548
+ { cause: err },
549
+ );
550
+ }
551
+ }
552
+ throw new NoNotificationServiceError();
553
+ }
554
+
555
+ /** Test seam, not public: drop the bus session without waiting on it. */
556
+ export async function _resetNotifications() {
557
+ await endSession();
558
+ }