react-x11 2.16.1 → 2.17.1

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 (55) 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 +17 -9
  10. package/src/cocoa/fonts.js +1 -1
  11. package/src/cocoa/glarea.js +48 -10
  12. package/src/cocoa/overlay.js +2 -2
  13. package/src/cocoa/panewindow.js +2 -2
  14. package/src/cocoa/presenter.js +2 -2
  15. package/src/cocoa/surface.js +3 -3
  16. package/src/cocoa/window.js +23 -2
  17. package/src/events.js +21 -0
  18. package/src/foreignnodes.js +8 -3
  19. package/src/frame/index.js +30 -4
  20. package/src/glnodes.js +21 -7
  21. package/src/idle.js +59 -1
  22. package/src/index.d.ts +41 -0
  23. package/src/index.js +30 -3
  24. package/src/launcher.js +17 -8
  25. package/src/launcherhooks.js +24 -10
  26. package/src/node.d.ts +1 -1
  27. package/src/nodes/cascade.js +9 -0
  28. package/src/nodes/node.js +6 -1
  29. package/src/nodes/window/hints.js +21 -2
  30. package/src/nodes/window/window.js +2 -2
  31. package/src/notifications.js +39 -14
  32. package/src/taskbarhooks.js +164 -0
  33. package/src/transfer.js +20 -1
  34. package/src/trayhooks.js +1 -1
  35. package/src/types/capabilities.d.ts +32 -3
  36. package/src/types/elements.d.ts +23 -1
  37. package/src/types/events.d.ts +16 -0
  38. package/src/types/launcher.d.ts +20 -6
  39. package/src/types/taskbar.d.ts +79 -0
  40. package/src/wayland/context2d.js +1 -1
  41. package/src/win32/a11y.js +604 -0
  42. package/src/win32/app.js +768 -0
  43. package/src/win32/bezels.js +158 -0
  44. package/src/win32/dnd.js +283 -0
  45. package/src/win32/fonts.js +497 -0
  46. package/src/win32/glarea.js +548 -0
  47. package/src/win32/ime.js +267 -0
  48. package/src/win32/keymap.js +116 -0
  49. package/src/win32/native.js +54 -0
  50. package/src/win32/panehost.js +106 -0
  51. package/src/win32/panewindow.js +343 -0
  52. package/src/win32/shell.js +426 -0
  53. package/src/win32/surface.js +192 -0
  54. package/src/win32/window.js +659 -0
  55. package/src/windowid.js +66 -0
package/src/cocoa/app.js CHANGED
@@ -38,10 +38,10 @@ import { CocoaPaneWindow } from './panewindow.js';
38
38
  import { CocoaColorSampler } from './screencolor.js';
39
39
  import { CocoaFilePanels } from './filepanels.js';
40
40
  import { CocoaFontManager } from './fonts.js';
41
- import { releaseImageUpload } from './context2d.js';
41
+ import { releaseImageUpload } from '../backend/context2d.js';
42
42
  import { CocoaSurface } from './surface.js';
43
43
  import { CocoaSymbols } from './symbols.js';
44
- import { CocoaWindow } from './window.js';
44
+ import { CocoaWindow, FRAME_SLACK_MS } from './window.js';
45
45
  import { decodeKey, modifierMask } from './keymap.js';
46
46
  import { loadNative } from './native.js';
47
47
  import { requestAppKit, threadedChannel } from './threaded.js';
@@ -55,9 +55,6 @@ import { requestAppKit, threadedChannel } from './threaded.js';
55
55
  // bridge that does not report it: a 60Hz floor every Mac clears.
56
56
  const RAF_INTERVAL_MS = 16;
57
57
  const PUMP_INTERVAL_MS = 8;
58
- // How early a pump tick may take a frame that is not quite due, in ms — the
59
- // drift of a timer, not a fraction of the pump (`_frameDue`).
60
- const FRAME_SLACK_MS = 1;
61
58
  // Threaded mode's live-resize handshake (windowkit/appkit#53): how long
62
59
  // AppKit may hold a resize tick for a frame painted at the new size, in ms.
63
60
  // The edge moves when the delegate returns, so the budget is how long a
