react-x11 2.6.0 → 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.
- package/README.md +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +358 -20
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/context2d.js +271 -25
- package/src/cocoa/dnd.js +347 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +190 -2
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +85 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +92 -3
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +17 -1
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +553 -35
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/screens.js +39 -4
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- 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
|
+
}
|