react-x11 2.6.1 → 2.8.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 +367 -3
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/dnd.js +358 -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 +274 -33
- package/src/cocoa/promotion.js +708 -0
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +113 -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 +137 -11
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +29 -2
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +604 -37
- 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
|
@@ -258,6 +258,13 @@ export class DropSession {
|
|
|
258
258
|
// FIFO gate: messages queue behind atom interning and XdndEnter's
|
|
259
259
|
// async type-list resolution, so a Position never overtakes its Enter.
|
|
260
260
|
this._chain = Promise.resolve();
|
|
261
|
+
// The lane an enter/over dispatch schedules its renders in. Continuous
|
|
262
|
+
// by default, because a drag hovering is a stream like the pointer's
|
|
263
|
+
// own and the frame clock paces it. A transport whose backend *stops*
|
|
264
|
+
// that clock for the duration of the drag raises it — the cocoa one
|
|
265
|
+
// does (src/cocoa/dnd.js), because inside AppKit's tracking loop a
|
|
266
|
+
// render scheduled for the next frame lands after the drop.
|
|
267
|
+
this.hoverPriority = ContinuousEventPriority;
|
|
261
268
|
this._reset();
|
|
262
269
|
}
|
|
263
270
|
|
|
@@ -269,6 +276,9 @@ export class DropSession {
|
|
|
269
276
|
this.path = [];
|
|
270
277
|
// what an `ask` source offered, read once per drag (see _readAskOffer)
|
|
271
278
|
this._askOffer = null;
|
|
279
|
+
// driven through the local entry points (a DragSession here, or a
|
|
280
|
+
// backend's own drop machinery) rather than the XDND wire
|
|
281
|
+
this._viaLocal = false;
|
|
272
282
|
this.accepted = null;
|
|
273
283
|
this.acceptedAction = 'copy';
|
|
274
284
|
this.requestedAction = 'copy';
|
|
@@ -467,7 +477,7 @@ export class DropSession {
|
|
|
467
477
|
action: this.requestedAction,
|
|
468
478
|
freeze: false,
|
|
469
479
|
};
|
|
470
|
-
runWithPriority(
|
|
480
|
+
runWithPriority(this.hoverPriority, () => {
|
|
471
481
|
this._updateDragPath(path, native);
|
|
472
482
|
// onDragOver may override the declarative answer, synchronously —
|
|
473
483
|
// same latency budget as any event handler, no render awaited
|
|
@@ -491,7 +501,13 @@ export class DropSession {
|
|
|
491
501
|
// back as a return value instead of an XdndStatus.
|
|
492
502
|
|
|
493
503
|
localOver(rootX, rootY, offer, time) {
|
|
494
|
-
|
|
504
|
+
// `offer.source` is what the handlers see: 'internal' for a DragSession
|
|
505
|
+
// in this process, 'external' when a backend's own drop machinery drives
|
|
506
|
+
// these same entry points for another application's drag (src/cocoa/
|
|
507
|
+
// dnd.js) — the path diffing and the dispatch are one implementation
|
|
508
|
+
// either way
|
|
509
|
+
this._source = offer.source ?? 'internal';
|
|
510
|
+
this._viaLocal = true;
|
|
495
511
|
this.sourceWid = 0;
|
|
496
512
|
this.types = offer.types;
|
|
497
513
|
this.requestedAction = offer.action;
|
|
@@ -500,10 +516,11 @@ export class DropSession {
|
|
|
500
516
|
}
|
|
501
517
|
|
|
502
518
|
localLeave() {
|
|
503
|
-
if (this.
|
|
519
|
+
if (!this._viaLocal) return;
|
|
504
520
|
discrete(() => this._clearPath())();
|
|
505
521
|
this.accepted = null;
|
|
506
522
|
this.lastPoint = null;
|
|
523
|
+
this._viaLocal = false;
|
|
507
524
|
this._source = 'external';
|
|
508
525
|
}
|
|
509
526
|
|
|
@@ -512,7 +529,7 @@ export class DropSession {
|
|
|
512
529
|
* from the caller's point of view — `onDragEnd` follows immediately,
|
|
513
530
|
* like the DOM's dragend after drop. */
|
|
514
531
|
localDrop(offer, extras, time) {
|
|
515
|
-
this._source = 'internal';
|
|
532
|
+
this._source = offer.source ?? 'internal';
|
|
516
533
|
this.types = offer.types;
|
|
517
534
|
const point = this.lastPoint;
|
|
518
535
|
const accepted = this.accepted;
|
|
@@ -555,6 +572,7 @@ export class DropSession {
|
|
|
555
572
|
})();
|
|
556
573
|
this.accepted = null;
|
|
557
574
|
this.lastPoint = null;
|
|
575
|
+
this._viaLocal = false;
|
|
558
576
|
this._source = 'external';
|
|
559
577
|
return {
|
|
560
578
|
handled: outcome.accept,
|
|
@@ -1186,6 +1204,7 @@ export class DragSession {
|
|
|
1186
1204
|
this._atoms = null;
|
|
1187
1205
|
this._cursor = null;
|
|
1188
1206
|
this.ext = null;
|
|
1207
|
+
this._nativeSession = false;
|
|
1189
1208
|
}
|
|
1190
1209
|
|
|
1191
1210
|
/** The pressed node (or an ancestor) is draggable: remember where, and
|
|
@@ -1219,6 +1238,7 @@ export class DragSession {
|
|
|
1219
1238
|
/** Every mousemove while armed or dragging. Returns true when the drag
|
|
1220
1239
|
* consumed the motion — the caller then skips hover and mousemove. */
|
|
1221
1240
|
motion(native) {
|
|
1241
|
+
if (this._nativeSession) return true;
|
|
1222
1242
|
if (this.phase === 'armed') {
|
|
1223
1243
|
const moved =
|
|
1224
1244
|
Math.abs(native.x - this.press.x) + Math.abs(native.y - this.press.y);
|
|
@@ -1245,13 +1265,33 @@ export class DragSession {
|
|
|
1245
1265
|
Array.isArray(actions) && actions.length > 0 ? actions : ['copy'];
|
|
1246
1266
|
this.currentAction = this.actions[0];
|
|
1247
1267
|
this._resolved = new Map();
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1268
|
+
// A backend with a drag session of its own (the cocoa backend's
|
|
1269
|
+
// NSDraggingSession, src/cocoa/dnd.js) takes the gesture from here:
|
|
1270
|
+
// the pointer's motion and release stop arriving and come back as
|
|
1271
|
+
// `nativeMoved`/`nativeEnded`, and a drop on one of our own windows
|
|
1272
|
+
// comes through that window's destination events, routed to this
|
|
1273
|
+
// session's live payload by `app._activeDrag`.
|
|
1274
|
+
const wnd = this.node.window;
|
|
1275
|
+
const nativeSession = typeof wnd?.beginDrag === 'function';
|
|
1276
|
+
// …and it owns the thread for the *whole* gesture, so anything this
|
|
1277
|
+
// dispatch schedules for later has no later: the `<popup dragPreview>`
|
|
1278
|
+
// an `onDragStart` setState renders would otherwise be created after
|
|
1279
|
+
// the drop. Discrete priority puts the update in the one lane a
|
|
1280
|
+
// backend can land by hand from inside a callback (`flushSyncWork`,
|
|
1281
|
+
// src/cocoa/app.js `_afterInput`). Where the frame clock keeps running,
|
|
1282
|
+
// the motion this arrived on is paced like any other and the update
|
|
1283
|
+
// keeps the priority the dispatcher gave it.
|
|
1284
|
+
const startEvent = () =>
|
|
1285
|
+
this.node.events.dispatch('DragStart', source, native, {
|
|
1286
|
+
types: this.types,
|
|
1287
|
+
action: this.currentAction,
|
|
1288
|
+
source: 'internal',
|
|
1289
|
+
screenX: (native.rootx ?? native.x) / this.node.events.scale,
|
|
1290
|
+
screenY: (native.rooty ?? native.y) / this.node.events.scale,
|
|
1291
|
+
});
|
|
1292
|
+
const ev = nativeSession
|
|
1293
|
+
? runWithPriority(DiscreteEventPriority, startEvent)
|
|
1294
|
+
: startEvent();
|
|
1255
1295
|
if (ev.defaultPrevented) {
|
|
1256
1296
|
this._reset();
|
|
1257
1297
|
return false;
|
|
@@ -1259,9 +1299,94 @@ export class DragSession {
|
|
|
1259
1299
|
this.phase = 'dragging';
|
|
1260
1300
|
source.setStyleState(':dragging', true);
|
|
1261
1301
|
this._setCursor('grab');
|
|
1302
|
+
if (nativeSession) {
|
|
1303
|
+
this._nativeSession = true;
|
|
1304
|
+
this.app._activeDrag = this;
|
|
1305
|
+
try {
|
|
1306
|
+
wnd.beginDrag(this);
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
this._nativeSession = false;
|
|
1309
|
+
this.app._activeDrag = null;
|
|
1310
|
+
this._reset();
|
|
1311
|
+
throw err;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1262
1314
|
return true;
|
|
1263
1315
|
}
|
|
1264
1316
|
|
|
1317
|
+
/** `drag-session-moved` on a native session: the source's `onDrag`, in
|
|
1318
|
+
* global device pixels, with whether a window of ours has accepted.
|
|
1319
|
+
*
|
|
1320
|
+
* Discrete priority, like the start: this is the only news of the gesture
|
|
1321
|
+
* that arrives while the native session owns the thread, so the render it
|
|
1322
|
+
* schedules has to be landable from inside the callback. A preview that
|
|
1323
|
+
* follows the pointer is exactly a render per position. */
|
|
1324
|
+
nativeMoved(ev) {
|
|
1325
|
+
if (this.phase !== 'dragging' || !this._nativeSession) return;
|
|
1326
|
+
const s = this.node.scale;
|
|
1327
|
+
const origin = this.node.window?._screenOrigin ?? { x: 0, y: 0 };
|
|
1328
|
+
const rootX = Math.round(ev.x * s);
|
|
1329
|
+
const rootY = Math.round(ev.y * s);
|
|
1330
|
+
const native = {
|
|
1331
|
+
x: rootX - origin.x,
|
|
1332
|
+
y: rootY - origin.y,
|
|
1333
|
+
rootx: rootX,
|
|
1334
|
+
rooty: rootY,
|
|
1335
|
+
buttons: 256,
|
|
1336
|
+
time: 0,
|
|
1337
|
+
};
|
|
1338
|
+
const source = this.source;
|
|
1339
|
+
if (source && !source.destroyed && source.props.onDrag) {
|
|
1340
|
+
runWithPriority(DiscreteEventPriority, () =>
|
|
1341
|
+
callHandler(
|
|
1342
|
+
source,
|
|
1343
|
+
'onDrag',
|
|
1344
|
+
source.props.onDrag,
|
|
1345
|
+
this.node.events._makeEvent('drag', native, source, {
|
|
1346
|
+
types: this.types,
|
|
1347
|
+
action: this.currentAction,
|
|
1348
|
+
source: this.localSession ? 'internal' : 'external',
|
|
1349
|
+
accepted: this.accepted,
|
|
1350
|
+
screenX: rootX / this.node.events.scale,
|
|
1351
|
+
screenY: rootY / this.node.events.scale,
|
|
1352
|
+
}),
|
|
1353
|
+
),
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** `drag-session-ended`: the release, with what the destination did. */
|
|
1359
|
+
nativeEnded(ev) {
|
|
1360
|
+
if (!this._nativeSession) return;
|
|
1361
|
+
this._nativeSession = false;
|
|
1362
|
+
if (this.app._activeDrag === this) this.app._activeDrag = null;
|
|
1363
|
+
if (this.phase !== 'dragging') return this._reset();
|
|
1364
|
+
const source = this.source;
|
|
1365
|
+
source?.setStyleState?.(':dragging', false);
|
|
1366
|
+
this.localSession?.localLeave();
|
|
1367
|
+
const s = this.node.scale;
|
|
1368
|
+
const origin = this.node.window?._screenOrigin ?? { x: 0, y: 0 };
|
|
1369
|
+
const rootX = Math.round((ev.x ?? 0) * s);
|
|
1370
|
+
const rootY = Math.round((ev.y ?? 0) * s);
|
|
1371
|
+
const operation =
|
|
1372
|
+
ev.operation && ev.operation !== 'none' ? ev.operation : null;
|
|
1373
|
+
// the release, and the last callback before the thread comes back:
|
|
1374
|
+
// `onDragEnd` takes the preview down, and that is a discrete answer to
|
|
1375
|
+
// the button like any other
|
|
1376
|
+
runWithPriority(DiscreteEventPriority, () =>
|
|
1377
|
+
this._end(
|
|
1378
|
+
{
|
|
1379
|
+
x: rootX - origin.x,
|
|
1380
|
+
y: rootY - origin.y,
|
|
1381
|
+
rootx: rootX,
|
|
1382
|
+
rooty: rootY,
|
|
1383
|
+
},
|
|
1384
|
+
ev.dropped ? operation : null,
|
|
1385
|
+
Boolean(ev.dropped),
|
|
1386
|
+
),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1265
1390
|
/** dragData values resolve once per drag: thunks are called on first
|
|
1266
1391
|
* use — at delivery for an internal drop, at promotion for an external
|
|
1267
1392
|
* one — and never at mousedown. */
|
|
@@ -1354,6 +1479,7 @@ export class DragSession {
|
|
|
1354
1479
|
/** Button release. Returns true when a drag ran (the caller suppresses
|
|
1355
1480
|
* mouseup/click, like the DOM after a drag gesture). */
|
|
1356
1481
|
release(native) {
|
|
1482
|
+
if (this._nativeSession) return true;
|
|
1357
1483
|
if (this.phase !== 'dragging') {
|
|
1358
1484
|
this._reset();
|
|
1359
1485
|
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
|
|
@@ -184,19 +188,42 @@ export interface RootOptions {
|
|
|
184
188
|
* The Cocoa backend's knobs (docs/macos.md). `presenter` picks the frame
|
|
185
189
|
* path: `'surface'` (the measured default — one bitmap per window, the
|
|
186
190
|
* X11 paint machinery over an IOSurface swapchain) or `'layers'` (one
|
|
187
|
-
* CALayer per drawn node, opt-in while it is measured).
|
|
191
|
+
* CALayer per drawn node, opt-in while it is measured). `promote` is the
|
|
192
|
+
* surface presenter's layer promotion: a plain `<box>` with a transition
|
|
193
|
+
* or a loop on its colour, border or radius gets a CALayer of its own
|
|
194
|
+
* above the bitmap for as long as it animates, and the render server
|
|
195
|
+
* draws the motion — no frames, and it keeps moving while the JS thread
|
|
196
|
+
* is busy. On by default where the bridge draws a layer's colour and a
|
|
197
|
+
* rastered one alike (`@windowkit/appkit` >= 0.5.1, which says so with
|
|
198
|
+
* `colorSpace()`; off on 0.5.0, where the two shades differed); `true`
|
|
199
|
+
* turns it on regardless, `false` keeps every animation on the frame
|
|
200
|
+
* clock.
|
|
201
|
+
* `REACT_X11_COCOA_PROMOTE=1` / `=0` say the same from the environment.
|
|
188
202
|
* `frameInterval` is how often a scheduled frame may paint, in ms. By
|
|
189
203
|
* default each window paces itself on the display it is on — 8.3ms on
|
|
190
204
|
* a 120Hz panel, 16.7 on a 60Hz monitor, the screen's own refresh rate
|
|
191
205
|
* as the bridge reports it, 16 where the OS cannot say. A number here
|
|
192
206
|
* applies to every window instead. `pumpInterval` is the AppKit event
|
|
193
207
|
* pump's cadence, in ms (8 by default), which is the floor under input
|
|
194
|
-
* latency.
|
|
208
|
+
* latency. `appName` is what the Dock, ⌘-Tab and the app menu print for
|
|
209
|
+
* an unbundled process (a bundle's Info.plist wins); `activationPolicy`
|
|
210
|
+
* is `'regular'` (a Dock tile, a ⌘-Tab entry — the default),
|
|
211
|
+
* `'accessory'` (a menu-bar app: windows but no tile) or `'prohibited'`,
|
|
212
|
+
* fixed before the app finishes launching. `exitOnQuit` (default `true`)
|
|
213
|
+
* ends the process once a quit request — the Dock's Quit, ⌘Q, a logout —
|
|
214
|
+
* has closed the app: the request routes through the primary window's
|
|
215
|
+
* close request first, so `onCloseRequest` there is where an app
|
|
216
|
+
* intercepts it; `false` is for an embedder that owns the process's
|
|
217
|
+
* lifetime. Ignored off macOS and when {@link RootOptions.app} is passed.
|
|
195
218
|
*/
|
|
196
219
|
cocoa?: {
|
|
197
220
|
presenter?: 'surface' | 'layers';
|
|
221
|
+
promote?: boolean;
|
|
198
222
|
frameInterval?: number;
|
|
199
223
|
pumpInterval?: number;
|
|
224
|
+
appName?: string;
|
|
225
|
+
activationPolicy?: 'regular' | 'accessory' | 'prohibited';
|
|
226
|
+
exitOnQuit?: boolean;
|
|
200
227
|
};
|
|
201
228
|
/**
|
|
202
229
|
* 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
|
+
}
|