react-x11 2.16.0 → 2.17.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 (62) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/acceleratorhooks.js +40 -6
  6. package/src/anchor.js +20 -2
  7. package/src/appcontext.js +8 -0
  8. package/src/appearance.js +36 -0
  9. package/src/{cocoa → backend}/context2d.js +27 -7
  10. package/src/capabilities.js +99 -1
  11. package/src/cocoa/app.js +204 -6
  12. package/src/cocoa/fonts.js +1 -1
  13. package/src/cocoa/overlay.js +2 -2
  14. package/src/cocoa/panewindow.js +2 -2
  15. package/src/cocoa/presenter.js +2 -2
  16. package/src/cocoa/surface.js +3 -3
  17. package/src/cocoa/window.js +2 -2
  18. package/src/events.js +21 -0
  19. package/src/foreignnodes.js +8 -3
  20. package/src/frame/index.js +30 -4
  21. package/src/glnodes.js +12 -1
  22. package/src/idle.js +59 -1
  23. package/src/index.d.ts +51 -1
  24. package/src/index.js +30 -3
  25. package/src/keysymchars.js +47 -0
  26. package/src/keysyms.d.ts +19 -1
  27. package/src/keysyms.js +107 -8
  28. package/src/launcher.js +17 -8
  29. package/src/launcherhooks.js +24 -10
  30. package/src/node.d.ts +1 -1
  31. package/src/nodes/cascade.js +9 -0
  32. package/src/nodes/node.js +6 -1
  33. package/src/nodes/window/hints.js +21 -2
  34. package/src/nodes/window/window.js +2 -2
  35. package/src/notifications.js +39 -14
  36. package/src/screens.js +159 -24
  37. package/src/taskbarhooks.js +164 -0
  38. package/src/transfer.js +20 -1
  39. package/src/trayhooks.js +1 -1
  40. package/src/types/capabilities.d.ts +32 -3
  41. package/src/types/elements.d.ts +23 -1
  42. package/src/types/events.d.ts +21 -0
  43. package/src/types/filedialog.d.ts +3 -1
  44. package/src/types/launcher.d.ts +20 -6
  45. package/src/types/taskbar.d.ts +79 -0
  46. package/src/wayland/context2d.js +1 -1
  47. package/src/wayland/xkb.js +170 -59
  48. package/src/win32/a11y.js +604 -0
  49. package/src/win32/app.js +768 -0
  50. package/src/win32/bezels.js +158 -0
  51. package/src/win32/dnd.js +283 -0
  52. package/src/win32/fonts.js +497 -0
  53. package/src/win32/glarea.js +548 -0
  54. package/src/win32/ime.js +267 -0
  55. package/src/win32/keymap.js +116 -0
  56. package/src/win32/native.js +54 -0
  57. package/src/win32/panehost.js +106 -0
  58. package/src/win32/panewindow.js +343 -0
  59. package/src/win32/shell.js +426 -0
  60. package/src/win32/surface.js +192 -0
  61. package/src/win32/window.js +659 -0
  62. package/src/windowid.js +128 -20
@@ -232,6 +232,23 @@ export function windowAttributes(props, scale = 1) {
232
232
  : props[key];
233
233
  }
234
234
  if (Object.keys(hints).length > 0) attributes.sizeHints = hints;
