react-x11 2.16.1 → 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 (54) 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/appcontext.js +8 -0
  6. package/src/appearance.js +36 -0
  7. package/src/{cocoa → backend}/context2d.js +27 -7
  8. package/src/capabilities.js +99 -1
  9. package/src/cocoa/app.js +8 -4
  10. package/src/cocoa/fonts.js +1 -1
  11. package/src/cocoa/overlay.js +2 -2
  12. package/src/cocoa/panewindow.js +2 -2
  13. package/src/cocoa/presenter.js +2 -2
  14. package/src/cocoa/surface.js +3 -3
  15. package/src/cocoa/window.js +2 -2
  16. package/src/events.js +21 -0
  17. package/src/foreignnodes.js +8 -3
  18. package/src/frame/index.js +30 -4
  19. package/src/glnodes.js +12 -1
  20. package/src/idle.js +59 -1
  21. package/src/index.d.ts +41 -0
  22. package/src/index.js +30 -3
  23. package/src/launcher.js +17 -8
  24. package/src/launcherhooks.js +24 -10
  25. package/src/node.d.ts +1 -1
  26. package/src/nodes/cascade.js +9 -0
  27. package/src/nodes/node.js +6 -1
  28. package/src/nodes/window/hints.js +21 -2
  29. package/src/nodes/window/window.js +2 -2
  30. package/src/notifications.js +39 -14
  31. package/src/taskbarhooks.js +164 -0
  32. package/src/transfer.js +20 -1
  33. package/src/trayhooks.js +1 -1
  34. package/src/types/capabilities.d.ts +32 -3
  35. package/src/types/elements.d.ts +23 -1
  36. package/src/types/events.d.ts +16 -0
  37. package/src/types/launcher.d.ts +20 -6
  38. package/src/types/taskbar.d.ts +79 -0
  39. package/src/wayland/context2d.js +1 -1
  40. package/src/win32/a11y.js +604 -0
  41. package/src/win32/app.js +768 -0
  42. package/src/win32/bezels.js +158 -0
  43. package/src/win32/dnd.js +283 -0
  44. package/src/win32/fonts.js +497 -0
  45. package/src/win32/glarea.js +548 -0
  46. package/src/win32/ime.js +267 -0
  47. package/src/win32/keymap.js +116 -0
  48. package/src/win32/native.js +54 -0
  49. package/src/win32/panehost.js +106 -0
  50. package/src/win32/panewindow.js +343 -0
  51. package/src/win32/shell.js +426 -0
  52. package/src/win32/surface.js +192 -0
  53. package/src/win32/window.js +659 -0
  54. package/src/windowid.js +66 -0
@@ -14,7 +14,7 @@
14
14
  // via emit. Geometry and input arrive as channel messages (the host owns
15
15
  // layout and hit-testing — this is CPU offloading, not isolation), and the
16
16
  // only outbound traffic is pane-present.
17
- import { CocoaContext2D } from './context2d.js';
17
+ import { BackendContext2D } from '../backend/context2d.js';
18
18
 
19
19
  let nextPaneId = 1;
20
20
 
