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.
- 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 +317 -1
- package/src/cocoa/bezels.js +51 -1
- 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/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
package/src/dnd.js
CHANGED
|
@@ -269,6 +269,9 @@ export class DropSession {
|
|
|
269
269
|
this.path = [];
|
|
270
270
|
// what an `ask` source offered, read once per drag (see _readAskOffer)
|
|
271
271
|
this._askOffer = null;
|
|
272
|
+
// driven through the local entry points (a DragSession here, or a
|
|
273
|
+
// backend's own drop machinery) rather than the XDND wire
|
|
274
|
+
this._viaLocal = false;
|
|
272
275
|
this.accepted = null;
|
|
273
276
|
this.acceptedAction = 'copy';
|
|
274
277
|
this.requestedAction = 'copy';
|
|
@@ -491,7 +494,13 @@ export class DropSession {
|
|
|
491
494
|
// back as a return value instead of an XdndStatus.
|
|
492
495
|
|
|
493
496
|
localOver(rootX, rootY, offer, time) {
|
|
494
|
-
|
|
497
|
+
// `offer.source` is what the handlers see: 'internal' for a DragSession
|
|
498
|
+
// in this process, 'external' when a backend's own drop machinery drives
|
|
499
|
+
// these same entry points for another application's drag (src/cocoa/
|
|
500
|
+
// dnd.js) — the path diffing and the dispatch are one implementation
|
|
501
|
+
// either way
|
|
502
|
+
this._source = offer.source ?? 'internal';
|
|
503
|
+
this._viaLocal = true;
|
|
495
504
|
this.sourceWid = 0;
|
|
496
505
|
this.types = offer.types;
|
|
497
506
|
this.requestedAction = offer.action;
|
|
@@ -500,10 +509,11 @@ export class DropSession {
|
|
|
500
509
|
}
|
|
501
510
|
|
|
502
511
|
localLeave() {
|
|
503
|
-
if (this.
|
|
512
|
+
if (!this._viaLocal) return;
|
|
504
513
|
discrete(() => this._clearPath())();
|
|
505
514
|
this.accepted = null;
|
|
506
515
|
this.lastPoint = null;
|
|
516
|
+
this._viaLocal = false;
|
|
507
517
|
this._source = 'external';
|
|
508
518
|
}
|
|
509
519
|
|
|
@@ -512,7 +522,7 @@ export class DropSession {
|
|
|
512
522
|
* from the caller's point of view — `onDragEnd` follows immediately,
|
|
513
523
|
* like the DOM's dragend after drop. */
|
|
514
524
|
localDrop(offer, extras, time) {
|
|
515
|
-
this._source = 'internal';
|
|
525
|
+
this._source = offer.source ?? 'internal';
|
|
516
526
|
this.types = offer.types;
|
|
517
527
|
const point = this.lastPoint;
|
|
518
528
|
const accepted = this.accepted;
|
|
@@ -555,6 +565,7 @@ export class DropSession {
|
|
|
555
565
|
})();
|
|
556
566
|
this.accepted = null;
|
|
557
567
|
this.lastPoint = null;
|
|
568
|
+
this._viaLocal = false;
|
|
558
569
|
this._source = 'external';
|
|
559
570
|
return {
|
|
560
571
|
handled: outcome.accept,
|
|
@@ -1186,6 +1197,7 @@ export class DragSession {
|
|
|
1186
1197
|
this._atoms = null;
|
|
1187
1198
|
this._cursor = null;
|
|
1188
1199
|
this.ext = null;
|
|
1200
|
+
this._nativeSession = false;
|
|
1189
1201
|
}
|
|
1190
1202
|
|
|
1191
1203
|
/** The pressed node (or an ancestor) is draggable: remember where, and
|
|
@@ -1219,6 +1231,7 @@ export class DragSession {
|
|
|
1219
1231
|
/** Every mousemove while armed or dragging. Returns true when the drag
|
|
1220
1232
|
* consumed the motion — the caller then skips hover and mousemove. */
|
|
1221
1233
|
motion(native) {
|
|
1234
|
+
if (this._nativeSession) return true;
|
|
1222
1235
|
if (this.phase === 'armed') {
|
|
1223
1236
|
const moved =
|
|
1224
1237
|
Math.abs(native.x - this.press.x) + Math.abs(native.y - this.press.y);
|
|
@@ -1259,9 +1272,84 @@ export class DragSession {
|
|
|
1259
1272
|
this.phase = 'dragging';
|
|
1260
1273
|
source.setStyleState(':dragging', true);
|
|
1261
1274
|
this._setCursor('grab');
|
|
1275
|
+
// A backend with a drag session of its own (the cocoa backend's
|
|
1276
|
+
// NSDraggingSession, src/cocoa/dnd.js) takes the gesture from here:
|
|
1277
|
+
// the pointer's motion and release stop arriving and come back as
|
|
1278
|
+
// `nativeMoved`/`nativeEnded`, and a drop on one of our own windows
|
|
1279
|
+
// comes through that window's destination events, routed to this
|
|
1280
|
+
// session's live payload by `app._activeDrag`.
|
|
1281
|
+
const wnd = this.node.window;
|
|
1282
|
+
if (typeof wnd?.beginDrag === 'function') {
|
|
1283
|
+
this._nativeSession = true;
|
|
1284
|
+
this.app._activeDrag = this;
|
|
1285
|
+
try {
|
|
1286
|
+
wnd.beginDrag(this);
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
this._nativeSession = false;
|
|
1289
|
+
this.app._activeDrag = null;
|
|
1290
|
+
this._reset();
|
|
1291
|
+
throw err;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1262
1294
|
return true;
|
|
1263
1295
|
}
|
|
1264
1296
|
|
|
1297
|
+
/** `drag-session-moved` on a native session: the source's `onDrag`, in
|
|
1298
|
+
* global device pixels, with whether a window of ours has accepted. */
|
|
1299
|
+
nativeMoved(ev) {
|
|
1300
|
+
if (this.phase !== 'dragging' || !this._nativeSession) return;
|
|
1301
|
+
const s = this.node.scale;
|
|
1302
|
+
const origin = this.node.window?._screenOrigin ?? { x: 0, y: 0 };
|
|
1303
|
+
const rootX = Math.round(ev.x * s);
|
|
1304
|
+
const rootY = Math.round(ev.y * s);
|
|
1305
|
+
const native = {
|
|
1306
|
+
x: rootX - origin.x,
|
|
1307
|
+
y: rootY - origin.y,
|
|
1308
|
+
rootx: rootX,
|
|
1309
|
+
rooty: rootY,
|
|
1310
|
+
buttons: 256,
|
|
1311
|
+
time: 0,
|
|
1312
|
+
};
|
|
1313
|
+
const source = this.source;
|
|
1314
|
+
if (source && !source.destroyed && source.props.onDrag) {
|
|
1315
|
+
callHandler(
|
|
1316
|
+
source,
|
|
1317
|
+
'onDrag',
|
|
1318
|
+
source.props.onDrag,
|
|
1319
|
+
this.node.events._makeEvent('drag', native, source, {
|
|
1320
|
+
types: this.types,
|
|
1321
|
+
action: this.currentAction,
|
|
1322
|
+
source: this.localSession ? 'internal' : 'external',
|
|
1323
|
+
accepted: this.accepted,
|
|
1324
|
+
screenX: rootX / this.node.events.scale,
|
|
1325
|
+
screenY: rootY / this.node.events.scale,
|
|
1326
|
+
}),
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** `drag-session-ended`: the release, with what the destination did. */
|
|
1332
|
+
nativeEnded(ev) {
|
|
1333
|
+
if (!this._nativeSession) return;
|
|
1334
|
+
this._nativeSession = false;
|
|
1335
|
+
if (this.app._activeDrag === this) this.app._activeDrag = null;
|
|
1336
|
+
if (this.phase !== 'dragging') return this._reset();
|
|
1337
|
+
const source = this.source;
|
|
1338
|
+
source?.setStyleState?.(':dragging', false);
|
|
1339
|
+
this.localSession?.localLeave();
|
|
1340
|
+
const s = this.node.scale;
|
|
1341
|
+
const origin = this.node.window?._screenOrigin ?? { x: 0, y: 0 };
|
|
1342
|
+
const rootX = Math.round((ev.x ?? 0) * s);
|
|
1343
|
+
const rootY = Math.round((ev.y ?? 0) * s);
|
|
1344
|
+
const operation =
|
|
1345
|
+
ev.operation && ev.operation !== 'none' ? ev.operation : null;
|
|
1346
|
+
this._end(
|
|
1347
|
+
{ x: rootX - origin.x, y: rootY - origin.y, rootx: rootX, rooty: rootY },
|
|
1348
|
+
ev.dropped ? operation : null,
|
|
1349
|
+
Boolean(ev.dropped),
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1265
1353
|
/** dragData values resolve once per drag: thunks are called on first
|
|
1266
1354
|
* use — at delivery for an internal drop, at promotion for an external
|
|
1267
1355
|
* one — and never at mousedown. */
|
|
@@ -1354,6 +1442,7 @@ export class DragSession {
|
|
|
1354
1442
|
/** Button release. Returns true when a drag ran (the caller suppresses
|
|
1355
1443
|
* mouseup/click, like the DOM after a drag gesture). */
|
|
1356
1444
|
release(native) {
|
|
1445
|
+
if (this._nativeSession) return true;
|
|
1357
1446
|
if (this.phase !== 'dragging') {
|
|
1358
1447
|
this._reset();
|
|
1359
1448
|
return false;
|
package/src/errors.js
CHANGED
|
@@ -87,15 +87,18 @@ const reportedStyleErrors = new WeakMap();
|
|
|
87
87
|
* run or a supervisor still counts this as a failure. `REACT_X11_STRICT_TOKENS=1`
|
|
88
88
|
* restores the throw.
|
|
89
89
|
*/
|
|
90
|
-
export function reportStyleError(
|
|
90
|
+
export function reportStyleError(
|
|
91
|
+
node,
|
|
92
|
+
message,
|
|
93
|
+
consequence = 'The property is dropped and the app carries on',
|
|
94
|
+
) {
|
|
91
95
|
const seen = reportedStyleErrors.get(node);
|
|
92
96
|
if (seen?.has(message)) return;
|
|
93
97
|
if (seen) seen.add(message);
|
|
94
98
|
else reportedStyleErrors.set(node, new Set([message]));
|
|
95
99
|
const owner = ownerName(node);
|
|
96
100
|
console.error(
|
|
97
|
-
`${message}${owner ? ` — in ${owner}` : ''}. ` +
|
|
98
|
-
'The property is dropped and the app carries on; set ' +
|
|
101
|
+
`${message}${owner ? ` — in ${owner}` : ''}. ${consequence}; set ` +
|
|
99
102
|
'REACT_X11_STRICT_TOKENS=1 to make this throw instead.',
|
|
100
103
|
);
|
|
101
104
|
markFailed();
|
package/src/filedialog.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// There is no one answer, so this is a ladder, tried in order:
|
|
4
4
|
//
|
|
5
|
-
// 1. **the
|
|
5
|
+
// 1. **the native panel** — `NSOpenPanel`/`NSSavePanel` in this process, on
|
|
6
|
+
// the cocoa backend (src/cocoa/filepanels.js). A sheet on the window
|
|
7
|
+
// that asked, with every filter the OS type database knows. Found by
|
|
8
|
+
// the app the window belongs to offering `filePanels`, never by naming
|
|
9
|
+
// a backend here.
|
|
10
|
+
// 2. **the portal** — `org.freedesktop.portal.FileChooser` over D-Bus. The
|
|
6
11
|
// real desktop dialog, with the user's bookmarks and recent files, drawn
|
|
7
12
|
// by GTK or KDE in another process. What a Linux desktop should get.
|
|
8
|
-
//
|
|
9
|
-
// that has not gone out of its way. `choose file`
|
|
10
|
-
// the user gets the dialog they know.
|
|
11
|
-
//
|
|
13
|
+
// 3. **`osascript`** — macOS on the X11 backend with no portal, which is
|
|
14
|
+
// every XQuartz install that has not gone out of its way. `choose file`
|
|
15
|
+
// is `NSOpenPanel`, so the user gets the dialog they know.
|
|
16
|
+
// 4. **the built-in dialog** — a file browser drawn by react-x11 itself.
|
|
12
17
|
// Reached over ssh, under a bare `startx`, in a container: everywhere
|
|
13
18
|
// there is a display and nothing else. See `components/FileDialog.js`;
|
|
14
19
|
// it needs a React tree, so `useFileDialog()` has it and the bare
|
|
@@ -24,12 +29,14 @@
|
|
|
24
29
|
// process; all you get is logical parenting, through `parent_window`. That
|
|
25
30
|
// is `transientFor` by another name, and it is why these calls want a window
|
|
26
31
|
// to point at.
|
|
27
|
-
// -
|
|
28
|
-
// `NSWindow`s owned by X11.app,
|
|
29
|
-
//
|
|
30
|
-
// modality still works, because the
|
|
32
|
+
// - **`osascript` has no transient-for at all.** XQuartz windows are
|
|
33
|
+
// `NSWindow`s owned by X11.app, `addChildWindow` is same-process only, and
|
|
34
|
+
// the panel belongs to a third process anyway. So it appears over the app
|
|
35
|
+
// but is not attached to it. Application modality still works, because the
|
|
36
|
+
// caller is awaiting the promise. The native panel is the one rung on a
|
|
37
|
+
// Mac that *is* attached — a sheet — which is the whole reason it exists.
|
|
31
38
|
// - **The built-in dialog is ours**, so it is the only rung that can be
|
|
32
|
-
// modal-and-parented
|
|
39
|
+
// modal-and-parented on X11 — and the only one that has never seen the
|
|
33
40
|
// user's bookmarks.
|
|
34
41
|
|
|
35
42
|
import {
|
|
@@ -45,7 +52,8 @@ import {
|
|
|
45
52
|
variant,
|
|
46
53
|
} from './portal.js';
|
|
47
54
|
import { sessionBus } from './bus.js';
|
|
48
|
-
import {
|
|
55
|
+
import { liveApps } from './trace-registry.js';
|
|
56
|
+
import { windowIdOf, windowOf } from './windowid.js';
|
|
49
57
|
|
|
50
58
|
const FILE_CHOOSER = 'org.freedesktop.portal.FileChooser';
|
|
51
59
|
|
|
@@ -60,9 +68,9 @@ const FILE_CHOOSER = 'org.freedesktop.portal.FileChooser';
|
|
|
60
68
|
export class NoFileDialogError extends Error {
|
|
61
69
|
constructor(cause) {
|
|
62
70
|
super(
|
|
63
|
-
'react-x11: no file dialog is available —
|
|
64
|
-
'xdg-desktop-portal on the bus, and this is not macOS.
|
|
65
|
-
'useFileDialog() instead, which draws one, or supply `backend`.',
|
|
71
|
+
'react-x11: no file dialog is available — no native panel on this ' +
|
|
72
|
+
'backend, no xdg-desktop-portal on the bus, and this is not macOS. ' +
|
|
73
|
+
'Use useFileDialog() instead, which draws one, or supply `backend`.',
|
|
66
74
|
{ cause },
|
|
67
75
|
);
|
|
68
76
|
this.name = 'NoFileDialogError';
|
|
@@ -230,14 +238,32 @@ async function osascriptDialog(kind, opts) {
|
|
|
230
238
|
if (error.code === 'ENOENT') {
|
|
231
239
|
return reject(new NoFileDialogError(error));
|
|
232
240
|
}
|
|
241
|
+
// The caller's abort killed the child. From the outside that is a
|
|
242
|
+
// process that died on a signal, which is not a failure of the
|
|
243
|
+
// dialog: it is the abort, reported the way the portal rung
|
|
244
|
+
// reports one.
|
|
245
|
+
if (opts.signal?.aborted) {
|
|
246
|
+
return reject(
|
|
247
|
+
opts.signal.reason ??
|
|
248
|
+
new PortalCancelledError(RESPONSE_CANCELLED),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
233
251
|
// AppleScript reports a cancel as -128, which is an ordinary
|
|
234
252
|
// outcome and must not read as a failure.
|
|
235
253
|
if (/-128/.test(stderr) || /User canceled/i.test(stderr)) {
|
|
236
254
|
return reject(new PortalCancelledError(RESPONSE_CANCELLED));
|
|
237
255
|
}
|
|
256
|
+
// What osascript said, minus the lines AppKit prints to every
|
|
257
|
+
// process that puts a window up (IMKClient and friends), which
|
|
258
|
+
// would otherwise be the whole of the message.
|
|
259
|
+
const said = stderr
|
|
260
|
+
.split('\n')
|
|
261
|
+
.map((line) => line.trim())
|
|
262
|
+
.filter((line) => line && !/\+\[IMK\w+ subclass\]/.test(line))
|
|
263
|
+
.join(' ');
|
|
238
264
|
return reject(
|
|
239
265
|
new Error(
|
|
240
|
-
`react-x11: the macOS file dialog failed — ${
|
|
266
|
+
`react-x11: the macOS file dialog failed — ${said || error.message}`,
|
|
241
267
|
{ cause: error },
|
|
242
268
|
),
|
|
243
269
|
);
|
|
@@ -267,6 +293,29 @@ function defaultTitle(kind) {
|
|
|
267
293
|
: 'Open file';
|
|
268
294
|
}
|
|
269
295
|
|
|
296
|
+
// --------------------------------------------------------------------------
|
|
297
|
+
// Rung 1: the native panel, on the cocoa backend
|
|
298
|
+
// --------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The app whose native panels a dialog should use, or null.
|
|
302
|
+
*
|
|
303
|
+
* Never a backend check: an app that can show panels says so by carrying
|
|
304
|
+
* `filePanels` (src/cocoa/app.js), and this asks the app the named window
|
|
305
|
+
* belongs to. With no window named it asks the connections the renderer is
|
|
306
|
+
* drawing through — one is the normal case; several with only one of them
|
|
307
|
+
* still showing a window is the next (a borrowed connection stays
|
|
308
|
+
* registered after its root unmounts); genuinely several is a real null,
|
|
309
|
+
* since a panel has to belong to one of them.
|
|
310
|
+
*/
|
|
311
|
+
function panelsApp(wnd) {
|
|
312
|
+
if (wnd) return wnd.app?.filePanels ? wnd.app : null;
|
|
313
|
+
const apps = liveApps().filter((app) => app.filePanels);
|
|
314
|
+
if (apps.length <= 1) return apps[0] ?? null;
|
|
315
|
+
const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
|
|
316
|
+
return showing.length === 1 ? showing[0] : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
270
319
|
/**
|
|
271
320
|
* Which rung this machine lands on, without showing anything.
|
|
272
321
|
*
|
|
@@ -274,9 +323,10 @@ function defaultTitle(kind) {
|
|
|
274
323
|
* for the tests. It acquires a bus ref and releases it, so it is cheap to call
|
|
275
324
|
* but not free — cache it if it is on a render path.
|
|
276
325
|
*
|
|
277
|
-
* @returns {Promise<'portal'|'osascript'|'builtin'>}
|
|
326
|
+
* @returns {Promise<'cocoa'|'portal'|'osascript'|'builtin'>}
|
|
278
327
|
*/
|
|
279
328
|
export async function fileDialogBackend() {
|
|
329
|
+
if (panelsApp(null)) return 'cocoa';
|
|
280
330
|
const ref = await sessionBus();
|
|
281
331
|
if (ref) {
|
|
282
332
|
try {
|
|
@@ -298,6 +348,21 @@ export async function fileDialogBackend() {
|
|
|
298
348
|
export async function runNativeDialog(kind, opts = {}) {
|
|
299
349
|
if (opts.backend === 'builtin') throw new NoFileDialogError();
|
|
300
350
|
|
|
351
|
+
const wantCocoa = !opts.backend || opts.backend === 'cocoa';
|
|
352
|
+
if (wantCocoa) {
|
|
353
|
+
const wnd = windowOf(opts.parentWindow);
|
|
354
|
+
const app = panelsApp(wnd);
|
|
355
|
+
if (app) return await app.filePanels.show(kind, opts, wnd);
|
|
356
|
+
if (opts.backend === 'cocoa') {
|
|
357
|
+
throw new NoFileDialogError(
|
|
358
|
+
new Error(
|
|
359
|
+
"backend: 'cocoa' — no native file panels here: the window is not " +
|
|
360
|
+
'on the cocoa backend, or the bridge is older than 0.5.',
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
301
366
|
const wantPortal = !opts.backend || opts.backend === 'portal';
|
|
302
367
|
if (wantPortal) {
|
|
303
368
|
const ref = await sessionBus();
|
package/src/index.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export * from './types/screencolor.js';
|
|
|
25
25
|
export * from './types/appearance.js';
|
|
26
26
|
export * from './types/fonts.js';
|
|
27
27
|
export * from './types/system.js';
|
|
28
|
+
export * from './types/launcher.js';
|
|
29
|
+
export * from './types/tray.js';
|
|
30
|
+
export * from './types/permissions.js';
|
|
31
|
+
export * from './types/notifications.js';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The XID of the X11 window a ref points at, or `null` if there is not one
|
|
@@ -191,12 +195,24 @@ export interface RootOptions {
|
|
|
191
195
|
* as the bridge reports it, 16 where the OS cannot say. A number here
|
|
192
196
|
* applies to every window instead. `pumpInterval` is the AppKit event
|
|
193
197
|
* pump's cadence, in ms (8 by default), which is the floor under input
|
|
194
|
-
* latency.
|
|
198
|
+
* latency. `appName` is what the Dock, ⌘-Tab and the app menu print for
|
|
199
|
+
* an unbundled process (a bundle's Info.plist wins); `activationPolicy`
|
|
200
|
+
* is `'regular'` (a Dock tile, a ⌘-Tab entry — the default),
|
|
201
|
+
* `'accessory'` (a menu-bar app: windows but no tile) or `'prohibited'`,
|
|
202
|
+
* fixed before the app finishes launching. `exitOnQuit` (default `true`)
|
|
203
|
+
* ends the process once a quit request — the Dock's Quit, ⌘Q, a logout —
|
|
204
|
+
* has closed the app: the request routes through the primary window's
|
|
205
|
+
* close request first, so `onCloseRequest` there is where an app
|
|
206
|
+
* intercepts it; `false` is for an embedder that owns the process's
|
|
207
|
+
* lifetime. Ignored off macOS and when {@link RootOptions.app} is passed.
|
|
195
208
|
*/
|
|
196
209
|
cocoa?: {
|
|
197
210
|
presenter?: 'surface' | 'layers';
|
|
198
211
|
frameInterval?: number;
|
|
199
212
|
pumpInterval?: number;
|
|
213
|
+
appName?: string;
|
|
214
|
+
activationPolicy?: 'regular' | 'accessory' | 'prohibited';
|
|
215
|
+
exitOnQuit?: boolean;
|
|
200
216
|
};
|
|
201
217
|
/**
|
|
202
218
|
* The size, in logical pixels, under which a `<text>` is painted as a
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,23 @@ export {
|
|
|
10
10
|
registerApplication,
|
|
11
11
|
} from './application.js';
|
|
12
12
|
export { useAppActivate, useAppOpen } from './apphooks.js';
|
|
13
|
+
export { setBadge } from './launcher.js';
|
|
14
|
+
export { useBadge, useDockMenu } from './launcherhooks.js';
|
|
15
|
+
export { useTray } from './trayhooks.js';
|
|
16
|
+
export {
|
|
17
|
+
NoPermissionServiceError,
|
|
18
|
+
openPrivacySettings,
|
|
19
|
+
permissionBackend,
|
|
20
|
+
permissionStatus,
|
|
21
|
+
requestPermission,
|
|
22
|
+
} from './permissions.js';
|
|
23
|
+
export { usePermission } from './permissionhooks.js';
|
|
24
|
+
export {
|
|
25
|
+
NoNotificationServiceError,
|
|
26
|
+
notificationBackend,
|
|
27
|
+
notify,
|
|
28
|
+
} from './notifications.js';
|
|
29
|
+
export { useNotifier } from './notificationhooks.js';
|
|
13
30
|
export { parseUriList } from './transfer.js';
|
|
14
31
|
export { useApp, useClipboard, useSupports } from './appcontext.js';
|
|
15
32
|
export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The launcher's view of the app: the badge on its icon.
|
|
2
|
+
//
|
|
3
|
+
// A count on the Dock tile, a dot on the taskbar entry — the one thing an
|
|
4
|
+
// app says to the desktop that the user reads without opening it. Two
|
|
5
|
+
// desktops, two mechanisms, one call:
|
|
6
|
+
//
|
|
7
|
+
// 1. **the app's own tile** — the cocoa backend's `NSDockTile.badgeLabel`,
|
|
8
|
+
// reached through the app object (`setDockBadge`, src/cocoa/app.js).
|
|
9
|
+
// Any string shows, and it is the whole of the API on a Mac.
|
|
10
|
+
// 2. **`com.canonical.Unity.LauncherEntry`** over the session bus — the
|
|
11
|
+
// protocol Unity defined and the KDE, elementary and Cairo-Dock
|
|
12
|
+
// launchers still listen for. One signal, `Update(app_uri, a{sv})`,
|
|
13
|
+
// carrying a `count` and whether it is visible, attributed to the app
|
|
14
|
+
// by `application://<id>.desktop` — which is why it needs the identity
|
|
15
|
+
// `registerApplication({ appId })` established: with no app id there is
|
|
16
|
+
// nothing for a launcher to pin the count to, and the call resolves
|
|
17
|
+
// false rather than guessing.
|
|
18
|
+
//
|
|
19
|
+
// The two disagree on what a badge *is*, and the API takes the stricter
|
|
20
|
+
// shape: a **count**. A number shows on both; a string shows on macOS and is
|
|
21
|
+
// a visible count of nothing on Linux (the protocol has no text field), so
|
|
22
|
+
// pass a string only where the Mac is the audience. `0`, `null` and `''`
|
|
23
|
+
// all clear it, because a badge of zero is the badge nobody wanted.
|
|
24
|
+
//
|
|
25
|
+
// Nothing here imports react: `useBadge` (launcherhooks.js) is the hook, and
|
|
26
|
+
// this is the function under it, callable from host-side code with no tree.
|
|
27
|
+
// The entry on the bus is held for as long as the badge is shown and
|
|
28
|
+
// released when it is cleared — a held bus ref is a ref()'d socket, and an
|
|
29
|
+
// app that cleared its badge on the way out should be free to exit.
|
|
30
|
+
|
|
31
|
+
import { currentRegistration } from './application.js';
|
|
32
|
+
import { loadTransport, sessionBus } from './bus.js';
|
|
33
|
+
import { liveApps } from './trace-registry.js';
|
|
34
|
+
|
|
35
|
+
export const LAUNCHER_ENTRY_IFACE = 'com.canonical.Unity.LauncherEntry';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The object path libunity exports an entry at: a djb2 hash of the app URI
|
|
39
|
+
* under a fixed prefix. Launchers match on the signal's `app_uri` argument
|
|
40
|
+
* rather than the path, so any unique path would do — this one is the
|
|
41
|
+
* conventional one, and the tests read it to subscribe.
|
|
42
|
+
*/
|
|
43
|
+
export function launcherEntryPath(appUri) {
|
|
44
|
+
let hash = 5381;
|
|
45
|
+
for (const ch of String(appUri)) hash = (hash * 33 + ch.codePointAt(0)) >>> 0;
|
|
46
|
+
return `/com/canonical/unity/launcherentry/${hash}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `application://<appId>.desktop`, the URI the desktop knows an app by. */
|
|
50
|
+
export function launcherAppUri(appId) {
|
|
51
|
+
return `application://${appId}.desktop`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A badge value as the label a tile shows, or `null` for none: a number is
|
|
56
|
+
* its digits, a string is itself, and zero, empty, false and nothing are all
|
|
57
|
+
* "no badge".
|
|
58
|
+
*/
|
|
59
|
+
export function badgeLabel(value) {
|
|
60
|
+
if (value == null || value === false || value === '') return null;
|
|
61
|
+
if (typeof value === 'number') {
|
|
62
|
+
return Number.isFinite(value) && value !== 0
|
|
63
|
+
? String(Math.trunc(value))
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
return String(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The app to badge when the caller did not say: the one connection the
|
|
70
|
+
* renderer draws through, or the one of several still showing a window. */
|
|
71
|
+
function soleApp() {
|
|
72
|
+
const apps = liveApps();
|
|
73
|
+
if (apps.length <= 1) return apps[0] ?? null;
|
|
74
|
+
const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
|
|
75
|
+
return showing.length === 1 ? showing[0] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The one exported entry per process — an app has one icon. */
|
|
79
|
+
let entry = null;
|
|
80
|
+
|
|
81
|
+
async function launcherEntry(appUri) {
|
|
82
|
+
if (entry && entry.appUri === appUri) return entry;
|
|
83
|
+
if (entry) await releaseEntry();
|
|
84
|
+
const ref = await sessionBus();
|
|
85
|
+
if (!ref) return null;
|
|
86
|
+
let dbus;
|
|
87
|
+
try {
|
|
88
|
+
dbus = await loadTransport();
|
|
89
|
+
} catch {
|
|
90
|
+
await ref.release();
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const iface = dbus.defineInterface({
|
|
94
|
+
name: LAUNCHER_ENTRY_IFACE,
|
|
95
|
+
methods: {},
|
|
96
|
+
signals: {
|
|
97
|
+
Update: { args: { app_uri: 's', properties: 'a{sv}' } },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
let registration;
|
|
101
|
+
try {
|
|
102
|
+
registration = await ref.bus.export(launcherEntryPath(appUri), iface);
|
|
103
|
+
} catch {
|
|
104
|
+
await ref.release();
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
entry = { appUri, ref, dbus, iface, registration };
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function releaseEntry() {
|
|
112
|
+
const held = entry;
|
|
113
|
+
entry = null;
|
|
114
|
+
if (!held) return;
|
|
115
|
+
await held.registration?.remove?.().catch(() => {});
|
|
116
|
+
await held.ref.release();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Show `value` on the app's icon, or clear it.
|
|
121
|
+
*
|
|
122
|
+
* ```js
|
|
123
|
+
* await setBadge(unread); // 3 → "3"; 0 → cleared
|
|
124
|
+
* await setBadge(null); // cleared
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* Resolves to whether a launcher was told: `false` means there is nothing
|
|
128
|
+
* on this machine to show a badge — no cocoa Dock tile, and no session bus
|
|
129
|
+
* or no `registerApplication({ appId })` for the Linux protocol to attribute
|
|
130
|
+
* it to. Never rejects for anything about the machine: a badge is not a
|
|
131
|
+
* thing an app should have an error path for.
|
|
132
|
+
*
|
|
133
|
+
* `app` names the connection when there are several; the one the renderer
|
|
134
|
+
* draws through is the default.
|
|
135
|
+
*/
|
|
136
|
+
export async function setBadge(value, { app } = {}) {
|
|
137
|
+
const label = badgeLabel(value);
|
|
138
|
+
const target = app ?? soleApp();
|
|
139
|
+
|
|
140
|
+
// Rung 1: the app's own tile.
|
|
141
|
+
if (typeof target?.setDockBadge === 'function') {
|
|
142
|
+
target.setDockBadge(label);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Rung 2: the launcher protocol, which needs an identity to badge.
|
|
147
|
+
const appId = currentRegistration()?.appId;
|
|
148
|
+
if (!appId) {
|
|
149
|
+
if (label === null) await releaseEntry();
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const appUri = launcherAppUri(appId);
|
|
153
|
+
if (label === null && !entry) return false; // nothing shown, nothing to clear
|
|
154
|
+
const held = await launcherEntry(appUri);
|
|
155
|
+
if (!held) return false;
|
|
156
|
+
const count =
|
|
157
|
+
typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
158
|
+
const V = held.dbus.Variant;
|
|
159
|
+
held.iface.emit.Update(appUri, {
|
|
160
|
+
count: new V('x', label === null ? 0 : count),
|
|
161
|
+
'count-visible': new V('b', label !== null),
|
|
162
|
+
});
|
|
163
|
+
if (label === null) await releaseEntry();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Test seam, not public: drop the exported entry without emitting. */
|
|
168
|
+
export async function _resetLauncher() {
|
|
169
|
+
await releaseEntry();
|
|
170
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `useBadge()` and `useDockMenu()` — the launcher's view of the app, as
|
|
2
|
+
// things a component declares rather than manages.
|
|
3
|
+
//
|
|
4
|
+
// The badge lives in `launcher.js` and is cross-backend; the Dock menu is
|
|
5
|
+
// the cocoa backend's alone, because its freedesktop counterpart — desktop
|
|
6
|
+
// actions in the `.desktop` file — is an install step and not runtime code.
|
|
7
|
+
// Where a hook has no mechanism it is inert, with a one-time development
|
|
8
|
+
// note naming the backend: the inert-props policy of docs/macos.md, so
|
|
9
|
+
// shared app code stays branch-free.
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef } from 'react';
|
|
12
|
+
|
|
13
|
+
import { useAppOrNull } from './appcontext.js';
|
|
14
|
+
import { setBadge } from './launcher.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Show `value` on the app's icon while this component is mounted, and clear
|
|
18
|
+
* it on unmount.
|
|
19
|
+
*
|
|
20
|
+
* ```jsx
|
|
21
|
+
* useBadge(unread); // a count; 0 clears
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* A number shows on both backends; a string shows on macOS only — see
|
|
25
|
+
* docs/desktop.md. Nothing is shown where nothing can show it, silently: a
|
|
26
|
+
* badge is not a feature an app should branch on.
|
|
27
|
+
*/
|
|
28
|
+
export function useBadge(value) {
|
|
29
|
+
const app = useAppOrNull();
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setBadge(value, { app }).catch(() => {});
|
|
32
|
+
}, [value, app]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
return () => {
|
|
35
|
+
setBadge(null, { app }).catch(() => {});
|
|
36
|
+
};
|
|
37
|
+
}, [app]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let warnedInert = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The menu behind a right-click on the Dock icon, from the same `items`
|
|
44
|
+
* vocabulary `MenuBar` and `ContextMenu` take — an item's `onSelect` fires
|
|
45
|
+
* when the user picks it.
|
|
46
|
+
*
|
|
47
|
+
* ```jsx
|
|
48
|
+
* useDockMenu([
|
|
49
|
+
* { label: 'New Window', onSelect: openWindow },
|
|
50
|
+
* { type: 'separator' },
|
|
51
|
+
* { label: 'Recent', items: recent.map(toItem) },
|
|
52
|
+
* ]);
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* Installed for as long as the component is mounted, replaced when `items`
|
|
56
|
+
* changes, and taken down on unmount. Inert off the cocoa backend, with a
|
|
57
|
+
* development note the first time.
|
|
58
|
+
*/
|
|
59
|
+
export function useDockMenu(items) {
|
|
60
|
+
const app = useAppOrNull();
|
|
61
|
+
// read at activation time, so a pick three minutes from now runs the
|
|
62
|
+
// handler from the current render rather than the mounting one
|
|
63
|
+
const live = useRef(items);
|
|
64
|
+
live.current = items;
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (typeof app?.setDockMenu !== 'function') {
|
|
68
|
+
if (process.env.NODE_ENV !== 'production' && !warnedInert && app) {
|
|
69
|
+
warnedInert = true;
|
|
70
|
+
console.warn(
|
|
71
|
+
'react-x11: useDockMenu() is inert on this backend — only the ' +
|
|
72
|
+
'cocoa backend has a Dock. On a Linux desktop the counterpart ' +
|
|
73
|
+
'is desktop actions in the .desktop file (docs/desktop.md).',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
app.setDockMenu(items ?? null);
|
|
79
|
+
return () => app.setDockMenu(null);
|
|
80
|
+
}, [items, app]);
|
|
81
|
+
}
|