@@ -204,7 +201,7 @@ export class CocoaApp {
204
201
  native.setAppName(String(appName));
205
202
  }
206
203
 
207
- // The Dock menu (src/cocoa/dock.js), installed by `useDockMenu`.
204
+ // The Dock menu (src/cocoa/dock.js), installed by `useLauncherMenu`.
208
205
  this._dockMenu = new CocoaDockMenu(this);
209
206
  // The tray items (src/cocoa/statusitem.js), by the bridge's handle —
210
207
  // which is what a click event names them by.
@@ -703,8 +700,12 @@ export class CocoaApp {
703
700
  this._native.setDockBadge(label == null ? null : String(label));
704
701
  }
705
702
 
706
- /** The menu behind a right-click on the Dock icon — `useDockMenu()`. */
707
- setDockMenu(items) {
703
+ /**
704
+ * The menu behind a right-click on the launcher icon — `useLauncherMenu()`.
705
+ * Here the launcher is the Dock, which is why the AppKit call below keeps
706
+ * its own name: `_native.setDockMenu` *is* what Apple calls it.
707
+ */
708
+ setLauncherMenu(items) {
708
709
  this._dockMenu.update(items);
709
710
  }
710
711
 
@@ -902,6 +903,13 @@ export class CocoaApp {
902
903
  * tick alone still does is pump AppKit's events, which is what
903
904
  * `pumpInterval` stays the cadence of. With no pump at all — threaded
904
905
  * mode, where `_pumpInterval` is Infinity — every frame is one of these.
906
+ *
907
+ * The wait is rounded **up** to whole milliseconds, because `setTimeout`
908
+ * only counts those: a 120Hz panel's 8.333ms gate rounded to 8 fired
909
+ * before the clock would take the frame, which cost a second timer per
910
+ * frame to cover the last third of a millisecond. Rounding up costs the
911
+ * grid nothing — `_frameDue` anchors the next slot to the last one, not
912
+ * to when the frame ran — and lands on the first tick the clock accepts.
905
913
  */
906
914
  _armFrameTimer(wait, now) {
907
915
  if (!(wait > 0 && wait < this._pumpInterval)) return;
@@ -918,7 +926,7 @@ export class CocoaApp {
918
926
  this._tickFrames();
919
927
  this._presentAll();
920
928
  },
921
- Math.max(1, Math.round(wait)),
929
+ Math.max(1, Math.ceil(wait)),
922
930
  );
923
931
  }
924
932
 
@@ -846,7 +846,7 @@ export class CocoaFontManager {
846
846
  }
847
847
 
848
848
  /**
849
- * The CTFont a glyph run draws with (`CocoaContext2D.drawGlyphs`): a face
849
+ * The CTFont a glyph run draws with (`BackendContext2D.drawGlyphs`): a face
850
850
  * of this engine's at the run's size, or an ntk `Font` — `openFont()`'s —
851
851
  * resolved to CoreText from the same bytes, so the glyph ids it shaped
852
852
  * with hold. Null for anything else, and the run is skipped.
@@ -231,6 +231,16 @@ export class CocoaGLArea {
231
231
  return this.parent.requestAnimationFrame(cb);
232
232
  }
233
233
 
234
+ /**
235
+ * The owning window's clock, asked where a gate of `interval` ms lands
236
+ * on its grid (`CocoaWindow.nextFrameAt`) — the swap gate phase-locks to
237
+ * it so the two do not stack. A parent with no clock (a bare stub in a
238
+ * test) answers one interval from now, the old swap-relative wait.
239
+ */
240
+ nextFrameAt(interval) {
241
+ return this.parent.nextFrameAt?.(interval) ?? performance.now() + interval;
242
+ }
243
+
234
244
  getContext(kind, config) {
235
245
  if (kind !== 'opengl' || this.destroyed) return null;
236
246
  if (!this._context) {
@@ -262,7 +272,10 @@ function createGLAreaContext(runtime, area, config) {
262
272
  let back = null;
263
273
  let width = 0;
264
274
  let height = 0;
265
- let gateClosed = false;
275
+ // The moment the swap gate reopens, on the window clock's grid — not a
276
+ // boolean a timer flips, so a frame that arrives a fraction of a
277
+ // millisecond before its own timer still reads the true state.
278
+ let gateOpenAt = -Infinity;
266
279
  let gateTimer = null;
267
280
  let destroyed = false;
268
281
 
@@ -289,7 +302,17 @@ function createGLAreaContext(runtime, area, config) {
289
302
  // one, so the honest gate is one display period per swap — the same
290
303
  // timer-reopened gate the XQuartz CGL flavor uses (no backpressure
291
304
  // exists on either).
292
- ctx.canRender = () => !destroyed && !gateClosed;
305
+ //
306
+ // One period *per frame*, though, not per swap: timed from the swap the
307
+ // gate reopened at `slot + drawCost + period`, which overshoots the
308
+ // window clock's next slot however cheap the frame, and the clock then
309
+ // rounded it up to the slot after — two display periods, a 120Hz panel
310
+ // pinned at 60fps whatever the scene cost (issue #631). Anchored to the
311
+ // slot the frame was due at (`CocoaWindow.nextFrameAt`) the two gates
312
+ // are on one grid: the clock cannot take a frame the gate would refuse,
313
+ // because both are the same instant, and the gate still never lets two
314
+ // frames into one period.
315
+ ctx.canRender = (now = performance.now()) => !destroyed && now >= gateOpenAt;
293
316
 
294
317
  ctx.makeCurrent = () => {
295
318
  if (destroyed) return;
@@ -305,6 +328,26 @@ function createGLAreaContext(runtime, area, config) {
305
328
  runtime.gl.bindFramebuffer(target, fb == null ? (back?.fbo ?? 0) : fb);
306
329
  };
307
330
 
331
+ // A frame refused by the gate has nothing else to wake it, so the gate
332
+ // says when it opens. `setTimeout` counts whole milliseconds and a
333
+ // display period is rarely one (8.333 at 120Hz), so a timer that lands
334
+ // early re-arms rather than announcing a gate that is still shut.
335
+ const armGate = () => {
336
+ if (gateTimer || destroyed) return;
337
+ const wait = gateOpenAt - performance.now();
338
+ gateTimer = setTimeout(
339
+ () => {
340
+ gateTimer = null;
341
+ if (destroyed) return;
342
+ if (performance.now() < gateOpenAt) armGate();
343
+ else ctx.onFrameAvailable?.();
344
+ },
345
+ Math.max(1, Math.ceil(wait)),
346
+ );
347
+ // the timer must not hold the process open for an idle scene
348
+ gateTimer.unref?.();
349
+ };
350
+
308
351
  ctx.SwapBuffers = () => {
309
352
  if (destroyed || !back) return;
310
353
  runtime.gl.flush();
@@ -312,14 +355,9 @@ function createGLAreaContext(runtime, area, config) {
312
355
  const shown = back;
313
356
  back = front;
314
357
  front = shown;
315
- gateClosed = true;
316
- gateTimer = setTimeout(() => {
317
- gateTimer = null;
318
- gateClosed = false;
319
- ctx.onFrameAvailable?.();
320
- }, runtime.frameInterval);
321
- // the timer must not hold the process open for an idle scene
322
- gateTimer.unref?.();
358
+ // one display period from the frame's slot, not from this moment
359
+ gateOpenAt = area.nextFrameAt(runtime.frameInterval);
360
+ armGate();
323
361
  };
324
362
 
325
363
  ctx._resized = () => {
@@ -14,7 +14,7 @@
14
14
  // `map`, `unmap`, `getContext`, `destroy` — plus `present`, which puts what
15
15
  // was painted on the layer: ntk blits an X window's backing store on its
16
16
  // own, where a layer's contents are a copy the bitmap has to be pushed to.
17
- import { CocoaContext2D } from './context2d.js';
17
+ import { BackendContext2D } from '../backend/context2d.js';
18
18
 
19
19
  export const OVERLAY_Z = 1e7 + 1;
20
20
 
@@ -115,7 +115,7 @@ export class CocoaOverlayPane {
115
115
 
116
116
  getContext() {
117
117
  if (!this._ctx) {
118
- this._ctx = new CocoaContext2D(
118
+ this._ctx = new BackendContext2D(
119
119
  this._native,
120
120
  () => this._ensureSurface(),
121
121
  () => {
@@ -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,12 +6,17 @@
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';
13
13
 
14
14
  let nextWindowId = 1;
15
+ // The slack a frame clock allows under its interval, in ms: a timer's drift
16
+ // and no more (`CocoaApp._frameDue`, `_frameWait`, `nextFrameAt`). It lives
17
+ // here because the window is the clock — `_rafLast` and `_frameInterval`
18
+ // are its fields — and the app imports it from this side.
19
+ export const FRAME_SLACK_MS = 1;
15
20
  // How long a worker's flip may hold its window's next frame (`_armFence`).
16
21
  // The release is reported once the replacing frame has committed, a
17
22
  // fraction of a millisecond later, and a window must not freeze on a report
@@ -794,7 +799,7 @@ export class CocoaWindow {
794
799
 
795
800
  getContext() {
796
801
  if (!this._ctx) {
797
- this._ctx = new CocoaContext2D(
802
+ this._ctx = new BackendContext2D(
798
803
  this._native,
799
804
  () => this._ensureSurface(),
800
805
  () => {
@@ -910,6 +915,22 @@ export class CocoaWindow {
910
915
  return this.app._requestFrame(cb, this);
911
916
  }
912
917
 
918
+ /**
919
+ * The earliest moment this window's clock will hand out another frame,
920
+ * for a gate of `interval` ms — the display's period by default, which
921
+ * is the clock's own.
922
+ *
923
+ * It is the grid `_frameDue` decides on, not the wall clock: the anchor
924
+ * is the slot the running frame was *due* at, so the answer does not
925
+ * move with how long that frame took or how late its timer fired. A
926
+ * second gate that wants to compose with this clock rather than be
927
+ * rounded up by it has to land on this grid — which is what a
928
+ * `<glarea>`'s swap gate reads it for (src/cocoa/glarea.js).
929
+ */
930
+ nextFrameAt(interval = this._frameInterval) {
931
+ return this._rafLast + interval - FRAME_SLACK_MS;
932
+ }
933
+
913
934
  /**
914
935
  * Push the backing surface at the WindowServer, if anything drew — and
915
936
  * tell the window node what it cost. The flip itself is cheap, and the
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
  }
@@ -365,18 +376,21 @@ export class GlAreaNode extends Node {
365
376
  typeof this.window.requestAnimationFrame === 'function'
366
377
  ? (cb) => this.window.requestAnimationFrame(cb)
367
378
  : (cb) => setImmediate(cb);
368
- schedule(() => {
379
+ // the frame clock's own timestamp, not a fresh reading: a gate that
380
+ // composes with that clock is answered from the same moment it is
381
+ // (`canRender` on the Cocoa surface, issue #631)
382
+ schedule((now) => {
369
383
  this._frameScheduled = false;
370
- this._drawFrame();
384
+ this._drawFrame(now);
371
385
  });
372
386
  }
373
387
 
374
- _drawFrame() {
388
+ _drawFrame(now) {
375
389
  const pacer = this._pacer;
376
390
  pacer.began();
377
391
  let drawn = false;
378
392
  try {
379
- drawn = this._drawFrameNow();
393
+ drawn = this._drawFrameNow(now);
380
394
  } finally {
381
395
  pacer.ended(undefined, drawn);
382
396
  }
@@ -386,14 +400,14 @@ export class GlAreaNode extends Node {
386
400
  }
387
401
 
388
402
  /** The frame itself; true when it drew. */
389
- _drawFrameNow() {
403
+ _drawFrameNow(now) {
390
404
  const gl = this.gl;
391
405
  if (!gl || this.destroyed) return false;
392
406
  const direct = gl.backend === 'direct';
393
407
  // On the direct backend every buffer may still be held by the display,
394
408
  // and drawing into one before it comes back would paint what is on
395
409
  // screen. `onFrameAvailable` asks for this frame again when one frees.
396
- if (direct && gl.canRender && !gl.canRender()) {
410
+ if (direct && gl.canRender && !gl.canRender(now)) {
397
411
  this._frameRefused = true;
398
412
  return false;
399
413
  }
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