react-x11 2.51.1 → 2.52.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 (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +9 -2
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -11,17 +11,30 @@
11
11
  //
12
12
  // `appearance.js` climbs three sources for colour scheme and accent, because
13
13
  // those are answered by three different things and the portal is the modern
14
- // one. Nothing here has a portal equivalent: `org.freedesktop.appearance` has
15
- // four keys and none of them is a timing. XSETTINGS is the whole story, and
16
- // where there is no settings daemon — a bare `startx`, most window managers,
17
- // XQuartz — the defaults below stand.
14
+ // one. Nothing here has a *standard* portal equivalent:
15
+ // `org.freedesktop.appearance` has four keys and none of them is a timing.
16
+ // On X11, XSETTINGS is the whole story, and where there is no settings
17
+ // daemon — a bare `startx`, most window managers, XQuartz — the defaults
18
+ // below stand.
18
19
  //
19
- // So this reads the map `xsettings.js` already maintains, rather than
20
- // standing up a source of its own. The one exception is macOS, which has no
21
- // XSETTINGS and answers exactly one of these — "reduce motion", through
22
- // `NSWorkspace` — so the Cocoa app exposes that behind a two-method seam
23
- // (`accessibilityDisplayOptions`, `watchAccessibilityDisplay`) and this
24
- // module reads it the way it reads the map: synchronously, cached, live.
20
+ // So on X11 this reads the map `xsettings.js` already maintains, rather than
21
+ // standing up a source of its own. There are two exceptions, one per backend
22
+ // that has no X server to ask:
23
+ //
24
+ // - **macOS** answers exactly one of these — "reduce motion", through
25
+ // `NSWorkspace` — so the Cocoa app exposes that behind a two-method seam
26
+ // (`accessibilityDisplayOptions`, `watchAccessibilityDisplay`) and this
27
+ // module reads it the way it reads the map: synchronously, cached, live.
28
+ //
29
+ // - **The Wayland backend** has no XSETTINGS either, but the settings portal
30
+ // is not limited to `org.freedesktop.appearance`: the GNOME and GTK portal
31
+ // backends pass whole GSettings schemas through it, and
32
+ // `org.gnome.desktop.interface` and `org.gnome.desktop.peripherals.mouse`
33
+ // are exactly what gsd-xsettings translates into the XSETTINGS keys this
34
+ // reads on X11. Same numbers, one hop closer to the source. Read once at
35
+ // startup and again on `SettingChanged`; a desktop whose portal does not
36
+ // carry them (KDE, a bare compositor) keeps the defaults, as a bare
37
+ // `startx` does.
25
38
  //
26
39
  // ## Two of these are read synchronously, which is what shapes the module
27
40
  //
@@ -34,8 +47,26 @@
34
47
  // desktop's cadence from the next focus onward; that is the one seam, and it
35
48
  // is worth more than the round trips on every app's startup path.
36
49
 
50
+ import { sessionBus } from './bus.js';
51
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
52
+ import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
37
53
  import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
38
54
 
55
+ const SETTINGS_IFACE = 'org.freedesktop.portal.Settings';
56
+ const INTERFACE_NS = 'org.gnome.desktop.interface';
57
+ const MOUSE_NS = 'org.gnome.desktop.peripherals.mouse';
58
+
59
+ /** The keys a `SettingChanged` has to name for a re-read to be worth it —
60
+ * `org.gnome.desktop.interface` also carries the clock format, the font
61
+ * and the icon theme, and none of those is a reason to re-render. */
62
+ const WATCHED = new Set([
63
+ `${INTERFACE_NS}/cursor-blink`,
64
+ `${INTERFACE_NS}/cursor-blink-time`,
65
+ `${INTERFACE_NS}/enable-animations`,
66
+ `${MOUSE_NS}/double-click`,
67
+ `${MOUSE_NS}/drag-threshold`,
68
+ ]);
69
+
39
70
  /**
40
71
  * What is true with no settings daemon to ask.
41
72
  *
@@ -71,24 +102,15 @@ const sessions = new WeakMap();
71
102
  * export a key with a string value or a nonsense one, and a caret with a
72
103
  * blink period of `'fast'` would never come back on. */
73
104
  function positive(map, key) {
74
- const value = map?.get(key);
105
+ return positiveValue(map?.get(key));
106
+ }
107
+
108
+ function positiveValue(value) {
75
109
  return typeof value === 'number' && Number.isFinite(value) && value > 0
76
110
  ? value
77
111
  : undefined;
78
112
  }
79
113
 
80
- /**
81
- * An XSETTINGS map → the settings. Pure, and exported for the test: the
82
- * in-process X server has no settings daemon, so this is the part of the
83
- * path worth pinning without one.
84
- *
85
- * **`Net/CursorBlinkTime` is a full cycle**, on and off together — that is
86
- * what GTK means by `gtk-cursor-blink-time` and what every daemon writes —
87
- * and `caretBlinkMs` is how long the caret stays in *each* state, because
88
- * that is the number that goes into a timer. Halving it is the whole
89
- * conversion, and forgetting to is a caret that blinks at half speed and
90
- * looks broken rather than wrong.
91
- */
92
114
  /**
93
115
  * macOS's accessibility display options → the settings. Only `animations`
94
116
  * comes from the system there: it is the one of these macOS publishes at all
@@ -105,6 +127,18 @@ export function fromMacOS(options) {
105
127
  });
106
128
  }
107
129
 
130
+ /**
131
+ * An XSETTINGS map → the settings. Pure, and exported for the test: the
132
+ * in-process X server has no settings daemon, so this is the part of the
133
+ * path worth pinning without one.
134
+ *
135
+ * **`Net/CursorBlinkTime` is a full cycle**, on and off together — that is
136
+ * what GTK means by `gtk-cursor-blink-time` and what every daemon writes —
137
+ * and `caretBlinkMs` is how long the caret stays in *each* state, because
138
+ * that is the number that goes into a timer. Halving it is the whole
139
+ * conversion, and forgetting to is a caret that blinks at half speed and
140
+ * looks broken rather than wrong.
141
+ */
108
142
  export function fromXSettings(map) {
109
143
  if (!map) return DEFAULTS;
110
144
  const cycle = positive(map, 'Net/CursorBlinkTime');
@@ -134,6 +168,39 @@ export function fromXSettings(map) {
134
168
  });
135
169
  }
