react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,683 @@
1
+ // The global menu: handing a window's menu bar to the desktop's panel.
2
+ //
3
+ // On a desktop that shows application menus in its own panel — Unity's
4
+ // heritage, KDE Plasma's Application Menu widget, `vala-panel-appmenu`,
5
+ // several GNOME extensions — an app that draws its own menu bar is drawing a
6
+ // second one. This module notices, exports the menu over
7
+ // `com.canonical.dbusmenu` (`dbusmenu.js`), and tells `MenuBar` to stop
8
+ // drawing. **Nothing is asked of the app author**: `<MenuBar menus={…}/>` is
9
+ // the whole of the integration, and it is the same line either way.
10
+ //
11
+ // ## Detection is "is a panel running", not "could one be started"
12
+ //
13
+ // This is the one place where the rule `hasService()` follows is exactly
14
+ // wrong, and the mistake is silent and total — an app with no menu at all.
15
+ //
16
+ // `hasService()` counts an **activatable** name as present, and it is right to:
17
+ // `org.freedesktop.portal.Desktop` ships a `.service` file, so on a healthy
18
+ // GNOME session where nothing has opened a file dialog yet the name has no
19
+ // owner, and a feature gated on ownership would take the fallback path
20
+ // forever. Activation is what makes it appear.
21
+ //
22
+ // The registrar is the mirror image. Ubuntu ships
23
+ // `com.canonical.AppMenu.Registrar` as an activatable service too, but a
24
+ // registrar is not the feature — it is a *directory* the panel reads. Nothing
25
+ // activates it except a panel starting up, and starting one ourselves would
26
+ // leave the menu registered with a registrar that no panel is reading. The bar
27
+ // would vanish from the window and appear nowhere else.
28
+ //
29
+ // So: **a live owner, and only a live owner.** Which is also why the answer is
30
+ // not cached — a panel is exactly the kind of thing that gets restarted, and
31
+ // `NameOwnerChanged` moves the menu back into the window and out again with
32
+ // it.
33
+ //
34
+ // ## Why the KDE properties are set *as well*, not instead
35
+ //
36
+ // Plasma's applet reads `_KDE_NET_WM_APPMENU_SERVICE_NAME` and
37
+ // `_KDE_NET_WM_APPMENU_OBJECT_PATH` off the window rather than asking a
38
+ // registrar. They are not an alternative route for desktops without a
39
+ // registrar: Plasma runs one *and* reads the properties, and which of the two
40
+ // a given version prefers is not something to bet a menu on. They cost two
41
+ // `ChangeProperty` round trips.
42
+ //
43
+ // ## Where it deliberately does nothing
44
+ //
45
+ // Stock GNOME has no global menu bar, so nothing owns the registrar name,
46
+ // `exported` stays false and the in-window bar stays. That is not a
47
+ // degradation and it needs no code — but it does need saying, or
48
+ // "my global menu doesn't show under GNOME" is the first issue filed.
49
+ //
50
+ // We also do **not** export `org.gtk.Menus`/`org.gtk.Actions`. Handling both
51
+ // serialisations is right for a *host* and wrong for an app: traffic flows
52
+ // towards dbusmenu, not away from it — Plasma ships `gmenu-dbusmenu-proxy` to
53
+ // convert GTK's menus *into* dbusmenu, and the GNOME extensions that render
54
+ // third-party menus consume dbusmenu. A second exporter is roughly double the
55
+ // work for no consumer that the first one does not already reach.
56
+
57
+ import { useEffect, useRef, useState } from 'react';
58
+
59
+ import { loadTransport, sessionBus } from './bus.js';
60
+ import {
61
+ DBUSMENU_IFACE,
62
+ PROPERTY_TYPES,
63
+ diffSnapshots,
64
+ groupProperties,
65
+ layoutOf,
66
+ snapshot,
67
+ IdAllocator,
68
+ } from './dbusmenu.js';
69
+ import { useTopLevelWindow, windowIdOf } from './windowid.js';
70
+
71
+ export const REGISTRAR_NAME = 'com.canonical.AppMenu.Registrar';
72
+ export const REGISTRAR_PATH = '/com/canonical/AppMenu/Registrar';
73
+
74
+ const KDE_SERVICE_PROPERTY = '_KDE_NET_WM_APPMENU_SERVICE_NAME';
75
+ const KDE_PATH_PROPERTY = '_KDE_NET_WM_APPMENU_OBJECT_PATH';
76
+
77
+ /** dbusmenu's recursive layout struct: `(id, properties, children)`. */
78
+ const LAYOUT_SIGNATURE = '(ia{sv}av)';
79
+
80
+ /**
81
+ * How long the registrar gets to answer.
82
+ *
83
+ * Short, and explicit, because dbus-native's default is 25 seconds and the
84
+ * reply timer is **not** unref'd: a panel that dies between our `ListNames`
85
+ * and our `RegisterWindow` — or one whose name we still hold when the window
86
+ * closes — would otherwise hold the event loop open long enough that an app
87
+ * quitting looks hung. There is nothing useful to do with a slow answer here
88
+ * either; the bar is drawn in the meantime and stays drawn.
89
+ */
90
+ const REGISTRAR_TIMEOUT = 4000;
91
+
92
+ /**
93
+ * `NO_AUTO_START`, D-Bus's own message flag, on every call we make to the
94
+ * registrar.
95
+ *
96
+ * Without it this feature starts the very thing it is careful never to start.
97
+ * `com.canonical.AppMenu.Registrar` is activatable on Ubuntu, and a method
98
+ * call to an unowned activatable name **launches the service** — so a panel
99
+ * that exits while a window is registered turns our tidy-up `UnregisterWindow`
100
+ * into `exec /usr/libexec/vala-panel/appmenu-registrar`. The name then has an
101
+ * owner again, every app on the session hands its menu over, and nothing is
102
+ * drawing any of them.
103
+ *
104
+ * Observed, not theorised: killing the host in `scripts/globalmenu-host.mjs`
105
+ * spawned Ubuntu's registrar from exactly this call.
106
+ *
107
+ * The liveness check below is the first line of defence and this is the
108
+ * second, because between the two there is a window in which the panel can
109
+ * die. A call that fails because nothing is there is the outcome we want:
110
+ * `exported` stays false and the menu stays in the window.
111
+ */
112
+ const NO_AUTO_START = 2;
113
+
114
+ /**
115
+ * D-Bus object paths are `[A-Za-z0-9_/]`, so the package's own name cannot
116
+ * appear in one — `react-x11` has a hyphen. Hence the underscore.
117
+ */
118
+ const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
119
+
120
+ /**
121
+ * The feature's off switch.
122
+ *
123
+ * An embedder that owns the toplevel, or an app that exports its own menu on
124
+ * its own terms, needs the renderer to keep its hands off. `MenuBar` also
125
+ * takes `globalMenu={false}` for one bar; this is the process-wide form, and
126
+ * it is the one that works without touching application code.
127
+ */
128
+ function globalMenuEnabled() {
129
+ const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
130
+ return !flag || flag === '0';
131
+ }
132
+
133
+ /** Is a panel listening *right now*? See the header for why not `hasService`. */
134
+ async function registrarIsLive(bus) {
135
+ try {
136
+ const names = await bus.listNames();
137
+ return names.includes(REGISTRAR_NAME);
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /** The ntk window behind whatever `useTopLevelWindow()`/a ref is holding. */
144
+ function ntkWindowOf(target) {
145
+ const node = target && 'current' in target ? target.current : target;
146
+ if (!node) return null;
147
+ if (typeof node.setProperty === 'function') return node;
148
+ return node.window ?? node.root?.window ?? null;
149
+ }
150
+
151
+ /**
152
+ * One window's menu on the bus: the exported object, the registration, and the
153
+ * two X properties.
154
+ *
155
+ * Imperative and framework-free on purpose — `useGlobalMenu` is a thin lid on
156
+ * it, and the parts that are awkward (a panel restarting, a window closing
157
+ * mid-call, no bus at all) are awkward in ways React has nothing to say about.
158
+ */
159
+ export class GlobalMenuExport {
160
+ constructor({ getMenus, onSelect, onAboutToShow, target, onChange }) {
161
+ this.getMenus = getMenus;
162
+ this.onSelect = onSelect;
163
+ this.onAboutToShow = onAboutToShow;
164
+ this.target = target;
165
+ this.onChange = onChange ?? (() => {});
166
+
167
+ this.alloc = new IdAllocator();
168
+ this.nodes = snapshot(getMenus(), this.alloc);
169
+ this.revision = 1;
170
+
171
+ this.stopped = false;
172
+ this.exported = false;
173
+ /** The in-flight `sync()`, which the next one queues behind. */
174
+ this.syncing = null;
175
+ this.ref = null;
176
+ this.registration = null;
177
+ this.subscription = null;
178
+ this.iface = null;
179
+ this.path = null;
180
+ this.xid = null;
181
+ this.window = null;
182
+ }
183
+
184
+ // ------------------------------------------------------------------ setup
185
+
186
+ async start() {
187
+ if (!globalMenuEnabled()) return;
188
+ const ref = await sessionBus();
189
+ // No bus is a first-class configuration, not a degraded one: ssh, a bare
190
+ // startx, CI, Node 20 without the transport. The bar draws itself and
191
+ // nothing is logged.
192
+ if (!ref) return;
193
+ if (this.stopped) return void ref.release();
194
+ this.ref = ref;
195
+
196
+ try {
197
+ await this.watchRegistrar();
198
+ await this.sync();
199
+ } catch {
200
+ // A desktop that answers the bus but not this protocol is not an error
201
+ // condition for an app that has a menu bar it can draw itself.
202
+ await this.teardown();
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Follow the registrar's ownership for as long as this menu exists.
208
+ *
209
+ * `arg0` narrows the match to the one name, so the daemon does not wake this
210
+ * process for every service on the session appearing and disappearing.
211
+ */
212
+ async watchRegistrar() {
213
+ const { bus } = this.ref;
214
+ const subscription = await bus.watch(
215
+ "type='signal',sender='org.freedesktop.DBus'," +
216
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
217
+ `arg0='${REGISTRAR_NAME}'`,
218
+ );
219
+ // `AddMatch` is a round trip, and a window that opens and closes inside
220
+ // one — an unmount, a StrictMode remount — leaves `teardown()` already
221
+ // finished by the time it lands. Installing the rule and the listener now
222
+ // would leave both behind for good, since `stop()` is idempotent and has
223
+ // nothing left to run: the daemon would keep waking the process, and the
224
+ // listener would keep this exporter, its snapshot and every item's
225
+ // `onSelect` closure alive for the life of it.
226
+ if (this.stopped) {
227
+ await subscription.remove().catch(() => {});
228
+ return;
229
+ }
230
+ this.subscription = subscription;
231
+ const key = bus.mangle(
232
+ '/org/freedesktop/DBus',
233
+ 'org.freedesktop.DBus',
234
+ 'NameOwnerChanged',
235
+ );
236
+ this.onOwnerChanged = () => {
237
+ if (!this.stopped) this.sync().catch(() => {});
238
+ };
239
+ bus.signals.on(key, this.onOwnerChanged);
240
+ }
241
+
242
+ /**
243
+ * Bring the export into line with whether a panel is currently listening,
244
+ * **one at a time**.
245
+ *
246
+ * `exported` only turns true at the *end* of `publish()`, which is several
247
+ * awaits long — the transport import, the export, the registrar round trip.
248
+ * A panel that restarts quickly, or any two `NameOwnerChanged` signals close
249
+ * together, would otherwise put two `publish()` runs in flight against the
250
+ * same window: two `RegisterWindow` calls, and the first `iface` and
251
+ * registration orphaned by the second. `withdraw()` has the mirror version
252
+ * of it. Serialising is the whole fix, and it costs a promise.
253
+ */
254
+ sync() {
255
+ // Two promises, deliberately. `syncing` is the queue and must never
256
+ // reject, or one failed sync would poison every later one; the returned
257
+ // promise is the caller's and must, or `start()` cannot tell that
258
+ // publishing failed and would leave a bus ref, a match rule and an orphan
259
+ // `com.canonical.dbusmenu` object behind for a registration that did not
260
+ // happen. The signal handler discards its own.
261
+ const done = (this.syncing ?? Promise.resolve()).then(
262
+ () => this._sync(),
263
+ () => this._sync(),
264
+ );
265
+ this.syncing = done.catch(() => {});
266
+ return done;
267
+ }
268
+
269
+ async _sync() {
270
+ if (this.stopped || !this.ref) return;
271
+ const live = await registrarIsLive(this.ref.bus);
272
+ if (this.stopped) return;
273
+ if (live && !this.exported) await this.publish();
274
+ else if (!live && this.exported) await this.withdraw();
275
+ }
276
+
277
+ async publish() {
278
+ const { bus } = this.ref;
279
+ // Resolved **once**, and both answers taken from the same read.
280
+ // `useTopLevelWindow()` is a live getter — it recomputes from the app's
281
+ // window list and the X focus every time — so asking again after the
282
+ // registrar round trip can name a different window than the xid just
283
+ // registered, and the KDE properties would land on the wrong one.
284
+ const node =
285
+ this.target && 'current' in this.target
286
+ ? this.target.current
287
+ : this.target;
288
+ const xid = windowIdOf(node);
289
+ const wnd = ntkWindowOf(node);
290
+ // A window that is not realized yet has no id to register. `sync()` runs
291
+ // again on the next ownership change, and the effect that owns this runs
292
+ // after the commit that creates the window, so this is the "closed mid
293
+ // call" case rather than the "too early" one.
294
+ if (!xid) return;
295
+
296
+ const dbus = await loadTransport();
297
+ const path = menuPathFor(xid);
298
+ if (!dbus.isValidObjectPath(path)) return;
299
+
300
+ this.iface = this.defineMenu(dbus);
301
+ this.registration = await bus.export(path, this.iface);
302
+ this.path = path;
303
+ this.xid = xid;
304
+
305
+ // `exported` becomes true only once the registrar has *answered*, so the
306
+ // in-window bar disappears on evidence rather than on a guess. A registrar
307
+ // that refuses the call — or one that never answers — leaves the menu
308
+ // drawn where the user can reach it.
309
+ await bus.invoke(
310
+ {
311
+ destination: REGISTRAR_NAME,
312
+ path: REGISTRAR_PATH,
313
+ interface: REGISTRAR_NAME,
314
+ member: 'RegisterWindow',
315
+ signature: 'uo',
316
+ body: [xid, path],
317
+ flags: NO_AUTO_START,
318
+ },
319
+ { timeout: REGISTRAR_TIMEOUT },
320
+ );
321
+
322
+ // The window can close, or the panel exit, while that call is in flight.
323
+ // Bailing *before* the properties are written is what keeps a torn-down
324
+ // window from being left advertising an object path that is about to be
325
+ // unexported — Plasma prefers those properties to the registrar, so it
326
+ // would show that window an empty menu for as long as it lives.
327
+ if (this.stopped) return void (await this.unpublish());
328
+
329
+ await this.setWindowProperties(wnd, bus.name, path);
330
+ if (this.stopped) return void (await this.unpublish());
331
+ this.exported = true;
332
+ this.onChange(true);
333
+ }
334
+
335
+ /**
336
+ * Plasma reads these instead of asking the registrar.
337
+ *
338
+ * Written as `STRING`/format 8 explicitly: ntk defaults a string property to
339
+ * `UTF8_STRING`, and KDE writes and expects `STRING`. A bus name and an
340
+ * object path are both ASCII by construction, so nothing is lost.
341
+ *
342
+ * The window is **remembered**, not looked up again when it is time to
343
+ * clear: `useTopLevelWindow()` answers from the tree's live window list, and
344
+ * by the time an unmount runs this module's cleanup the window it is asking
345
+ * about is already off it. Properties are cleared on the window they were
346
+ * set on or not at all.
347
+ */
348
+ async setWindowProperties(wnd, serviceName, path) {
349
+ if (!wnd?.setProperty) return;
350
+ this.window = wnd;
351
+ const opts = { type: 'STRING', format: 8 };
352
+ await wnd.setProperty(KDE_SERVICE_PROPERTY, serviceName, opts);
353
+ await wnd.setProperty(KDE_PATH_PROPERTY, path, opts);
354
+ }
355
+
356
+ async clearWindowProperties() {
357
+ const wnd = this.window;
358
+ this.window = null;
359
+ if (!wnd?.deleteProperty) return;
360
+ // A window on its way out takes its properties with it, and asking the
361
+ // server about a dead one is an X error rather than an exception.
362
+ await wnd.deleteProperty(KDE_SERVICE_PROPERTY).catch(() => {});
363
+ await wnd.deleteProperty(KDE_PATH_PROPERTY).catch(() => {});
364
+ }
365
+
366
+ /** The panel went away: take the menu back into the window. */
367
+ async withdraw() {
368
+ this.exported = false;
369
+ this.onChange(false);
370
+ await this.unpublish();
371
+ }
372
+
373
+ async unpublish() {
374
+ const bus = this.ref?.bus;
375
+ // Ask only a registrar that is still there. Half the time the reason we
376
+ // are unpublishing is that it went away, and a call to a name nobody owns
377
+ // would start one — see NO_AUTO_START.
378
+ if (bus && this.xid != null && (await registrarIsLive(bus))) {
379
+ await bus
380
+ .invoke(
381
+ {
382
+ destination: REGISTRAR_NAME,
383
+ path: REGISTRAR_PATH,
384
+ interface: REGISTRAR_NAME,
385
+ member: 'UnregisterWindow',
386
+ signature: 'u',
387
+ body: [this.xid],
388
+ flags: NO_AUTO_START,
389
+ },
390
+ { timeout: REGISTRAR_TIMEOUT },
391
+ )
392
+ .catch(() => {});
393
+ }
394
+ await this.clearWindowProperties();
395
+ await this.registration?.remove().catch(() => {});
396
+ this.registration = null;
397
+ this.iface = null;
398
+ this.path = null;
399
+ this.xid = null;
400
+ }
401
+
402
+ async stop() {
403
+ if (this.stopped) return;
404
+ // Set first, so a publish already in flight sees it and undoes itself at
405
+ // its next checkpoint rather than finishing.
406
+ this.stopped = true;
407
+ // Then wait for it. `teardown()` reads `registration`, `xid` and `window`
408
+ // across several awaits of its own, so running it *beside* a publish lets
409
+ // each undo the other's work: the classic end state is `exported === true`
410
+ // with the object unexported and the window unregistered — no bar in the
411
+ // window, nothing in the panel, and no event left that could recover it.
412
+ await this.syncing?.catch(() => {});
413
+ await this.teardown();
414
+ }
415
+
416
+ async teardown() {
417
+ if (this.exported) {
418
+ this.exported = false;
419
+ this.onChange(false);
420
+ }
421
+ await this.unpublish();
422
+ if (this.subscription) {
423
+ const key = this.ref?.bus.mangle(
424
+ '/org/freedesktop/DBus',
425
+ 'org.freedesktop.DBus',
426
+ 'NameOwnerChanged',
427
+ );
428
+ if (key && this.onOwnerChanged) {
429
+ this.ref.bus.signals.removeListener(key, this.onOwnerChanged);
430
+ }
431
+ await this.subscription.remove().catch(() => {});
432
+ this.subscription = null;
433
+ }
434
+ // Dropped as well as removed: it closes over this exporter, so leaving it
435
+ // on the instance keeps the snapshot and every item's handler reachable.
436
+ this.onOwnerChanged = undefined;
437
+ await this.ref?.release();
438
+ this.ref = null;
439
+ }
440
+
441
+ // ----------------------------------------------------------------- update
442
+
443
+ /**
444
+ * A new `menus` array. Serialise, diff, and send the *narrower* of the two
445
+ * signals the protocol has — see `diffSnapshots`, where the choice is made
446
+ * and why it matters.
447
+ */
448
+ update(menus) {
449
+ const next = snapshot(menus, this.alloc);
450
+ const change = diffSnapshots(this.nodes, next);
451
+ this.nodes = next;
452
+ if (!this.exported || change.kind === 'none') return;
453
+
454
+ if (change.kind === 'properties') {
455
+ this.iface?.emit.ItemsPropertiesUpdated(
456
+ change.updated.map(([id, props]) => [id, this.wrap(props)]),
457
+ change.removed,
458
+ );
459
+ return;
460
+ }
461
+ this.revision += 1;
462
+ this.iface?.emit.LayoutUpdated(this.revision, change.parent);
463
+ }
464
+
465
+ /** Plain property values → the `a{sv}` the wire wants. */
466
+ wrap(props) {
467
+ const out = {};
468
+ for (const [name, value] of Object.entries(props)) {
469
+ const type = PROPERTY_TYPES[name];
470
+ if (type) out[name] = new this.dbus.Variant(type, value);
471
+ }
472
+ return out;
473
+ }
474
+
475
+ /** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
476
+ wrapLayout(node) {
477
+ const [id, props, children] = node;
478
+ return [
479
+ id,
480
+ this.wrap(props),
481
+ children.map(
482
+ (child) =>
483
+ new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
484
+ ),
485
+ ];
486
+ }
487
+
488
+ // --------------------------------------------------------------- protocol
489
+
490
+ itemFor(id) {
491
+ return this.nodes.get(id)?.item ?? null;
492
+ }
493
+
494
+ defineMenu(dbus) {
495
+ this.dbus = dbus;
496
+ const fire = (id, eventId) => {
497
+ const item = this.itemFor(id);
498
+ if (!item) return;
499
+ if (eventId === 'clicked') this.onSelect?.(item);
500
+ else if (eventId === 'opened') this.onAboutToShow?.(item);
501
+ };
502
+
503
+ return dbus.defineInterface({
504
+ name: DBUSMENU_IFACE,
505
+ methods: {
506
+ GetLayout: {
507
+ in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
508
+ out: { revision: 'u', layout: LAYOUT_SIGNATURE },
509
+ handler: ({ parentId, recursionDepth, propertyNames }) => {
510
+ const tree = layoutOf(
511
+ this.nodes,
512
+ parentId,
513
+ recursionDepth ?? -1,
514
+ propertyNames,
515
+ );
516
+ // An id the shell remembers from before a structural change is the
517
+ // normal way to arrive here, not a protocol violation: answer with
518
+ // an empty item rather than an error, and let the LayoutUpdated it
519
+ // has already been sent bring it back for the real tree.
520
+ return {
521
+ revision: this.revision,
522
+ layout: this.wrapLayout(tree ?? [parentId, {}, []]),
523
+ };
524
+ },
525
+ },
526
+ GetGroupProperties: {
527
+ in: { ids: 'ai', propertyNames: 'as' },
528
+ out: { properties: 'a(ia{sv})' },
529
+ handler: ({ ids, propertyNames }) =>
530
+ groupProperties(this.nodes, ids, propertyNames).map(
531
+ ([id, props]) => [id, this.wrap(props)],
532
+ ),
533
+ },
534
+ GetProperty: {
535
+ in: { id: 'i', name: 's' },
536
+ out: { value: 'v' },
537
+ handler: ({ id, name }) => {
538
+ const props = this.nodes.get(id)?.props;
539
+ // `hasOwn` on both, so a name like `constructor` reads as absent
540
+ // rather than as a function that then fails to marshal.
541
+ const known = Object.hasOwn(PROPERTY_TYPES, name);
542
+ const value =
543
+ props && Object.hasOwn(props, name) ? props[name] : undefined;
544
+ // A property this item does not carry is at its default, and the
545
+ // spec's own advice is to answer with it rather than to error.
546
+ if (value === undefined || !known) return new dbus.Variant('s', '');
547
+ return new dbus.Variant(PROPERTY_TYPES[name], value);
548
+ },
549
+ },
550
+ Event: {
551
+ in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
552
+ out: {},
553
+ handler: ({ id, eventId }) => fire(id, eventId),
554
+ },
555
+ EventGroup: {
556
+ in: { events: 'a(isvu)' },
557
+ out: { idErrors: 'ai' },
558
+ handler: ({ events }) => {
559
+ const errors = [];
560
+ for (const [id, eventId] of events ?? []) {
561
+ if (this.itemFor(id)) fire(id, eventId);
562
+ else errors.push(id);
563
+ }
564
+ return errors;
565
+ },
566
+ },
567
+ AboutToShow: {
568
+ in: { id: 'i' },
569
+ out: { needUpdate: 'b' },
570
+ handler: ({ id }) => this.aboutToShow(id),
571
+ },
572
+ AboutToShowGroup: {
573
+ in: { ids: 'ai' },
574
+ out: { updatesNeeded: 'ai', idErrors: 'ai' },
575
+ handler: ({ ids }) => {
576
+ const errors = [];
577
+ for (const id of ids ?? []) {
578
+ if (this.itemFor(id)) this.aboutToShow(id);
579
+ else errors.push(id);
580
+ }
581
+ return { updatesNeeded: [], idErrors: errors };
582
+ },
583
+ },
584
+ },
585
+ properties: {
586
+ Version: { type: 'u', access: 'read', get: () => 3 },
587
+ Status: { type: 's', access: 'read', get: () => 'normal' },
588
+ TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
589
+ // Empty, and not a placeholder: `icon-name` is looked up in the
590
+ // desktop's own theme, which is where an app's icons should come from.
591
+ // A path here would be for icons shipped beside the app.
592
+ IconThemePath: { type: 'as', access: 'read', get: () => [] },
593
+ },
594
+ signals: {
595
+ LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
596
+ ItemsPropertiesUpdated: {
597
+ args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
598
+ },
599
+ ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
600
+ },
601
+ });
602
+ }
603
+
604
+ /**
605
+ * The reply has to go out **now**, and React has not rendered yet.
606
+ *
607
+ * `setState` from here does not produce a new tree before this function
608
+ * returns, so there is no honest way to answer `true` — and answering `true`
609
+ * dishonestly is worse than answering `false`: the shell then blocks on a
610
+ * `GetLayout` for a subtree that has not been built, gets the old one, and
611
+ * caches it.
612
+ *
613
+ * So: answer `false`, let the handler run, and let the ordinary update path
614
+ * emit `LayoutUpdated` when the new items actually serialise. Shells listen
615
+ * for that unconditionally — it is the same signal a menu changing while
616
+ * open produces — so a lazily-built submenu still fills in, one round trip
617
+ * later than a synchronous toolkit would manage.
618
+ */
619
+ aboutToShow(id) {
620
+ const item = this.itemFor(id);
621
+ if (item) this.onAboutToShow?.(item);
622
+ return false;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Is this window's menu bar being drawn by the desktop?
628
+ *
629
+ * `false` until proven otherwise, and proof is the registrar answering
630
+ * `RegisterWindow` — so a machine with no bus, no panel, or a panel that
631
+ * refuses the registration all render the menu where the app put it.
632
+ *
633
+ * ```jsx
634
+ * const exported = useGlobalMenu(menus, { onSelect });
635
+ * if (exported) return null; // the panel has it
636
+ * ```
637
+ *
638
+ * `MenuBar` calls this itself, which is why an app needs no code for any of
639
+ * it. It is exported for a component that draws its own bar and wants the
640
+ * same behaviour.
641
+ */
642
+ export function useGlobalMenu(
643
+ menus,
644
+ { onSelect, onAboutToShow, enabled = true } = {},
645
+ ) {
646
+ const target = useTopLevelWindow();
647
+ const [exported, setExported] = useState(false);
648
+ const exportRef = useRef(null);
649
+
650
+ // The handlers a D-Bus reply reaches for are read at call time, not captured
651
+ // at export time: a click on the panel three minutes from now must run the
652
+ // handler from the *current* render, not the one that happened to be current
653
+ // when the registrar answered.
654
+ const live = useRef({ menus, onSelect, onAboutToShow });
655
+ live.current = { menus, onSelect, onAboutToShow };
656
+
657
+ useEffect(() => {
658
+ if (!enabled) return undefined;
659
+ const owner = new GlobalMenuExport({
660
+ getMenus: () => live.current.menus ?? [],
661
+ onSelect: (item) => {
662
+ item.onSelect?.(item);
663
+ live.current.onSelect?.(item);
664
+ },
665
+ onAboutToShow: (item) => live.current.onAboutToShow?.(item),
666
+ target,
667
+ onChange: setExported,
668
+ });
669
+ exportRef.current = owner;
670
+ owner.start().catch(() => {});
671
+ return () => {
672
+ exportRef.current = null;
673
+ setExported(false);
674
+ owner.stop().catch(() => {});
675
+ };
676
+ }, [enabled, target]);
677
+
678
+ useEffect(() => {
679
+ exportRef.current?.update(menus ?? []);
680
+ }, [menus]);
681
+
682
+ return enabled && exported;
683
+ }