235
+ // The desktop identity of this window, under the one name every desktop's
236
+ // own word maps onto: `WM_CLASS` on X11, `app_id` on Wayland,
237
+ // AppUserModelID on Windows. `wmClass` was X11's word for it and is still
238
+ // accepted, so both are normalised here and a backend reads one key.
239
+ //
240
+ // X11 carries a *pair* — an instance naming this window and a class naming
241
+ // the application — and everything since carries one string. The class is
242
+ // the application's, so the class is what a single-id backend is given; the
243
+ // pair itself still reaches `setClass` from the props (`applyWindowHints`).
244
+ const identity = attributes.appId ?? attributes.wmClass;
245
+ if (identity !== undefined) {
246
+ attributes.appId = Array.isArray(identity)
247
+ ? (identity[1] ?? identity[0])
248
+ : identity && typeof identity === 'object'
249
+ ? (identity.class ?? identity.instance)
250
+ : identity;
251
+ }
235
252
  if (props.style !== undefined) {
236
253
  const style = flattenStyle(props.style);
237
254
  if (style.backgroundColor !== undefined) {
@@ -325,8 +342,10 @@ export class WindowHints {
325
342
  this._sendSizeHints(next);
326
343
  }
327
344
  }
328
- if (!shallowEqual(next.wmClass, prev.wmClass) && next.wmClass) {
329
- const c = next.wmClass;
345
+ const identity = next.appId ?? next.wmClass;
346
+ const wasIdentity = prev.appId ?? prev.wmClass;
347
+ if (!shallowEqual(identity, wasIdentity) && identity) {
348
+ const c = identity;
330
349
  if (Array.isArray(c)) wnd.setClass?.(c[0], c[1]);
331
350
  else if (typeof c === 'object') wnd.setClass?.(c.instance, c.class);
332
351
  else wnd.setClass?.(c);
@@ -138,8 +138,8 @@ export function flushWindowRestacks() {
138
138
  * issue #4).
139
139
  */
140
140
  export class WindowNode extends Scrollable(Node) {
141
- constructor(app, attributes, props) {
142
- super('window', props, app, { yoga: true });
141
+ constructor(app, attributes, props, { awaitsRootScope = false } = {}) {
142
+ super('window', props, app, { yoga: true, awaitsRootScope });
143
143
  assertWindowSize(props, this.kind);
144
144
  this.root = this;
145
145
  this.attributes = attributes;
@@ -429,6 +429,19 @@ function centreFor(options) {
429
429
  return app?.notifications ?? null;
430
430
  }
431
431
 
432
+ /**
433
+ * Which rung a backend's own notification centre is.
434
+ *
435
+ * The seam is the backend's, not macOS's: a centre answers `available()` and
436
+ * `post()`, and the cocoa one was simply the first. `kind` is how a centre
437
+ * says which rung it is, so `notificationBackend()` reports the truth rather
438
+ * than the name of whoever got here first. Cocoa's predates the field and is
439
+ * the default.
440
+ */
441
+ function kindOf(centre) {
442
+ return centre?.kind ?? 'cocoa';
443
+ }
444
+
432
445
  /**
433
446
  * Which rung this machine lands on, without posting anything.
434
447
  *
@@ -442,10 +455,11 @@ function centreFor(options) {
442
455
  export async function notificationBackend(options = {}) {
443
456
  const backend = options.backend;
444
457
  const want = (rung) => !backend || backend === rung;
445
- if (want('cocoa')) {
446
- const centre = centreFor(options);
447
- if (centre && (await centre.available())) return 'cocoa';
448
- if (backend === 'cocoa') return null;
458
+ const centre = centreFor(options);
459
+ const kind = kindOf(centre);
460
+ if (want(kind)) {
461
+ if (centre && (await centre.available())) return kind;
462
+ if (backend === kind) return null;
449
463
  }
450
464
  if (want('dbus')) {
451
465
  const ref = await sessionBus();
@@ -461,7 +475,14 @@ export async function notificationBackend(options = {}) {
461
475
  if (want('osascript') && (process.platform === 'darwin' || backend)) {
462
476
  return 'osascript';
463
477
  }
464
- if (want('notify-send') && (process.platform !== 'darwin' || backend)) {
478
+ // `notify-send` is a freedesktop tool. Windows has neither it nor a shell
479
+ // command that shows a notification, so the rung above is the floor there
480
+ // and `null` is the honest answer when the tree is not on the win32
481
+ // backend — a spawn that would fail with ENOENT is not a rung.
482
+ if (
483
+ want('notify-send') &&
484
+ (backend || (process.platform !== 'darwin' && process.platform !== 'win32'))
485
+ ) {
465
486
  return 'notify-send';
466
487
  }
467
488
  return null;
@@ -497,26 +518,28 @@ export async function notify(options = {}) {
497
518
  const backend = options.backend;
498
519
  const want = (rung) => !backend || backend === rung;
499
520
 
500
- if (want('cocoa')) {
501
- const centre = centreFor(options);
521
+ const centre = centreFor(options);
522
+ const kind = kindOf(centre);
523
+ if (want(kind)) {
502
524
  if (centre && (await centre.available())) {
503
525
  const handle = await centre.post(options);
504
526
  if (handle) return handle;
505
527
  // The bundle is right and the centre is there, but the system never
506
528
  // put the prompt in front of anybody, so nobody declined: not the
507
529
  // refusal above, and no reason to stop. The ladder moves on.
508
- if (backend === 'cocoa') {
530
+ if (backend === kind) {
509
531
  throw new NoNotificationServiceError(
510
- "backend: 'cocoa' — the centre could not ask for authorization " +
532
+ `backend: '${kind}' — the centre could not ask for authorization ` +
511
533
  '(the status is still notDetermined). A bundle macOS has not ' +
512
534
  'registered never gets the prompt (docs/notifications.md).',
513
535
  );
514
536
  }
515
- } else if (backend === 'cocoa') {
537
+ } else if (backend === kind) {
516
538
  throw new NoNotificationServiceError(
517
- "backend: 'cocoa' — no notification centre here: the tree is not " +
518
- 'on the cocoa backend, the bridge is older than 0.5, or this ' +
519
- 'process is not an app bundle (docs/notifications.md).',
539
+ `backend: '${kind}' — no notification centre here: the tree is not ` +
540
+ 'on a backend that has one, the bridge is older than the version ' +
541
+ 'that added it, or on macOS this process is not an app bundle ' +
542
+ '(docs/notifications.md).',
520
543
  );
521
544
  }
522
545
  }
@@ -534,7 +557,9 @@ export async function notify(options = {}) {
534
557
  const shell =
535
558
  want('osascript') && (process.platform === 'darwin' || backend)
536
559
  ? 'osascript'
537
- : want('notify-send') && (process.platform !== 'darwin' || backend)
560
+ : want('notify-send') &&
561
+ (backend ||
562
+ (process.platform !== 'darwin' && process.platform !== 'win32'))
538
563
  ? 'notify-send'
539
564
  : null;
540
565
  if (shell) {
package/src/screens.js CHANGED
@@ -119,6 +119,11 @@ class ScreenSession {
119
119
  this._desktopAtom = null;
120
120
  this._snapshot = null;
121
121
  this._listeners = new Set();
122
+ /** A backend that has to be *asked* for its layout rather than told —
123
+ * see `setScreenPolling`. `_revalidate` re-reads it now; `_watched` is
124
+ * told whether anything is subscribed. */
125
+ this._revalidate = null;
126
+ this._watched = null;
122
127
  /** Every `X.on('event')` handler installed here, so `stop()` can take
123
128
  * them off again rather than leaving one per root on a shared client. */
124
129
  this._handlers = [];
@@ -138,7 +143,11 @@ class ScreenSession {
138
143
  }
139
144
  }
140
145
  this._handlers.length = 0;
146
+ const watched = this._listeners.size > 0;
141
147
  this._listeners.clear();
148
+ if (watched) this._watch(false);
149
+ this._revalidate = null;
150
+ this._watched = null;
142
151
  }
143
152
 
144
153
  /** Install an X event handler that this session owns. */
@@ -183,32 +192,35 @@ class ScreenSession {
183
192
 
184
193
  subscribe(fn) {
185
194
  this._listeners.add(fn);
186
- return () => this._listeners.delete(fn);
195
+ if (this._listeners.size === 1) this._watch(true);
196
+ return () => {
197
+ if (!this._listeners.delete(fn)) return;
198
+ if (!this._listeners.size) this._watch(false);
199
+ };
187
200
  }
188
- }
189
201
 
190
- /** The monitor a point is on, or the largest one when it is on none (a
191
- * window whose owner the WM has not placed yet, or coordinates from a
192
- * screen layout that has since changed). */
193
- function monitorAt(monitors, point) {
194
- if (!monitors?.length) return null;
195
- if (point) {
196
- for (const m of monitors) {
197
- if (
198
- point.x >= m.x &&
199
- point.x < m.x + m.width &&
200
- point.y >= m.y &&
201
- point.y < m.y + m.height
202
- ) {
203
- return m;
204
- }
202
+ /** Ask a pulled backend to re-read the layout, now. Synchronous: the
203
+ * callers are placement paths with no round trip available to them. */
204
+ revalidate() {
205
+ if (!this._revalidate || this.stopped) return;
206
+ try {
207
+ this._revalidate();
208
+ } catch {
209
+ // a backend that cannot answer leaves the layout it published
210
+ // standing, which is a better answer than none
205
211
  }
206
212
  }
207
- let best = monitors[0];
208
- for (const m of monitors) {
209
- if (m.width * m.height > best.width * best.height) best = m;
213
+
214
+ /** Whether anything is subscribed, for a backend that only has to keep
215
+ * asking while someone is listening. */
216
+ _watch(on) {
217
+ if (!this._watched) return;
218
+ try {
219
+ this._watched(on);
220
+ } catch {
221
+ // as above: its clock, its problem
222
+ }
210
223
  }
211
- return best;
212
224
  }
213
225
 
214
226
  /** The overlap of two rects, or `null` where they do not touch. */
@@ -221,6 +233,82 @@ function intersect(a, b) {
221
233
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
222
234
  }
223
235
 
236
+ /** How far apart two rects are, squared: zero where they meet, and the gap
237
+ * between their nearest edges otherwise. Squared because nothing compares
238
+ * it against a length — only against another of these. */
239
+ function gapSquared(a, b) {
240
+ const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
241
+ const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
242
+ return dx * dx + dy * dy;
243
+ }
244
+
245
+ /** The biggest monitor there is — the stand-in for "the one you look at",
246
+ * for a question with no position in it at all. */
247
+ function largestMonitor(monitors) {
248
+ let best = monitors[0];
249
+ for (const m of monitors) {
250
+ if (m.width * m.height > best.width * best.height) best = m;
251
+ }
252
+ return best;
253
+ }
254
+
255
+ /**
256
+ * The monitor `near` is on. `near` is a rect in screen coordinates, and a
257
+ * point is the 1x1 rect at it — the same containment a point used to get,
258
+ * since a 1x1 rect overlaps exactly the monitor that contains its corner.
259
+ *
260
+ * **The one it overlaps most**, because one corner of a rect does not say
261
+ * which monitor the rect is on. A menu-bar item's frame starts a few points
262
+ * *above* the top of its own display, and on a desk where another display
263
+ * reaches down past that edge, the corner alone is inside the *other*
264
+ * monitor — or inside none — and the popup opens there (#618). Every rect
265
+ * that has a size knows better than its corner does.
266
+ *
267
+ * **The nearest one**, by the gap between the rects, when it overlaps none.
268
+ * A rect that is off every monitor is nearly always just outside one of
269
+ * them — that same menu-bar furniture, a pointer at the very edge, a window
270
+ * the WM has not placed yet — and the nearest monitor is the only answer
271
+ * that has anything to do with where it was. The largest was the old answer
272
+ * and it can be anywhere on the desk.
273
+ *
274
+ * With no position at all (`near` null — an auto-sized window with no owner
275
+ * to open beside), the largest monitor, which is all there is to go on.
276
+ */
277
+ function monitorAt(monitors, near) {
278
+ if (!monitors?.length) return null;
279
+ if (!near) return largestMonitor(monitors);
280
+ // A degenerate rect counts as its own thinnest real version, the way
281
+ // `anchorOffscreen` reads a caret: a point is 1x1, and so is a rect whose
282
+ // size nobody filled in.
283
+ const rect = {
284
+ x: near.x,
285
+ y: near.y,
286
+ width: near.width > 1 ? near.width : 1,
287
+ height: near.height > 1 ? near.height : 1,
288
+ };
289
+ let best = null;
290
+ let most = 0;
291
+ for (const m of monitors) {
292
+ const over = intersect(m, rect);
293
+ const area = over ? over.width * over.height : 0;
294
+ if (area > most) {
295
+ best = m;
296
+ most = area;
297
+ }
298
+ }
299
+ if (best) return best;
300
+ let nearest = monitors[0];
301
+ let least = Infinity;
302
+ for (const m of monitors) {
303
+ const gap = gapSquared(m, rect);
304
+ if (gap < least) {
305
+ nearest = m;
306
+ least = gap;
307
+ }
308
+ }
309
+ return nearest;
310
+ }
311
+
224
312
  /**
225
313
  * The usable part of one monitor.
226
314
  *
@@ -262,12 +350,19 @@ function usable(monitor, work) {
262
350
 
263
351
  /**
264
352
  * The rect an auto-sized window may grow into, or `null` where there is
265
- * nothing to ask. `near` is a screen-coordinate point the window will open
266
- * next to — a `transientFor` owner's origin, in practice and picks the
267
- * monitor when there are several.
353
+ * nothing to ask. `near` is a screen-coordinate **rect** the window will
354
+ * open against — a `transientFor` owner's origin, the node a popup hangs
355
+ * off, the tray item a click reported — and picks the monitor when there
356
+ * are several (`monitorAt`); `{x, y}` alone is a point.
268
357
  */
269
358
  export function availableArea(app, near = null) {
270
359
  const session = sessions.get(app);
360
+ // The monitor a popup is flipped and clamped into is picked here, so a
361
+ // backend whose layout is pulled rather than pushed is asked *now*
362
+ // rather than answered from whatever it last read (`setScreenPolling`).
363
+ // A rect the desk has since moved lands inside another monitor's stale
364
+ // one, and the popup opens at that monitor's edge (#617).
365
+ session?.revalidate();
271
366
  const screen = session?.screenRect ?? null;
272
367
  if (!session) return screen;
273
368
  const monitor = monitorAt(session.monitors, near) ?? screen;
@@ -364,6 +459,46 @@ export function watchScreens(app, fn) {
364
459
  return session.subscribe(fn);
365
460
  }
366
461
 
462
+ /**
463
+ * Register a backend whose layout has to be **pulled**.
464
+ *
465
+ * X11 and Wayland are told: RandR sends an event, a `wl_output` announces
466
+ * itself, and a `publish` lands from the handler. The cocoa bridge keeps
467
+ * its `NSScreen` copy current on macOS's own
468
+ * `NSApplicationDidChangeScreenParametersNotification` but emits no event
469
+ * for it, so there a display plugged in, rearranged or made primary is a
470
+ * question nobody asked (#617). This is where the asking is wired up:
471
+ *
472
+ * - `revalidate()` re-reads the layout and publishes any change. Called
473
+ * before `availableArea()` picks the monitor a window is sized against
474
+ * or a popup is clamped into, which is where a stale rect does visible
475
+ * damage, and synchronous for that reason.
476
+ * - `watched(on)` is told when the *first* subscriber arrives and when the
477
+ * last one leaves. A change nobody asked about still has to reach
478
+ * `useScreens()`, which needs a clock where there is no event — and a
479
+ * clock that only runs while a component is watching costs an app that
480
+ * never asks nothing at all.
481
+ *
482
+ * Both are optional, and a session with neither behaves exactly as it did:
483
+ * this adds no work to the X11 path.
484
+ */
485
+ export function setScreenPolling(
486
+ app,
487
+ { revalidate = null, watched = null } = {},
488
+ ) {
489
+ let session = sessions.get(app);
490
+ if (!session) {
491
+ session = new ScreenSession(app);
492
+ sessions.set(app, session);
493
+ }
494
+ session._revalidate = revalidate;
495
+ session._watched = watched;
496
+ // Registered after a `useScreens()` already mounted — the backend still
497
+ // has to hear that it is being watched.
498
+ if (watched && session._listeners.size) session._watch(true);
499
+ return session;
500
+ }
501
+
367
502
  // --------------------------------------------------------------------------
368
503
  // Starting up
369
504
  // --------------------------------------------------------------------------
@@ -0,0 +1,164 @@
1
+ // The Windows taskbar's own surfaces, as hooks.
2
+ //
3
+ // Three things the taskbar has that no other desktop does, so none of them is
4
+ // a rung on an existing ladder and none of them pretends to be portable. What
5
+ // makes them safe to use anyway is that the answer to "is this here" is a
6
+ // value a component branches on rather than a platform check, and the hooks
7
+ // below do nothing at all where the backend has not installed them. An app
8
+ // writes the same tree everywhere and gets the feature where it exists.
9
+ //
10
+ // ```jsx
11
+ // const launcher = useDesktopCapability('launcher');
12
+ // useThumbnailToolbar(
13
+ // launcher.features.thumbnailToolbar ? [
14
+ // { id: 'prev', tooltip: 'Previous', icon: prevIcon },
15
+ // { id: 'play', tooltip: playing ? 'Pause' : 'Play', icon: playIcon },
16
+ // ] : null,
17
+ // (id) => transport(id),
18
+ // );
19
+ // ```
20
+ //
21
+ // They are **features of the launcher** — the same rung as the badge and the
22
+ // progress bar, since all of them hang off the one icon the desktop shows for
23
+ // this app — and not entries in `useSupports()`, which answers for the
24
+ // display. The seam is one rule: the backend installs a method, and its
25
+ // presence is the capability; the probe in `src/capabilities.js` reads those
26
+ // same methods, so the prediction and the hook cannot disagree. Nothing here
27
+ // knows what Windows is, and neither does anything in an app that uses it.
28
+ import { useEffect, useRef } from 'react';
29
+
30
+ import { useAppOrNull } from './appcontext.js';
31
+ import { liveApps } from './trace-registry.js';
32
+ import { useTopLevelWindow } from './windowid.js';
33
+
34
+ /** The app to act on when the caller did not say — the same rule the
35
+ * launcher's own imperative calls use. */
36
+ function soleApp() {
37
+ const apps = liveApps();
38
+ if (apps.length <= 1) return apps[0] ?? null;
39
+ const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
40
+ return showing.length === 1 ? showing[0] : null;
41
+ }
42
+
43
+ /** The value a caller passed, read by an effect that must not re-run for it. */
44
+ function useLatest(value) {
45
+ const ref = useRef(value);
46
+ ref.current = value;
47
+ return ref;
48
+ }
49
+
50
+ /** A stable key for a button list, so an effect re-runs when one actually
51
+ * changed rather than on every render. Icons compare by identity — a caller
52
+ * holding its icons still is the fast path, and one that rebuilds them every
53
+ * render was going to re-upload them anyway. */
54
+ function signatureOf(buttons) {
55
+ if (!buttons) return '';
56
+ return buttons
57
+ .map(
58
+ (b) =>
59
+ `${b.id ?? ''}\u0000${b.tooltip ?? b.label ?? ''}\u0000` +
60
+ `${b.enabled === false ? 0 : 1}${b.dismissOnClick ? 1 : 0}`,
61
+ )
62
+ .join('\u0001');
63
+ }
64
+
65
+ /**
66
+ * Up to seven buttons under this window's taskbar hover preview — where a
67
+ * media player puts play and skip.
68
+ *
69
+ * `buttons` is `[{ id, tooltip, icon, enabled, dismissOnClick }]`, and `icon`
70
+ * is whatever `useTray` takes: an ntk `Image`, raw RGBA with a size, or a
71
+ * path. `null` takes the toolbar down as far as the shell allows, which is to
72
+ * hide the buttons — a toolbar cannot be removed once the window has one, and
73
+ * saying so is better than a call that looks like it worked.
74
+ *
75
+ * `onClick` is called with the button's own `id`.
76
+ *
77
+ * Does nothing where the backend has no toolbar. The eighth button and beyond
78
+ * are dropped rather than refused, because the shell refuses the whole call
79
+ * for an eighth and one silently missing button is a better outcome than a
80
+ * toolbar that never appears.
81
+ */
82
+ export function useThumbnailToolbar(buttons, onClick) {
83
+ const app = useAppOrNull();
84
+ const owner = useTopLevelWindow();
85
+ const handler = useLatest(onClick);
86
+ const latest = useLatest(buttons);
87
+ const signature = signatureOf(buttons);
88
+
89
+ useEffect(() => {
90
+ if (typeof app?.thumbnailToolbar !== 'function') return;
91
+ // Read inside the effect: `useTopLevelWindow` answers through a getter,
92
+ // so the window a tree is in is whatever it is when the effect runs
93
+ // rather than what it was at render.
94
+ const wnd = owner?.current?.window;
95
+ if (!wnd?.id) return;
96
+ app.thumbnailToolbar(wnd.id, latest.current ?? []);
97
+
98
+ const fire = (event) => handler.current?.(event.id, event);
99
+ wnd.on?.('thumbbutton', fire);
100
+ return () => {
101
+ wnd.off?.('thumbbutton', fire);
102
+ // Hidden rather than removed, which is all the shell offers.
103
+ app.thumbnailToolbar(wnd.id, []);
104
+ };
105
+ // `signature` is the dependency; the buttons themselves are read through
106
+ // a ref, so a new array holding the same buttons does not re-send them.
107
+ }, [app, owner, signature, latest, handler]);
108
+ }
109
+
110
+ /**
111
+ * The Tasks category of this application's jump list — the menu on a right
112
+ * click of its taskbar button.
113
+ *
114
+ * `tasks` is `[{ title, arguments, description }]`. Each one **relaunches
115
+ * this executable** with the arguments given, which is what a jump-list task
116
+ * is: the shell starts the program, it does not call back into the running
117
+ * one. An app that wants the running instance to answer needs the single
118
+ * instance path, which is not built (docs/windows-integrations.md) — so until
119
+ * then a task is for something the app can do from a cold start.
120
+ *
121
+ * `null` deletes the category.
122
+ */
123
+ export function useJumpList(tasks) {
124
+ const app = useAppOrNull();
125
+ const latest = useLatest(tasks);
126
+ // The tasks are plain data — a title, arguments, a description — so their
127
+ // own JSON is the dependency, and a caller rebuilding an equal array every
128
+ // render does not re-send it. Read through a ref for the same reason
129
+ // `useThumbnailToolbar` does: the value is what it is when the effect runs.
130
+ const signature = JSON.stringify(tasks ?? null);
131
+
132
+ useEffect(() => {
133
+ if (typeof app?.jumpList !== 'function') return;
134
+ app.jumpList(latest.current ?? []);
135
+ return () => app.jumpList([]);
136
+ }, [app, signature, latest]);
137
+ }
138
+
139
+ /**
140
+ * Note a document this app just opened, for the shell's Recent lists — the
141
+ * jump list's own Recent section, and Explorer's quick access.
142
+ *
143
+ * The same act as macOS's `noteNewRecentDocumentURL:`; it lives here rather
144
+ * than in a cross-platform hook because this is the only backend with one to
145
+ * call today. Where the file type is not associated with this application the
146
+ * shell may keep it and show it nowhere, which is its decision to make.
147
+ *
148
+ * `null` clears the list.
149
+ */
150
+ export function noteRecentDocument(path, { app } = {}) {
151
+ const target = app ?? soleApp();
152
+ if (typeof target?.noteRecentDocument !== 'function') return false;
153
+ target.noteRecentDocument(path);
154
+ return true;
155
+ }
156
+
157
+ /** The hook form: notes `path` whenever it changes. */
158
+ export function useRecentDocument(path) {
159
+ const app = useAppOrNull();
160
+ useEffect(() => {
161
+ if (typeof app?.noteRecentDocument !== 'function' || path == null) return;
162
+ app.noteRecentDocument(path);
163
+ }, [app, path]);
164
+ }
package/src/transfer.js CHANGED
@@ -70,6 +70,25 @@ export function decodeData(data, target) {
70
70
  * URIs that are actually local — a remote `file://host/...` has no local
71
71
  * path and must not pretend to.
72
72
  */
73
+ /**
74
+ * A `file:` URL's pathname as a path this machine can open.
75
+ *
76
+ * On a POSIX path the two are the same string. A Windows one is not: the URL
77
+ * form of `C:\Users\a` is `file:///C:/Users/a`, whose pathname is
78
+ * `/C:/Users/a` — a leading slash that no Windows API accepts, and forward
79
+ * slashes that most of them tolerate and no user recognises. A drive letter
80
+ * after the slash is the tell, and it is unambiguous: no POSIX path begins
81
+ * `/C:`.
82
+ *
83
+ * Decided by the shape of the path rather than by `process.platform`, so a
84
+ * uri-list that arrived from another machine reads the same way on both.
85
+ */
86
+ function pathOfFileUrl(pathname) {
87
+ const drive = /^\/([A-Za-z]:)(\/.*)?$/.exec(pathname);
88
+ if (!drive) return pathname;
89
+ return (drive[1] + (drive[2] ?? '\\')).replace(/\//g, '\\');
90
+ }
91
+
73
92
  export function parseUriList(text) {
74
93
  const files = [];
75
94
  for (const line of String(text).split(/\r?\n/)) {
@@ -82,7 +101,7 @@ export function parseUriList(text) {
82
101
  url.protocol === 'file:' &&
83
102
  (url.hostname === '' || url.hostname === 'localhost')
84
103
  ) {
85
- entry.path = decodeURIComponent(url.pathname);
104
+ entry.path = pathOfFileUrl(decodeURIComponent(url.pathname));
86
105
  }
87
106
  } catch {
88
107
  // not a parseable URI — keep the raw line, claim no path
package/src/trayhooks.js CHANGED
@@ -73,7 +73,7 @@ import { StatusNotifierItem, allocateItemSlot } from './statusnotifier.js';
73
73
  * ```
74
74
  *
75
75
  * With `menu`, a click opens it — the same `items` vocabulary `MenuBar` and
76
- * `useDockMenu` take, an item's `onSelect` firing when picked. Without one,
76
+ * `useLauncherMenu` take, an item's `onSelect` firing when picked. Without one,
77
77
  * `onClick` is called with the button and where the click was, in logical
78
78
  * screen pixels — with the item's rect, where the backend knows it. `null`
79
79
  * means no item. Every field follows its value while mounted; the item is
@@ -10,7 +10,13 @@ export type DesktopBackend =
10
10
  | 'osascript'
11
11
  | 'notify-send'
12
12
  | 'statusnotifier'
13
- | 'launcherentry';
13
+ | 'launcherentry'
14
+ /** A Shell_NotifyIcon balloon. */
15
+ | 'win32'
16
+ /** The notify icon itself, as a tray. */
17
+ | 'shellnotifyicon'
18
+ /** `ITaskbarList3` and the jump list, as a launcher. */
19
+ | 'taskbar';
14
20
 
15
21
  /** The capabilities {@link desktopCapability} can be asked about. */
16
22
  export type DesktopCapabilityName = 'notifications' | 'tray' | 'launcher';
@@ -60,14 +66,37 @@ export interface TrayFeatures {
60
66
 
61
67
  export interface LauncherFeatures {
62
68
  badge: boolean;
63
- /** A string badge. macOS only — the launcher protocol carries a count. */
69
+ /**
70
+ * A string badge rather than a count. False on the launcher protocol,
71
+ * which carries a number; the taskbar draws the text into its overlay
72
+ * icon, where about three glyphs fit before it becomes `99+`.
73
+ */
64
74
  badgeText: boolean;
65
75
  progress: boolean;
66
76
  urgent: boolean;
67
- /** The Dock menu / quicklist. */
77
+ /**
78
+ * The Dock menu / quicklist: entries that **call back into this process**.
79
+ * False on the taskbar, whose menu is the jump list — see `tasks`.
80
+ */
68
81
  menu: boolean;
69
82
  /** The launcher needs an installed `.desktop` file to attach this to. */
70
83
  needsDesktopFile: boolean;
84
+ /**
85
+ * Static entries on the icon's menu that start a **new process** with
86
+ * arguments, rather than calling back into this one: the jump list's Tasks
87
+ * category. A different feature from `menu`, and `useJumpList` drives it.
88
+ */
89
+ tasks: boolean;
90
+ /**
91
+ * Buttons under the icon's hover preview, which `useThumbnailToolbar`
92
+ * drives. The taskbar's alone.
93
+ */
94
+ thumbnailToolbar: boolean;
95
+ /**
96
+ * The launcher keeps a Recent list this app can add to, through
97
+ * `useRecentDocument`.
98
+ */
99
+ recentDocuments: boolean;
71
100
  }
72
101
 
73
102
  export interface DesktopCapabilityResult<F = Record<string, boolean>> {