@@ -183,7 +183,7 @@ export class CocoaPaneWindow {
183
183
 
184
184
  getContext() {
185
185
  if (!this._ctx) {
186
- this._ctx = new CocoaContext2D(
186
+ this._ctx = new BackendContext2D(
187
187
  this._native,
188
188
  () => this._ensureSurface(),
189
189
  () => {
@@ -30,7 +30,7 @@ import { Node } from '../nodes/node.js';
30
30
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
31
31
  import { intersectRects } from '../nodes/rects.js';
32
32
  import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
33
- import { CocoaContext2D } from './context2d.js';
33
+ import { BackendContext2D } from '../backend/context2d.js';
34
34
 
35
35
  export const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
36
36
 
@@ -431,7 +431,7 @@ export class RasterState {
431
431
  this.height = height;
432
432
  this.gen++;
433
433
  if (!this.ctx) {
434
- this.ctx = new CocoaContext2D(
434
+ this.ctx = new BackendContext2D(
435
435
  presenter.native,
436
436
  () => this.surface,
437
437
  () => this.gen,
@@ -6,7 +6,7 @@
6
6
  // buffer the same way on both backends and names neither:
7
7
  //
8
8
  // const surface = new Surface(app, { width, height }); // device pixels
9
- // const ctx = surface.getContext('2d'); // a CocoaContext2D
9
+ // const ctx = surface.getContext('2d'); // a BackendContext2D
10
10
  // ctx.fillRect(0, 0, width, height);
11
11
  // surface.copyWithin({ x: 0, y: 0, width, height }, 0, -rowHeight);
12
12
  // windowCtx.drawImage(surface, x, y); // one composite
@@ -43,7 +43,7 @@
43
43
  // one from `contentBox()` numbers, which are device pixels already
44
44
  // (docs/scale.md). The bridge is told the app's scale so the bitmap carries
45
45
  // it — inert for a `drawImage` source, right for a layer's contents.
46
- import { CocoaContext2D } from './context2d.js';
46
+ import { BackendContext2D } from '../backend/context2d.js';
47
47
 
48
48
  export class CocoaSurface {
49
49
  constructor(app, { width, height, format = 'argb32' } = {}) {
@@ -113,7 +113,7 @@ export class CocoaSurface {
113
113
 
114
114
  _context() {
115
115
  if (!this._ctx) {
116
- this._ctx = new CocoaContext2D(
116
+ this._ctx = new BackendContext2D(
117
117
  this._native,
118
118
  () => this._handle(),
119
119
  () => 1,
@@ -6,7 +6,7 @@
6
6
  // an X window — attributes, reported width/height, event coordinates,
7
7
  // _screenOrigin. The divide-by-scale into Cocoa points happens against the
8
8
  // native layer and nowhere above it.
9
- import { CocoaContext2D } from './context2d.js';
9
+ import { BackendContext2D } from '../backend/context2d.js';
10
10
  import { CocoaDropTransport, dragSpec } from './dnd.js';
11
11
  import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
@@ -794,7 +794,7 @@ export class CocoaWindow {
794
794
 
795
795
  getContext() {
796
796
  if (!this._ctx) {
797
- this._ctx = new CocoaContext2D(
797
+ this._ctx = new BackendContext2D(
798
798
  this._native,
799
799
  () => this._ensureSurface(),
800
800
  () => {
package/src/events.js CHANGED
@@ -113,6 +113,27 @@ class SyntheticEvent {
113
113
  this.ctrlKey = Boolean(native?.buttons & MOD.Control);
114
114
  this.altKey = Boolean(native?.buttons & MOD.Alt);
115
115
  this.metaKey = Boolean(native?.buttons & MOD.Super);
116
+ // Where the pointer is on the **virtual screen**, in the same logical
117
+ // pixels `x`/`y` are in — the DOM's name for the DOM's quantity, and the
118
+ // one an app should reach for when it places something outside the
119
+ // window (a context menu at the pointer, a drag preview).
120
+ //
121
+ // `nativeEvent.rootx`/`rooty` is X11's name for it, is still there, and
122
+ // is still in *device* pixels: the same split as `ev.x` against
123
+ // `nativeEvent.x`. Defined exactly where the backend reported a position
124
+ // — an X11 KeyPress carries one too, so this is not pointer-events-only —
125
+ // and absent otherwise, because a made-up 0 would read as the screen's
126
+ // top-left corner rather than as "no answer".
127
+ if (native?.rootx !== undefined && native?.rootx !== null) {
128
+ // The **window's** scale, not the target's. `x`/`y` are in the target's
129
+ // unit on purpose, so a subtree zoomed by a `scale` prop reads its own
130
+ // — but a screen coordinate is not in that subtree's space at all, and
131
+ // dividing it by a zoom factor would put it somewhere nobody is. The
132
+ // drag events have always computed it this way (src/dnd.js).
133
+ const screen = manager.scale;
134
+ this.screenX = native.rootx / screen;
135
+ this.screenY = (native.rooty ?? 0) / screen;
136
+ }
116
137
  this.defaultPrevented = false;
117
138
  this.propagationStopped = false;
118
139
  if (extra) Object.assign(this, extra);
@@ -192,10 +192,15 @@ export class ForeignNode extends Node {
192
192
  */
193
193
  _refuse() {
194
194
  this._refused = true;
195
+ // Named by the capability, not by the backend that happens to have it.
196
+ // An app cannot act on "use X11", and a second backend growing embedding
197
+ // would make that wording wrong as well as unhelpful; what an app *can*
198
+ // act on is the question the last sentence names (AGENTS.md,
199
+ // "Vocabulary").
195
200
  const err = new Error(
196
- 'react-x11: <foreign> needs the X11 backend this one has no ' +
197
- 'cross-process window embedding, so nothing can be put in it. Ask ' +
198
- "useSupports('embedding') before rendering one.",
201
+ 'react-x11: <foreign> needs a backend with cross-process window ' +
202
+ 'embedding, and this one has none — so nothing can be put in it. ' +
203
+ "Ask useSupports('embedding') before rendering one.",
199
204
  );
200
205
  this.error = err;
201
206
  // The client is the whole reason this node is a Tab stop by default, and
@@ -44,6 +44,7 @@ import React, {
44
44
  } from 'react';
45
45
 
46
46
  import { useAppOrNull } from '../appcontext.js';
47
+ import { canEmbed } from '../embedding.js';
47
48
  import { FrameEnv } from './env.js';
48
49
  import { CallbackTable, PROTOCOL } from './protocol.js';
49
50
 
@@ -221,11 +222,14 @@ export function Frame({
221
222
  ref,
222
223
  }) {
223
224
  const env = useContext(FrameEnv);
224
- // A backend that composites panes from shared memory (Cocoa) declares
225
- // itself with createPaneHost; everything else embeds the pane's real
226
- // window through <foreign>, exactly as before.
225
+ // A backend that composites panes from a shared buffer (Cocoa, Windows)
226
+ // declares itself with createPaneHost; everything else embeds the pane's
227
+ // real window through <foreign>, exactly as before.
227
228
  const appOrNull = useAppOrNull();
228
229
  const paneApp = appOrNull?.createPaneHost ? appOrNull : null;
230
+ // Two mechanisms, one question: is there any way to *show* a pane here?
231
+ // Asked before the fork rather than after it — see the session effect.
232
+ const canShowPane = Boolean(paneApp) || canEmbed(appOrNull);
229
233
  const [state, setState] = useState({
230
234
  phase: 'starting',
231
235
  windowId: null,
@@ -262,6 +266,28 @@ export function Frame({
262
266
  setState({ phase: 'failed', windowId: null, error });
263
267
  };
264
268
 
269
+ // Before the fork, not after it. A backend with neither mechanism used
270
+ // to start the pane, let it load its module and mount, and only then
271
+ // discover at the embed that there was nowhere to put it — a whole
272
+ // process spawned and killed to reach a conclusion the app object had
273
+ // all along. The fallback it renders is the same one either way; what
274
+ // changes is that it renders at once and costs nothing.
275
+ if (!canShowPane) {
276
+ fail(
277
+ Object.assign(
278
+ new Error(
279
+ 'react-x11: <Frame> needs a backend that can show a pane — one ' +
280
+ 'that composites panes from a shared buffer, or one with ' +
281
+ 'cross-process window embedding — and this one has neither, ' +
282
+ 'so no pane was started. Ask ' +
283
+ "useSupports('embedding') before rendering one.",
284
+ ),
285
+ { phase: 'embed' },
286
+ ),
287
+ );
288
+ return undefined;
289
+ }
290
+
265
291
  let t;
266
292
  try {
267
293
  t = makeTransport({ src: source, display });
@@ -396,7 +422,7 @@ export function Frame({
396
422
  // ask, and everything is unref'd so nothing holds the host open
397
423
  if (session.current === s) session.current = null;
398
424
  };
399
- }, [source, display, generation, makeTransport]);
425
+ }, [source, display, generation, makeTransport, canShowPane]);
400
426
 
401
427
  // One update per commit that changed the pane's inputs, props and env in
402
428
  // the same message — so a theme flip and the state change that caused it
package/src/glnodes.js CHANGED
@@ -58,9 +58,20 @@ export function glxConfig(app, spec) {
58
58
  .chooseGLXConfig(spec)
59
59
  .then((config) => ({ backend: 'indirect', ...config }));
60
60
  } else {
61
+ // Which backend is asking decides what the honest answer is. Telling a
62
+ // Windows user to upgrade ntk sends them after a package that backend
63
+ // does not use, and an error a developer cannot act on is worse than
64
+ // the feature simply being absent (AGENTS.md, "An error you hit is an
65
+ // error an app developer will hit").
61
66
  promise = Promise.reject(
62
67
  new Error(
63
- 'react-x11: <glarea> needs ntk >= 3.6.0 (app.chooseGLConfig)',
68
+ process.platform === 'win32'
69
+ ? 'react-x11: <glarea> is not built on the win32 backend yet — it ' +
70
+ 'needs ANGLE (EGL and GLES over Direct3D 11), which ' +
71
+ 'docs/windows.md plans as an optional dependency the way ' +
72
+ 'x11-dri is on X11. Everything else on this backend works ' +
73
+ 'without it; for GL content today, use the X11 backend.'
74
+ : 'react-x11: <glarea> needs ntk >= 3.6.0 (app.chooseGLConfig)',
64
75
  ),
65
76
  );
66
77
  }
package/src/idle.js CHANGED
@@ -186,6 +186,17 @@ class IdleWatcher {
186
186
  */
187
187
  async function armIdle(watcher) {
188
188
  const session = watcher.session;
189
+
190
+ // The Windows rung, first because it is the only one there: everything
191
+ // below this reaches into `app.X`, and a tree that is not on an X server
192
+ // has none. `GetLastInputInfo` answers for the whole session, which is what
193
+ // an idle timeout means, so the shape is the polling rung's — with the
194
+ // answer arriving synchronously instead of over a connection.
195
+ if (typeof session.app?.lastInputMs === 'function') {
196
+ pollLastInput(watcher);
197
+ return;
198
+ }
199
+
189
200
  const counter = await session.counter();
190
201
  if (watcher.stopped) return;
191
202
 
@@ -281,6 +292,31 @@ function poll(watcher, saver) {
281
292
  });
282
293
  }
283
294
 
295
+ /**
296
+ * The same adaptive wait as {@link poll}, over a counter this process can read
297
+ * without asking anybody: `GetLastInputInfo` is a call, not a round trip.
298
+ *
299
+ * The two directions are still asymmetric for the same reason. Not idle yet:
300
+ * sleep for exactly the remainder. Idle: nothing says when the user will come
301
+ * back, and no input reaches this process while they are typing in another
302
+ * window, so poll on an interval scaled to the timeout.
303
+ */
304
+ function pollLastInput(watcher) {
305
+ if (watcher.stopped) return;
306
+ const elapsed = watcher.session.app.lastInputMs?.();
307
+ if (typeof elapsed !== 'number') return;
308
+ const idle = elapsed >= watcher.timeout;
309
+ watcher.set(idle);
310
+ clearTimeout(watcher._timer);
311
+ watcher._timer = setTimeout(
312
+ () => pollLastInput(watcher),
313
+ idle
314
+ ? Math.min(30_000, Math.max(1_000, watcher.timeout / 4))
315
+ : Math.max(250, watcher.timeout - elapsed),
316
+ );
317
+ watcher._timer.unref?.();
318
+ }
319
+
284
320
  function schedule(watcher, saver, delay) {
285
321
  clearTimeout(watcher._timer);
286
322
  watcher._timer = setTimeout(() => poll(watcher, saver), delay);
@@ -364,7 +400,12 @@ export function setIdleForTests(app, timeout, idle) {
364
400
  * code and for tests.
365
401
  */
366
402
  export async function keepAwake({ reason = 'Busy', app = null } = {}) {
367
- for (const rung of [portalInhibit, screenSaverInhibit, xInhibit]) {
403
+ for (const rung of [
404
+ windowsInhibit,
405
+ portalInhibit,
406
+ screenSaverInhibit,
407
+ xInhibit,
408
+ ]) {
368
409
  try {
369
410
  const release = await rung(reason, app);
370
411
  if (release) return once(release);
@@ -375,6 +416,23 @@ export async function keepAwake({ reason = 'Busy', app = null } = {}) {
375
416
  return () => {};
376
417
  }
377
418
 
419
+ /**
420
+ * Rung 0: `SetThreadExecutionState`, on Windows.
421
+ *
422
+ * Above the portal rungs because it is the only one a Windows session has,
423
+ * and below nothing: on a desktop with a portal this returns null on the
424
+ * first line and costs a property read.
425
+ *
426
+ * `reason` is dropped rather than passed. Windows takes no string with the
427
+ * call — `powercfg /requests` names the process, not a reason — and inventing
428
+ * somewhere to put it would be pretending the system shows it.
429
+ */
430
+ async function windowsInhibit(reason, app) {
431
+ const hold = app?.keepAwake;
432
+ if (typeof hold !== 'function') return null;
433
+ return hold.call(app, true);
434
+ }
435
+
378
436
  /** A release that runs once however many times it is called — a double
379
437
  * release would drop somebody else's inhibition on the counted X rung. */
380
438
  function once(fn) {
package/src/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export * from './types/fonts.js';
27
27
  export * from './types/system.js';
28
28
  export * from './types/capabilities.js';
29
29
  export * from './types/launcher.js';
30
+ export * from './types/taskbar.js';
30
31
  export * from './types/tray.js';
31
32
  export * from './types/permissions.js';
32
33
  export * from './types/notifications.js';
@@ -62,6 +63,46 @@ export function useWindowId(
62
63
  ref: RefObject<NtkWindow | DrawnNode | null>,
63
64
  ): () => number | null;
64
65
 
66
+ /**
67
+ * The handle **another process** embeds to show this window, or `null` where
68
+ * this backend cannot hand one out.
69
+ *
70
+ * The companion to `<window embeddable>`, and deliberately not
71
+ * {@link windowIdOf}: on X11 the two are the same number, and everywhere
72
+ * else they are not.
73
+ *
74
+ * - **X11** — the window's XID, which means the same thing in every process
75
+ * on the display.
76
+ * - **Windows** — a composition surface handle, already valid in the host
77
+ * process. A window cannot be embedded here (a composition target stops
78
+ * presenting once its window is a child), so the *buffer* crosses instead
79
+ * and the host binds it to a visual of its own. The host is the parent
80
+ * process unless `createRoot({ win32: { paneHostPid } })` says otherwise.
81
+ * - **Anything else** — `null`, which is the capability rather than a
82
+ * failure.
83
+ *
84
+ * Pass it to the host out of band, as an XID is passed: argv, an environment
85
+ * variable, a message. What the host does with it differs per platform; what
86
+ * an app writes to get it does not.
87
+ */
88
+ export function windowHandleOf(
89
+ target:
90
+ | NtkWindow
91
+ | DrawnNode
92
+ | RefObject<NtkWindow | DrawnNode | null>
93
+ | null
94
+ | undefined,
95
+ ): number | null;
96
+
97
+ /**
98
+ * `windowHandleOf` bound to a ref. A **getter**, stable across renders, for
99
+ * {@link useWindowId}'s reason: the window is not realized on the render
100
+ * that declares it.
101
+ */
102
+ export function useWindowHandle(
103
+ ref: RefObject<NtkWindow | DrawnNode | null>,
104
+ ): () => number | null;
105
+
65
106
  /**
66
107
  * Parse a `text/uri-list` payload (RFC 2483): CRLF-separated,
67
108
  * percent-encoded, `#` lines are comments. What `DropEvent.files` is made
package/src/index.js CHANGED
@@ -4,7 +4,13 @@ import './bootstrap.js';
4
4
 
5
5
  export { createRoot, Renderer } from './Reconciler.js';
6
6
  export { createStyles, flattenStyle } from './styles.js';
7
- export { windowIdOf, useWindowId, useTopLevelWindow } from './windowid.js';
7
+ export {
8
+ windowIdOf,
9
+ useWindowId,
10
+ windowHandleOf,
11
+ useWindowHandle,
12
+ useTopLevelWindow,
13
+ } from './windowid.js';
8
14
  export { launchTimestamp, notifyStartupComplete } from './startup.js';
9
15
  export { activateWindow } from './activate.js';
10
16
  export { lastInputTime, serverTime } from './inputtime.js';
@@ -14,9 +20,30 @@ export {
14
20
  registerApplication,
15
21
  } from './application.js';
16
22
  export { useAppActivate, useAppOpen } from './apphooks.js';
17
- export { setBadge, setProgress, setQuicklist, setUrgent } from './launcher.js';
18
- export { useBadge, useDockMenu, useProgress } from './launcherhooks.js';
23
+ export {
24
+ setBadge,
25
+ setLauncherMenu,
26
+ setProgress,
27
+ setQuicklist,
28
+ setUrgent,
29
+ } from './launcher.js';
30
+ export {
31
+ useBadge,
32
+ useDockMenu,
33
+ useLauncherMenu,
34
+ useProgress,
35
+ } from './launcherhooks.js';
19
36
  export { useTray } from './trayhooks.js';
37
+ // The Windows taskbar's own surfaces. Exported unconditionally and inert
38
+ // where the backend has none — `useDesktopCapability('launcher').features`
39
+ // carries `tasks`, `thumbnailToolbar` and `recentDocuments`, so an app
40
+ // branches on what this desktop has rather than on the platform.
41
+ export {
42
+ noteRecentDocument,
43
+ useJumpList,
44
+ useRecentDocument,
45
+ useThumbnailToolbar,
46
+ } from './taskbarhooks.js';
20
47
  export {
21
48
  CAPABILITIES,
22
49
  NO_CAPABILITY,
package/src/launcher.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // desktops, two mechanisms, one call each:
7
7
  //
8
8
  // 1. **the app's own tile** — the cocoa backend's `NSDockTile`, reached
9
- // through the app object (`setDockBadge`, `setDockMenu`, src/cocoa/
9
+ // through the app object (`setDockBadge`, `setLauncherMenu`, src/cocoa/
10
10
  // app.js).
11
11
  // 2. **`com.canonical.Unity.LauncherEntry`** over the session bus — the
12
12
  // protocol Unity defined and the KDE, elementary, Cairo-Dock and
@@ -34,13 +34,13 @@
34
34
  // is runtime, it follows state, and Dash-to-Dock/ubuntu-dock, Plank and the
35
35
  // Unity heritage launchers all render it.
36
36
  //
37
- // So `useDockMenu()` has a Linux rung after all, and the three menus an app
37
+ // So `useLauncherMenu()` has a Linux rung after all, and the three menus an app
38
38
  // puts on the desktop — panel, tray, launcher — are now one authoring model
39
39
  // on both backends. `.desktop` actions are still the right place for entries
40
40
  // that must work *while the app is not running*; they are a different feature
41
41
  // wearing a similar hat.
42
42
  //
43
- // Nothing here imports react: `useBadge`/`useDockMenu` (launcherhooks.js) are
43
+ // Nothing here imports react: `useBadge`/`useLauncherMenu` (launcherhooks.js) are
44
44
  // the hooks, and these are the functions under them, callable from host-side
45
45
  // code with no tree. The entry on the bus is held for as long as anything is
46
46
  // shown and released when the last of it is cleared — a held bus ref is a
@@ -300,7 +300,8 @@ export async function setUrgent(urgent, { app } = {}) {
300
300
  }
301
301
 
302
302
  /**
303
- * The menu behind a right-click on the app's launcher icon — the quicklist.
303
+ * The menu behind a right-click on the app's launcher icon — the Dock menu on
304
+ * macOS, the quicklist on Linux.
304
305
  *
305
306
  * Takes `MenuBar`'s item vocabulary and exports it as a `com.canonical.dbusmenu`
306
307
  * tree, exactly as the tray and the global menu do. `null` takes it down.
@@ -310,12 +311,12 @@ export async function setUrgent(urgent, { app } = {}) {
310
311
  * launcher that builds its client the instant it sees `quicklist` finds an
311
312
  * object there.
312
313
  */
313
- export async function setQuicklist(items, { app } = {}) {
314
+ export async function setLauncherMenu(items, { app } = {}) {
314
315
  const target = app ?? soleApp();
315
316
 
316
- // Rung 1: the app's own tile menu.
317
- if (typeof target?.setDockMenu === 'function') {
318
- target.setDockMenu(items ?? null);
317
+ // Rung 1: the app's own icon menu.
318
+ if (typeof target?.setLauncherMenu === 'function') {
319
+ target.setLauncherMenu(items ?? null);
319
320
  return true;
320
321
  }
321
322
 
@@ -367,6 +368,14 @@ export async function setQuicklist(items, { app } = {}) {
367
368
  return true;
368
369
  }
369
370
 
371
+ /**
372
+ * @deprecated Renamed to {@link setLauncherMenu}. "Quicklist" is the Unity
373
+ * launcher's word for the menu macOS calls the Dock menu; this function
374
+ * always drove both, and the name only ever named one of them. Kept working
375
+ * and kept quiet.
376
+ */
377
+ export const setQuicklist = setLauncherMenu;
378
+
370
379
  /** Test seam, not public: drop the exported entry without emitting. */
371
380
  export async function _resetLauncher() {
372
381
  await releaseEntry();
@@ -1,4 +1,4 @@
1
- // `useBadge()`, `useProgress()` and `useDockMenu()` — the launcher's view of
1
+ // `useBadge()`, `useProgress()` and `useLauncherMenu()` — the launcher's view of
2
2
  // the app, as things a component declares rather than manages.
3
3
  //
4
4
  // All three now have a rung on both backends except progress, which has one
@@ -7,7 +7,7 @@
7
7
  // feature an app should branch on, and a development warning for every
8
8
  // desktop that lacks one is a warning nobody can act on.
9
9
  //
10
- // The one that changed shape is `useDockMenu`. It used to be cocoa-only on
10
+ // The one that changed shape is `useLauncherMenu`. It used to be cocoa-only on
11
11
  // the grounds that the freedesktop counterpart was an install step — see
12
12
  // `launcher.js`, where that reasoning is corrected: the launcher protocol
13
13
  // carries a `quicklist` dbusmenu, so the Dock menu is runtime code on both.
@@ -15,7 +15,7 @@
15
15
  import { useEffect, useRef } from 'react';
16
16
 
17
17
  import { useAppOrNull } from './appcontext.js';
18
- import { setBadge, setProgress, setQuicklist } from './launcher.js';
18
+ import { setBadge, setLauncherMenu, setProgress } from './launcher.js';
19
19
 
20
20
  /**
21
21
  * Show `value` on the app's icon while this component is mounted, and clear
@@ -63,12 +63,12 @@ export function useProgress(value) {
63
63
  }
64
64
 
65
65
  /**
66
- * The menu behind a right-click on the app's icon in the Dock or launcher,
67
- * from the same `items` vocabulary `MenuBar` and `ContextMenu` take — an
68
- * item's `onSelect` fires when the user picks it.
66
+ * The menu behind a right-click on the app's **launcher icon** the Dock on
67
+ * macOS, the launcher on Linux — from the same `items` vocabulary `MenuBar`
68
+ * and `ContextMenu` take. An item's `onSelect` fires when the user picks it.
69
69
  *
70
70
  * ```jsx
71
- * useDockMenu([
71
+ * useLauncherMenu([
72
72
  * { label: 'New Window', onSelect: openWindow },
73
73
  * { type: 'separator' },
74
74
  * { label: 'Recent', items: recent.map(toItem) },
@@ -80,8 +80,14 @@ export function useProgress(value) {
80
80
  * backend; the launcher protocol's `quicklist` on Linux, which needs the
81
81
  * identity `registerApplication({ appId })` establishes and a `.desktop` file
82
82
  * of that name for a launcher to attach it to.
83
+ *
84
+ * It reads `useDesktopCapability('launcher').features.menu`, which is where
85
+ * the name comes from: every desktop has one icon standing for this
86
+ * application and calls it something different — Dock, taskbar, panel, dash —
87
+ * and `launcher` is the one word that is none of their words and all of their
88
+ * meanings (AGENTS.md, "Vocabulary").
83
89
  */
84
- export function useDockMenu(items) {
90
+ export function useLauncherMenu(items) {
85
91
  const app = useAppOrNull();
86
92
  // read at activation time, so a pick three minutes from now runs the
87
93
  // handler from the current render rather than the mounting one
@@ -89,12 +95,20 @@ export function useDockMenu(items) {
89
95
  live.current = items;
90
96
 
91
97
  useEffect(() => {
92
- setQuicklist(items ?? null, { app }).catch(() => {});
98
+ setLauncherMenu(items ?? null, { app }).catch(() => {});
93
99
  }, [items, app]);
94
100
 
95
101
  useEffect(() => {
96
102
  return () => {
97
- setQuicklist(null, { app }).catch(() => {});
103
+ setLauncherMenu(null, { app }).catch(() => {});
98
104
  };
99
105
  }, [app]);
100
106
  }
107
+
108
+ /**
109
+ * @deprecated Renamed to {@link useLauncherMenu}. "Dock" is one desktop's
110
+ * word for the icon every desktop has; this hook drove the Linux launcher's
111
+ * quicklist long before the name caught up. Kept working, and kept quiet —
112
+ * an alias that warned would punish an app for code that is still correct.
113
+ */
114
+ export const useDockMenu = useLauncherMenu;
package/src/node.d.ts CHANGED
@@ -40,7 +40,7 @@ export interface ImageDataLike {
40
40
  /**
41
41
  * The 2d context a node paints into — the canvas-shaped subset **both**
42
42
  * backends implement: ntk's `RenderingContext2D` over XRender on X11, and
43
- * `CocoaContext2D` over CoreGraphics on macOS. Declared as the contract an
43
+ * `BackendContext2D` over CoreGraphics on macOS. Declared as the contract an
44
44
  * element may rely on rather than as either class: what is here is on
45
45
  * both, and a member one backend has and the other does not is optional
46
46
  * here or absent. Coordinates are device pixels in the owning window's
@@ -422,6 +422,15 @@ export class NodeCascade {
422
422
  get placed() {
423
423
  const owner = this.isWindow ? this : this.root;
424
424
  if (!owner) return false;
425
+ // A window written under a root `<ThemeProvider>` is handed that palette
426
+ // when the scope inserts it, which is after it and its subtree were built
427
+ // and first resolved (nodes/scope.js `insertBefore`). Until then its
428
+ // ancestry is as incomplete as a popup's without its parent, and every
429
+ // token the provider defines would be reported as unknown — a warning for
430
+ // a style that then resolves correctly, and under
431
+ // `REACT_X11_STRICT_TOKENS=1` a throw that killed an app whose palette was
432
+ // fine.
433
+ if (owner._awaitsRootScope && owner._scope == null) return false;
425
434
  return owner.isPopup ? owner.parent != null : true;
426
435
  }
427
436
 
package/src/nodes/node.js CHANGED
@@ -70,7 +70,12 @@ export class Node {
70
70
  return DEVTOOLS_FAKE_DOCUMENT;
71
71
  }
72
72
 
73
- constructor(kind, props, app, { yoga = true } = {}) {
73
+ constructor(kind, props, app, { yoga = true, awaitsRootScope = false } = {}) {
74
+ // Set before anything resolves a style: a `<window>`'s own style is
75
+ // resolved by this constructor, and whether its ancestry is complete
76
+ // decides whether an unresolved `$token` is reported (cascade.js
77
+ // `placed`). A window under a root `<ThemeProvider>` has no palette yet.
78
+ this._awaitsRootScope = awaitsRootScope;
74
79
  this.kind = kind;
75
80
  this.props = props;
76
81
  this.app = app;
@@ -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;