136
170
 
171
+ /**
172
+ * The settings portal's GNOME namespaces → the settings. Pure, and exported
173
+ * for the test. `all` is a `ReadAll` reply, namespace → key → value.
174
+ *
175
+ * The same keys gsd-xsettings turns into the XSETTINGS ones above, so the
176
+ * same rules: `cursor-blink-time` is a full cycle and is halved, only an
177
+ * explicit `false` turns blinking or animation off, and a value of the wrong
178
+ * type is no answer. GNOME has no double-click *distance*; GTK's is a
179
+ * constant there, so the default stands.
180
+ *
181
+ * `null` when neither namespace came back — a portal that does not pass
182
+ * GSettings through has not answered, and the source stays null.
183
+ */
184
+ export function fromGnomePortal(all) {
185
+ const iface = all?.[INTERFACE_NS];
186
+ const mouse = all?.[MOUSE_NS];
187
+ if (!iface && !mouse) return null;
188
+ const cycle = positiveValue(iface?.['cursor-blink-time']);
189
+ return Object.freeze({
190
+ ...DEFAULTS,
191
+ caretBlink: iface?.['cursor-blink'] !== false,
192
+ caretBlinkMs: cycle
193
+ ? Math.max(1, Math.round(cycle / 2))
194
+ : DEFAULTS.caretBlinkMs,
195
+ doubleClickMs:
196
+ positiveValue(mouse?.['double-click']) ?? DEFAULTS.doubleClickMs,
197
+ dragThreshold:
198
+ positiveValue(mouse?.['drag-threshold']) ?? DEFAULTS.dragThreshold,
199
+ animations: iface?.['enable-animations'] !== false,
200
+ source: 'portal',
201
+ });
202
+ }
203
+
137
204
  /**
138
205
  * The settings for a connection, **synchronously**. Always a complete answer:
139
206
  * the defaults before the daemon has been read, and on a display with none.
@@ -148,7 +215,9 @@ export function desktopSettings(app) {
148
215
  if (!session.snapshot) {
149
216
  session.snapshot = session.macos
150
217
  ? fromMacOS(app.accessibilityDisplayOptions())
151
- : fromXSettings(xsettings(app));
218
+ : session.wayland
219
+ ? (fromGnomePortal(session.portal) ?? DEFAULTS)
220
+ : fromXSettings(xsettings(app));
152
221
  }
153
222
  return session.snapshot;
154
223
  }
@@ -173,6 +242,20 @@ export function beginDesktopSettings(app) {
173
242
  });
174
243
  return;
175
244
  }
245
+ // The Wayland backend: no X server, so no XSETTINGS — the portal instead,
246
+ // behind the same switch as the appearance ladder that reads the portal
247
+ // too. `createRoot({ desktop: false })` keeps the defaults, which is what a
248
+ // test or an embedder that asked for determinism wants.
249
+ if (app?.backend === 'wayland') {
250
+ session.wayland = true;
251
+ if (desktopIntegrationEnabled('appearance')) {
252
+ portalSettings(app, session).catch(() => {
253
+ // no bus, no portal, a portal without the GNOME namespaces: the
254
+ // defaults stand, which is what they are for
255
+ });
256
+ }
257
+ return;
258
+ }
176
259
  beginXSettings(app).then(
177
260
  () => {
178
261
  if (!sessions.has(app)) return;
@@ -190,6 +273,92 @@ export function beginDesktopSettings(app) {
190
273
  );
191
274
  }
192
275
 
276
+ /**
277
+ * Read the GNOME namespaces off the settings portal, and again whenever one
278
+ * of the keys this cares about changes.
279
+ *
280
+ * The order is `appearance.js`'s portal rung, for the same reasons: the
281
+ * match rule and the handler go in **before** the first read, so a change
282
+ * that lands while it is in flight is not lost; and reads are sequenced, so
283
+ * a slow reply never publishes over a newer one. The bus ref is released as
284
+ * soon as the first read settles — the subscription outlives it, and holding
285
+ * it would keep the process alive for as long as the app cared how fast its
286
+ * caret blinks.
287
+ */
288
+ async function portalSettings(app, session) {
289
+ const ref = await sessionBus();
290
+ if (!ref) return;
291
+ const { bus } = ref;
292
+ let sub = null;
293
+ let handler = null;
294
+ const key = bus.mangle(PORTAL_PATH, SETTINGS_IFACE, 'SettingChanged');
295
+ const stop = () => {
296
+ if (handler) bus.signals.removeListener(key, handler);
297
+ handler = null;
298
+ sub?.remove().catch(() => {});
299
+ sub = null;
300
+ };
301
+ try {
302
+ sub = await bus.watch(
303
+ `type='signal',sender='${PORTAL_NAME}',` +
304
+ `interface='${SETTINGS_IFACE}',member='SettingChanged'`,
305
+ );
306
+
307
+ let started = 0;
308
+ let published = 0;
309
+ const refresh = async () => {
310
+ const mine = ++started;
311
+ const all = await bus.invoke(
312
+ {
313
+ destination: PORTAL_NAME,
314
+ path: PORTAL_PATH,
315
+ interface: SETTINGS_IFACE,
316
+ member: 'ReadAll',
317
+ signature: 'as',
318
+ body: [[INTERFACE_NS, MOUSE_NS]],
319
+ },
320
+ { timeout: 5_000 },
321
+ );
322
+ if (mine <= published || sessions.get(app) !== session) return;
323
+ published = mine;
324
+ const next = fromGnomePortal(all);
325
+ if (!next) return;
326
+ const before = desktopSettings(app);
327
+ session.portal = all;
328
+ session.snapshot = null;
329
+ // A re-read that changed nothing this reads notifies nobody: the hook
330
+ // re-renders on identity, and so would every subscriber.
331
+ if (!sameSettings(before, desktopSettings(app))) notify(session);
332
+ else session.snapshot = before;
333
+ };
334
+
335
+ handler = ([namespace, name]) => {
336
+ if (!WATCHED.has(`${namespace}/${name}`)) return;
337
+ refresh().catch(() => {
338
+ // the portal went away mid-session; the last answer stands
339
+ });
340
+ };
341
+ bus.signals.on(key, handler);
342
+
343
+ // Torn down while the bus was connecting: undo, and publish nothing.
344
+ if (sessions.get(app) !== session) return stop();
345
+ session.stop = stop;
346
+ await refresh();
347
+ } catch (err) {
348
+ stop();
349
+ throw err;
350
+ } finally {
351
+ await ref.release();
352
+ }
353
+ }
354
+
355
+ function sameSettings(a, b) {
356
+ for (const key of Object.keys(DEFAULTS)) {
357
+ if (a[key] !== b[key]) return false;
358
+ }
359
+ return true;
360
+ }
361
+
193
362
  function notify(session) {
194
363
  for (const fn of [...session.listeners]) {
195
364
  try {
package/src/filedialog.js CHANGED
@@ -46,14 +46,14 @@ import {
46
46
  RESPONSE_CANCELLED,
47
47
  RESPONSE_OK,
48
48
  hasService,
49
- parentWindowHandle,
50
49
  pathBytes,
50
+ portalParentFor,
51
51
  portalRequest,
52
52
  variant,
53
53
  } from './portal.js';
54
54
  import { sessionBus } from './bus.js';
55
55
  import { liveApps } from './trace-registry.js';
56
- import { windowIdOf, windowOf } from './windowid.js';
56
+ import { windowOf } from './windowid.js';
57
57
 
58
58
  const FILE_CHOOSER = 'org.freedesktop.portal.FileChooser';
59
59
 
@@ -149,7 +149,7 @@ async function portalDialog(kind, opts, ref) {
149
149
  const { response, results } = await portalRequest(ref, {
150
150
  iface: FILE_CHOOSER,
151
151
  member,
152
- parentWindow: parentWindowHandle(windowIdOf(opts.parentWindow)),
152
+ parentWindow: await portalParentFor(opts.parentWindow),
153
153
  title: opts.title ?? defaultTitle(kind),
154
154
  options: await portalOptions(opts, {
155
155
  save: kind === 'save',
@@ -32,6 +32,18 @@
32
32
  // it answers atoms that have to be interned back one round trip each, and
33
33
  // what it returns is a description like `English (US)` where an indicator in
34
34
  // a status bar wants `us`.
35
+ //
36
+ // ## A backend with no X server
37
+ //
38
+ // The native Wayland backend has no XKB extension to ask, but its seat holds
39
+ // the same three answers: the locked modifiers and the group from
40
+ // `wl_keyboard.modifiers`, and the layout codes from the name of the keymap
41
+ // the compositor sent (src/wayland/xkb.js, `layoutsFromKeymap`). It hands
42
+ // them over through a two-method seam — `keyboardState()` and
43
+ // `watchKeyboardState(fn)` — and this module publishes what it says the way
44
+ // it publishes a StateNotify. The one difference is the protocol's:
45
+ // a compositor tells only the focused client, so the locks are as of the last
46
+ // time this app had the keyboard.
35
47
 
36
48
  import { requireExtension } from './extensions.js';
37
49
 
@@ -71,6 +83,7 @@ class KeyboardSession {
71
83
  this.armed = false;
72
84
  this.stopped = false;
73
85
  this._handler = null;
86
+ this._unwatch = null;
74
87
  }
75
88
 
76
89
  publish(values) {
@@ -106,6 +119,8 @@ class KeyboardSession {
106
119
 
107
120
  stop() {
108
121
  this.stopped = true;
122
+ this._unwatch?.();
123
+ this._unwatch = null;
109
124
  if (this._handler && this.app?.X?.off) {
110
125
  try {
111
126
  this.app.X.off('event', this._handler);
@@ -157,6 +172,19 @@ async function arm(session) {
157
172
  if (session.armed) return;
158
173
  session.armed = true;
159
174
  const app = session.app;
175
+
176
+ // A backend that answers for itself (the Wayland seat): subscribe, then
177
+ // read, for the same reason the XKB path below does.
178
+ if (typeof app?.watchKeyboardState === 'function') {
179
+ const read = () => {
180
+ const state = !session.stopped && app.keyboardState?.();
181
+ if (state) session.publish(state);
182
+ };
183
+ session._unwatch = app.watchKeyboardState(read);
184
+ read();
185
+ return;
186
+ }
187
+
160
188
  const xkb = await requireExtension(app, 'xkb');
161
189
  if (!xkb || session.stopped) return;
162
190
 
package/src/node.d.ts CHANGED
@@ -39,12 +39,13 @@ export interface ImageDataLike {
39
39
  }
40
40
 
41
41
  /**
42
- * The 2d context a node paints into — the canvas-shaped subset **both**
43
- * backends implement: ntk's `RenderingContext2D` over XRender on X11, and
44
- * `BackendContext2D` over CoreGraphics on macOS. Declared as the contract an
45
- * element may rely on rather than as either class: what is here is on
46
- * both, and a member one backend has and the other does not is optional
47
- * here or absent. Coordinates are device pixels in the owning window's
42
+ * The 2d context a node paints into — the canvas-shaped subset **every**
43
+ * backend implements: ntk's `RenderingContext2D` over XRender on X11, the
44
+ * Wayland backend's GLES rasterizer, and `BackendContext2D` over CoreGraphics
45
+ * on macOS and Direct2D on Windows. Declared as the contract an element may
46
+ * rely on rather than as any one class: what is here is on all of them, and
47
+ * a member some backends have and others do not is optional here or
48
+ * absent. Coordinates are device pixels in the owning window's
48
49
  * space — `abs`, `contentBox()` — unless a transform says otherwise.
49
50
  * Anything ntk documents beyond this is reachable at runtime and is
50
51
  * ntk's to change.
@@ -67,10 +68,9 @@ export interface Context2D {
67
68
  shadowOffsetY: number;
68
69
  shadowColor: string;
69
70
  /**
70
- * X11 only: XRender's compositing operator — `'source-over'` (the
71
- * default), `'copy'`, and the rest ntk documents. Absent on the cocoa
72
- * backend, whose bridge has no blend-mode verb yet; test for it with
73
- * `'globalCompositeOperation' in ctx`.
71
+ * The compositing operator — `'source-over'` (the default), `'copy'`,
72
+ * and the rest ntk documents on X11. A native context (macOS, Windows)
73
+ * takes only the modes its bridge draws: assign and read back to know.
74
74
  */
75
75
  globalCompositeOperation?: string;
76
76
  /**
package/src/portal.js CHANGED
@@ -29,6 +29,7 @@
29
29
  // re-solve.
30
30
 
31
31
  import { loadTransport, sessionBus } from './bus.js';
32
+ import { windowIdOf, windowOf } from './windowid.js';
32
33
 
33
34
  export const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
34
35
  export const PORTAL_PATH = '/org/freedesktop/portal/desktop';
@@ -439,6 +440,36 @@ export function parentWindowHandle(xid) {
439
440
  return typeof xid === 'number' && xid > 0 ? `x11:${xid.toString(16)}` : '';
440
441
  }
441
442
 
443
+ /**
444
+ * The `parent_window` handle for whatever names a window — a ref, a node, a
445
+ * backend window, an XID — on whichever backend it belongs to.
446
+ *
447
+ * - **X11**: `x11:<xid>`, as `parentWindowHandle` spells it.
448
+ * - **Wayland**: `wayland:<handle>`, from an xdg-foreign export of the
449
+ * toplevel (src/wayland/app.js, `exportHandle`) — what the portal needs to
450
+ * make the dialog modal to the window and stack it there. `''` where the
451
+ * compositor has no xdg-foreign, or the window is a popup; never an `x11:`
452
+ * handle, because a Wayland window's id names no X window, and on a desktop
453
+ * running Xwayland it could name somebody else's.
454
+ *
455
+ * `app` is the fallback for a target that resolves to no window object — a
456
+ * bare number on Wayland is that app's id for one of its own windows.
457
+ *
458
+ * @param {any} target
459
+ * @param {any} [app]
460
+ * @returns {Promise<string>}
461
+ */
462
+ export async function portalParentFor(target, app = null) {
463
+ let win = windowOf(target);
464
+ const owner = win?.app ?? app;
465
+ if (owner?.backend !== 'wayland') {
466
+ return parentWindowHandle(windowIdOf(target));
467
+ }
468
+ if (!win && typeof target === 'number') win = owner.windowById?.(target);
469
+ const handle = win ? await owner.exportHandle?.(win) : null;
470
+ return handle ? `wayland:${handle}` : '';
471
+ }
472
+
442
473
  /**
443
474
  * `Variant`, from the one copy of the transport the bus layer loaded.
444
475
  *
@@ -65,12 +65,11 @@ import {
65
65
  PortalCancelledError,
66
66
  RESPONSE_OK,
67
67
  hasService,
68
- parentWindowHandle,
68
+ portalParentFor,
69
69
  portalRequest,
70
70
  portalVersion,
71
71
  } from './portal.js';
72
72
  import { liveApps } from './trace-registry.js';
73
- import { windowIdOf } from './windowid.js';
74
73
 
75
74
  export const SCREENSHOT_IFACE = 'org.freedesktop.portal.Screenshot';
76
75
  /** `PickColor` arrived in version 2 of the Screenshot interface. */
@@ -220,14 +219,10 @@ async function portalPick(opts, ref) {
220
219
  // options)` — no title — which is the whole reason portalRequest takes a
221
220
  // signature now.
222
221
  signature: 'sa{sv}',
223
- // The handle names an X window. A Wayland toplevel has none to name —
224
- // an xdg-foreign export would be the equivalent — so there the dialog
225
- // floats, which the portal allows.
226
- args: [
227
- appFor(opts)?.backend === 'wayland'
228
- ? ''
229
- : parentWindowHandle(windowIdOf(opts.parentWindow)),
230
- ],
222
+ // An X window's handle on X11, an xdg-foreign export on Wayland, and
223
+ // nothing — a floating dialog, which the portal allows — where neither
224
+ // can be had.
225
+ args: [await portalParentFor(opts.parentWindow, appFor(opts))],
231
226
  options: {},
232
227
  signal: opts.signal,
233
228
  });
@@ -253,16 +253,18 @@ export interface DesktopSettings {
253
253
  /** How far a press moves before it is a drag rather than a click. */
254
254
  readonly dragThreshold: number;
255
255
  /** **False means hold still** — the desktop asked for less motion:
256
- * `Gtk/EnableAnimations` off over XSETTINGS, reduce motion on macOS.
256
+ * `Gtk/EnableAnimations` off over XSETTINGS, `enable-animations` off on
257
+ * the settings portal (Wayland), reduce motion on macOS.
257
258
  * Core's loops do not start then (docs/styling.md, "Loops"); an element
258
259
  * that animates on a timer of its own holds still too, and draws its
259
260
  * resting frame. True wherever the desktop said nothing. */
260
261
  readonly animations: boolean;
261
- /** `'xsettings'`; `'macos'` on the Cocoa backend, where only `animations`
262
- * comes from the system (reduce motion) and the rest are the defaults; or
263
- * null where no settings daemon answered and these are the renderer's own
264
- * defaults. */
265
- readonly source: 'xsettings' | 'macos' | 'test' | null;
262
+ /** `'xsettings'`; `'portal'` on the Wayland backend, from the settings
263
+ * portal's GNOME namespaces; `'macos'` on the Cocoa backend, where only
264
+ * `animations` comes from the system (reduce motion) and the rest are the
265
+ * defaults; or null where nothing answered and these are the renderer's
266
+ * own defaults. */
267
+ readonly source: 'xsettings' | 'portal' | 'macos' | 'test' | null;
266
268
  }
267
269
 
268
270
  /**
@@ -104,6 +104,9 @@ export class WaylandApp extends EventEmitter {
104
104
  this.viewporter = null;
105
105
  this.fractionalScale = null;
106
106
  this.activation = null;
107
+ /** xdg-foreign's exporter: a toplevel's handle for another client to
108
+ * parent to (`exportHandle`); null where the compositor has none */
109
+ this.exporter = null;
107
110
  /** the monitors, published into src/screens.js (outputs.js) */
108
111
  this.outputs = null;
109
112
  /** xdg-decoration, layer-shell, shm: null where the compositor has none */
@@ -153,6 +156,7 @@ export class WaylandApp extends EventEmitter {
153
156
  app.viewporter = await conn.bind('wp_viewporter');
154
157
  app.fractionalScale = await conn.bind('wp_fractional_scale_manager_v1');
155
158
  app.activation = await conn.bind('xdg_activation_v1');
159
+ app.exporter = await conn.bind('zxdg_exporter_v2');
156
160
  // The scale session first, so the outputs have a per-monitor map to
157
161
  // fill; then the outputs, awaited like the X11 backend's Xinerama tier:
158
162
  // an auto-sized window clamps to a monitor synchronously inside
@@ -304,6 +308,9 @@ export class WaylandApp extends EventEmitter {
304
308
  }
305
309
 
306
310
  _forgetWindow(win) {
311
+ // before the surface goes: the export is a handle *to* it
312
+ win._exported?.proxy.$.destroy();
313
+ win._exported = null;
307
314
  if (this.windows.get(win.wl.surface.id) === win)
308
315
  this.windows.delete(win.wl.surface.id);
309
316
  const i = this.toplevels.indexOf(win);
@@ -510,6 +517,37 @@ export class WaylandApp extends EventEmitter {
510
517
  return new WaylandOverlayPane(this, attributes);
511
518
  }
512
519
 
520
+ /**
521
+ * A handle another client can parent a window of its own to — the
522
+ * portal's `parent_window`, spelled `wayland:<handle>` there — or null.
523
+ *
524
+ * xdg-foreign's `export_toplevel`, made once per window on first ask and
525
+ * kept for its lifetime: the handle is valid for as long as the export
526
+ * object lives, and a file dialog opened twice from one window should name
527
+ * it the same way both times. Only an xdg_toplevel can be exported — a
528
+ * popup or a layer surface is a protocol error, so those answer null, as
529
+ * does a compositor without the protocol. The dialog then floats, which the
530
+ * portal allows; what it must never get is an `x11:` handle naming a
531
+ * Wayland window, which is what it got before this existed.
532
+ */
533
+ exportHandle(win) {
534
+ if (!this.exporter || !win?.wl?.surface || win.isPopup || win.isLayer) {
535
+ return Promise.resolve(null);
536
+ }
537
+ if (win._destroyed) return Promise.resolve(null);
538
+ if (!win._exported) {
539
+ const proxy = this.exporter.$.export_toplevel(win.wl.surface.id);
540
+ const handle = new Promise((resolve) => {
541
+ proxy.on('handle', (value) => resolve(value || null));
542
+ // the answer is one round trip away; a compositor that never sends
543
+ // it is a floating dialog, not a hung one
544
+ setTimeout(() => resolve(null), 1000).unref?.();
545
+ });
546
+ win._exported = { proxy, handle };
547
+ }
548
+ return win._exported.handle;
549
+ }
550
+
513
551
  /**
514
552
  * Ask the compositor to activate a window. Stacking is the compositor's;
515
553
  * xdg-activation lets a client ask with a token from a recent interaction,
@@ -542,6 +580,27 @@ export class WaylandApp extends EventEmitter {
542
580
 
543
581
  cancelAttention() {}
544
582
 
583
+ /**
584
+ * `useKeyboardState()`'s source on this backend: there is no XKB extension
585
+ * to ask, and the seat has the same answers from `wl_keyboard`
586
+ * (src/keyboardstate.js, `WaylandSeat.keyboardState`).
587
+ */
588
+ keyboardState() {
589
+ return this.seat?.keyboardState() ?? null;
590
+ }
591
+
592
+ /** Call `fn` when the locks, the group or the keymap change. */
593
+ watchKeyboardState(fn) {
594
+ const seat = this.seat;
595
+ if (!seat) return () => {};
596
+ seat.on('modifiers', fn);
597
+ seat.on('keymap', fn);
598
+ return () => {
599
+ seat.off('modifiers', fn);
600
+ seat.off('keymap', fn);
601
+ };
602
+ }
603
+
545
604
  async close() {
546
605
  for (const w of [...this.windows.values()]) w.destroy();
547
606
  this.windows.clear();
@@ -67,17 +67,22 @@ class Selection {
67
67
  /** our own source while we own the selection */
68
68
  this.source = null;
69
69
  this.ownData = null;
70
- this._pendingOffers = new Map(); // offer id -> types
70
+ this._pendingOffers = new Map(); // offer id -> { offer, types }
71
71
  /** `watch()` handlers, told when the selection changes hands */
72
72
  this.watchers = new Set();
73
73
 
74
74
  device.on('data_offer', (offer) => {
75
75
  const types = [];
76
- this._pendingOffers.set(offer.id, types);
76
+ this._pendingOffers.set(offer.id, { offer, types });
77
77
  offer.on('offer', (mime) => types.push(mime));
78
78
  });
79
- device.on('selection', (offer) => {
80
- const id = offer?.id ?? offer;
79
+ // The offer arrives as an id: an `object` argument is a number off the
80
+ // wire, where `data_offer`'s `new_id` was a proxy. The proxy is what a
81
+ // read calls `receive` on, so it is found by the id — keeping the number
82
+ // made every read throw, and nothing another application copied could
83
+ // be pasted here.
84
+ device.on('selection', (offerArg) => {
85
+ const id = offerArg?.id ?? offerArg ?? 0;
81
86
  if (this.offer && this.offer.id !== id) {
82
87
  try {
83
88
  this.offer.$.destroy();
@@ -85,15 +90,17 @@ class Selection {
85
90
  /* gone */
86
91
  }
87
92
  }
88
- if (!offer || offer === 0) {
93
+ const pending = id ? this._pendingOffers.get(id) : null;
94
+ this._pendingOffers.delete(id);
95
+ const offer = pending?.offer ?? (offerArg?.$ ? offerArg : null);
96
+ if (!offer) {
89
97
  this.offer = null;
90
98
  this.offerTypes = [];
91
99
  this._notify('closed');
92
100
  return;
93
101
  }
94
102
  this.offer = offer;
95
- this.offerTypes = this._pendingOffers.get(id) ?? [];
96
- this._pendingOffers.delete(id);
103
+ this.offerTypes = pending?.types ?? [];
97
104
  // an offer from our own source means we still own it; anyone else's
98
105
  // means we lost it
99
106
  if (this.source && !this._isOurs(offer)) this._dropSource();
@@ -65,6 +65,9 @@ export const PROTOCOLS = [
65
65
  'cursor-shape-v1',
66
66
  'tablet-v2',
67
67
  'xdg-activation-v1',
68
+ // a handle another client can parent a dialog to — the portal's
69
+ // `parent_window` (src/portal.js, `portalParentFor`)
70
+ 'xdg-foreign-unstable-v2',
68
71
  'xdg-decoration-unstable-v1',
69
72
  // the older KDE protocol that does the same job, for the compositors that
70
73
  // have only that one (ssd.js picks between them)