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.
Files changed (59) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +367 -3
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +358 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +274 -33
  18. package/src/cocoa/promotion.js +708 -0
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +113 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +137 -11
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +29 -2
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +604 -37
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/style.d.ts +10 -4
  44. package/src/style.js +1 -0
  45. package/src/styles.js +161 -15
  46. package/src/textselection.js +1 -4
  47. package/src/trayhooks.js +90 -0
  48. package/src/types/appearance.d.ts +24 -0
  49. package/src/types/components.d.ts +10 -0
  50. package/src/types/elements.d.ts +14 -0
  51. package/src/types/events.d.ts +14 -0
  52. package/src/types/filedialog.d.ts +18 -7
  53. package/src/types/launcher.d.ts +43 -0
  54. package/src/types/notifications.d.ts +113 -0
  55. package/src/types/permissions.d.ts +100 -0
  56. package/src/types/style.d.ts +30 -2
  57. package/src/types/system.d.ts +5 -3
  58. package/src/types/tray.d.ts +54 -0
  59. 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(ContinuousEventPriority, () => {
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
- this._source = 'internal';
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._source !== 'internal') return;
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
- const ev = this.node.events.dispatch('DragStart', source, native, {
1249
- types: this.types,
1250
- action: this.currentAction,
1251
- source: 'internal',
1252
- screenX: (native.rootx ?? native.x) / this.node.events.scale,
1253
- screenY: (native.rooty ?? native.y) / this.node.events.scale,
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(node, message) {
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 portal** — `org.freedesktop.portal.FileChooser` over D-Bus. 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
- // 2. **`osascript`** — macOS with no portal, which is every XQuartz install
9
- // that has not gone out of its way. `choose file` is `NSOpenPanel`, so
10
- // the user gets the dialog they know.
11
- // 3. **the built-in dialog** — a file browser drawn by react-x11 itself.
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
- // - **macOS has no cross-process transient-for at all.** XQuartz windows are
28
- // `NSWindow`s owned by X11.app, and `addChildWindow` is same-process only.
29
- // So the panel appears over the app but is not attached to it. Application
30
- // modality still works, because the caller is awaiting the promise.
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 properly — and the only one that has never seen the
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 { windowIdOf } from './windowid.js';
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 — there is no ' +
64
- 'xdg-desktop-portal on the bus, and this is not macOS. Use ' +
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 — ${stderr.trim() || error.message}`,
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. Ignored off macOS and when {@link RootOptions.app} is passed.
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';
@@ -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
+ }