react-x11 2.15.2 → 2.16.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 (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -0,0 +1,752 @@
1
+ // `org.kde.StatusNotifierItem` — the freedesktop tray, from the app's side.
2
+ //
3
+ // The Linux rung of `useTray()` (react-x11#353). The other rung is the cocoa
4
+ // backend's `NSStatusItem`; `trayhooks.js` is the ladder and this is the
5
+ // climb it could not make until now.
6
+ //
7
+ // ## Why this and not XEmbed
8
+ //
9
+ // The old tray — `_NET_SYSTEM_TRAY_S<n>`, a real X window reparented into the
10
+ // panel — is what `@react-x11/components`' `<TrayHost>` *hosts*. It is not
11
+ // what an app should *speak* any more: GNOME removed the XEmbed tray in 3.26,
12
+ // Plasma treats it as legacy, and on Wayland there is no window to hand over
13
+ // at all. StatusNotifierItem is D-Bus only, so it works identically under X11,
14
+ // XWayland and Wayland — which is the whole reason the tray is the one desktop
15
+ // feature that gets *simpler* as the display server gets stricter.
16
+ //
17
+ // ## The registration is sender-attributed, on purpose
18
+ //
19
+ // `RegisterStatusNotifierItem` takes one string, and hosts read it two ways:
20
+ // KDE apps pass a **bus name**, Ayatana-patched GNOME apps pass an **object
21
+ // path**. Every host in the wild handles both (gnome-shell's appindicator
22
+ // extension has a comment about it that is funnier than this one).
23
+ //
24
+ // We pass the **path**, for two reasons that both matter:
25
+ //
26
+ // - **No extra name.** The bus is shared — `sessionBus()` hands every
27
+ // consumer the same socket and the same unique name, so the app's tray,
28
+ // its menu and its exported service are visibly one application. The
29
+ // bus-name form would need `org.kde.StatusNotifierItem-<pid>-<n>`
30
+ // requested on top, which is a second identity for no gain.
31
+ // - **Several items coexist.** `useTray()` promises that each mount is its
32
+ // own item. Paths are per-item (`/StatusNotifierItem/1`, `/2`, …); a
33
+ // well-known name is per-process, so the name form caps an app at one
34
+ // tray icon.
35
+ //
36
+ // ## What the desktop cannot tell us, and what we say instead
37
+ //
38
+ // The protocol carries far less about a click than AppKit does. There is no
39
+ // click count, no modifier state, and no item rectangle — `Activate(x, y)`
40
+ // gives a position and nothing else. Those fields are reported as
41
+ // `0`/`false` rather than guessed at, and `docs/desktop.md` says so, because a
42
+ // tray menu that only opens on shift-click is an app built on a field this
43
+ // rung cannot fill. The menu is the portable interaction; `onClick` is the
44
+ // one that degrades.
45
+ //
46
+ // ## The position has no unit
47
+ //
48
+ // The spec says "screen coordinates" and stops, which on a scaled display is
49
+ // two different numbers — and the hosts split between them:
50
+ //
51
+ // - **X root coordinates, device pixels.** Plasma since 5.27, on purpose:
52
+ // its xembed-sni-proxy synthesises X clicks from them. GNOME's
53
+ // AppIndicator extension passes its stage coordinates through, which are
54
+ // device pixels on X11 and in Wayland's physical layout, the default up
55
+ // to GNOME 49.
56
+ // - **The host's own logical pixels.** xfce4-panel, Budgie and LXQt pass
57
+ // their toolkit's root position through, and Cinnamon sends the icon's
58
+ // corner over its UI scale. GNOME's stage is in logical pixels from 50,
59
+ // and before it on Fedora and Debian 13, which switch the layout on
60
+ // downstream. So did Plasma before 5.27.
61
+ // - **Not a screen position.** MATE sends the item's corner in its applet's
62
+ // own window, and snixembed's `Activate` is always `(0, 0)`.
63
+ //
64
+ // Dividing by the scale is right for the first group and halves every click
65
+ // in the second; passing the numbers through is the opposite. So a click is
66
+ // read both ways and the screen picks (`clickReadings`, `clickPoint`):
67
+ //
68
+ // 1. **A reading no monitor holds is not one.** A tray is on the screen,
69
+ // and a device-pixel host's item at the right or the bottom of it —
70
+ // where panels put the tray — is off the screen when read as logical
71
+ // pixels. The common device-pixel click costs nothing more.
72
+ // 2. **Then the pointer.** A host that sends a position sends the
73
+ // pointer's or its icon's, so one `QueryPointer` names the reading the
74
+ // click was at.
75
+ // 3. **Otherwise the numbers as sent.** The pointer is somewhere else
76
+ // because the click was a key, or the connection is XWayland's, which
77
+ // is told nothing of a pointer over a Wayland panel. As sent is what the
78
+ // second group needs, and the first group's usual trays were settled at
79
+ // step 1.
80
+ //
81
+ // What that leaves wrong is a device-pixel host under XWayland whose tray
82
+ // still lands on a monitor read as logical pixels: in the top-left quarter
83
+ // of the screen, or on a desk where it falls on another monitor.
84
+
85
+ import { loadTransport, sessionBus } from './bus.js';
86
+ import { DbusMenuExport } from './dbusmenuexport.js';
87
+ import { scaleOf } from './scale.js';
88
+ import { screensSnapshot } from './screens.js';
89
+
90
+ export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
91
+ export const WATCHER_PATH = '/StatusNotifierWatcher';
92
+ export const WATCHER_IFACE = 'org.kde.StatusNotifierWatcher';
93
+ export const ITEM_IFACE = 'org.kde.StatusNotifierItem';
94
+
95
+ /** dbusmenu's own struct, repeated here so the tooltip signature reads. */
96
+ const PIXMAP_SIGNATURE = 'a(iiay)';
97
+
98
+ /** One process, many trays: the path counter behind `/StatusNotifierItem/<n>`.
99
+ *
100
+ * A **slot**, not a mount counter. A hook that is toggled off and on again is
101
+ * the *same* tray icon and must come back on the same path — see `stop()`. */
102
+ let nextItemIndex = 1;
103
+
104
+ /** Claim a tray slot. `useTray()` takes one per hook instance, for its life. */
105
+ export function allocateItemSlot() {
106
+ return nextItemIndex++;
107
+ }
108
+
109
+ /**
110
+ * Straight RGBA pixels → the ARGB32 pixmap array the spec wants.
111
+ *
112
+ * Pure, and exported for the test: the byte order is the single most
113
+ * get-wrong-able thing in this file. The spec says ARGB32 in **network byte
114
+ * order**, i.e. big-endian, so a pixel is the bytes `A R G B` in that order —
115
+ * *not* the little-endian `B G R A` that a Cairo/`getImageData` buffer holds
116
+ * when read as a 32-bit word. Getting it backwards produces an icon that is
117
+ * recognisably the right shape in the wrong colours, which is why it is worth
118
+ * a test rather than a squint.
119
+ */
120
+ export function toPixmapArray(image) {
121
+ if (!image) return [];
122
+ const { width, height, data } = image;
123
+ if (!width || !height || !data) return [];
124
+ const out = Buffer.alloc(width * height * 4);
125
+ for (let i = 0; i < width * height; i += 1) {
126
+ const s = i * 4;
127
+ out[s] = data[s + 3]; // A
128
+ out[s + 1] = data[s]; // R
129
+ out[s + 2] = data[s + 1]; // G
130
+ out[s + 3] = data[s + 2]; // B
131
+ }
132
+ return [[width, height, out]];
133
+ }
134
+
135
+ /**
136
+ * How far from the pointer a click's point may be and still be the click, in
137
+ * logical pixels. A host that sends the pointer is within a pixel or two of
138
+ * it, and Cinnamon, which sends its icon's corner, within the icon. A point
139
+ * further off is taken as sent — which for Cinnamon, a logical-pixel host,
140
+ * is right anyway.
141
+ */
142
+ const CLICK_REACH = 64;
143
+
144
+ /**
145
+ * A host's `(x, y)` read both ways — as X root coordinates, and as the host's
146
+ * own logical pixels — keeping the readings some monitor holds. Each is the
147
+ * point on the screen it names, in device pixels, and what it means in
148
+ * logical ones. See "The position has no unit" in the header.
149
+ *
150
+ * `screens` are device-pixel rects, as `screensSnapshot(app).screens` holds
151
+ * them; with none known both readings stand, and with neither on a monitor
152
+ * the host sent something that is not a position.
153
+ */
154
+ function clickReadings(x, y, scale, screens = []) {
155
+ const readings = [
156
+ { device: { x, y }, logical: { x: x / scale, y: y / scale } },
157
+ { device: { x: x * scale, y: y * scale }, logical: { x, y } },
158
+ ];
159
+ if (!screens.length) return readings;
160
+ // Edges count: a host that sends an icon's far corner can land on one.
161
+ return readings.filter(({ device: p }) =>
162
+ screens.some(
163
+ (m) =>
164
+ p.x >= m.x &&
165
+ p.y >= m.y &&
166
+ p.x <= m.x + m.width &&
167
+ p.y <= m.y + m.height,
168
+ ),
169
+ );
170
+ }
171
+
172
+ /** `visible: false` is `Passive`, which is how the spec spells "hidden". */
173
+ function statusOf(options) {
174
+ if (options?.visible === false) return 'Passive';
175
+ return options?.attention ? 'NeedsAttention' : 'Active';
176
+ }
177
+
178
+ /**
179
+ * The icon fields, resolved once per update.
180
+ *
181
+ * A string is a **themed icon name**, which is what the desktop wants and
182
+ * what scales: the panel picks the size and the theme picks light or dark.
183
+ * Bytes are decoded and sent as a pixmap — correct, but a fixed size, so the
184
+ * name is the better answer wherever the app can ship an icon in a theme.
185
+ * (On the cocoa rung the same string is an SF Symbol name. One field, two
186
+ * vocabularies, and no app has to branch — which is the point.)
187
+ */
188
+ function iconOf(icon, decode) {
189
+ if (typeof icon === 'string' && icon) return { name: icon, pixmap: [] };
190
+ if (icon && typeof icon === 'object') {
191
+ try {
192
+ return { name: '', pixmap: toPixmapArray(decode(icon)) };
193
+ } catch {
194
+ // Corrupt bytes are a content failure, not a reason to have no tray.
195
+ return { name: '', pixmap: [] };
196
+ }
197
+ }
198
+ return { name: '', pixmap: [] };
199
+ }
200
+
201
+ /**
202
+ * One tray icon on the session bus, for as long as it is started.
203
+ *
204
+ * Shaped like `GlobalMenuExport`, and for the same reasons: the watcher is a
205
+ * thing that restarts, so ownership is followed rather than sampled, and
206
+ * publish/withdraw are serialised behind one promise so two ownership changes
207
+ * in quick succession cannot put two registrations in flight.
208
+ */
209
+ export class StatusNotifierItem {
210
+ constructor({ getOptions, app, appId, decodeIcon, onError, slot } = {}) {
211
+ this.getOptions = getOptions ?? (() => null);
212
+ // The display the tray is on — not for the protocol, which is D-Bus
213
+ // only, but for a click's position: its scale, its monitors and its
214
+ // pointer are what put the host's numbers into logical pixels. Null is
215
+ // no display to ask, and a click's numbers are then passed as sent.
216
+ this.app = app ?? null;
217
+ this.appId = appId ?? 'react-x11';
218
+ this.decodeIcon = decodeIcon ?? (() => null);
219
+ this.onError = onError ?? (() => {});
220
+
221
+ // The caller's slot when it has one. A hook that is switched off and on
222
+ // again must re-register the **same** `sender@path`, because that string
223
+ // is the host's identity for the icon: a fresh path reads as a second,
224
+ // additional tray icon rather than as the first one coming back.
225
+ this.index = slot ?? nextItemIndex++;
226
+ this.path = `/StatusNotifierItem/${this.index}`;
227
+ this.menuPath = `${this.path}/Menu`;
228
+
229
+ this.stopped = false;
230
+ this.exported = false;
231
+ this.syncing = null;
232
+ /** Set while withdrawing, so `Status` reads `Passive` on the way out —
233
+ * see `announcePassive()`. */
234
+ this.withdrawing = false;
235
+
236
+ this.ref = null;
237
+ this.dbus = null;
238
+ this.iface = null;
239
+ this.registration = null;
240
+ this.menuRegistration = null;
241
+ this.subscription = null;
242
+ this.onOwnerChanged = undefined;
243
+
244
+ this.menu = null;
245
+ }
246
+
247
+ /** The options the *current* render supplied — never the mounting one's. */
248
+ get options() {
249
+ return this.getOptions() ?? {};
250
+ }
251
+
252
+ // ------------------------------------------------------------------ setup
253
+
254
+ async start() {
255
+ const ref = await sessionBus();
256
+ // No bus is a first-class configuration, not a degraded one: ssh, a bare
257
+ // startx, CI, Node 20 without the transport. There is simply no tray.
258
+ if (!ref) return false;
259
+ if (this.stopped) {
260
+ await ref.release();
261
+ return false;
262
+ }
263
+ this.ref = ref;
264
+ try {
265
+ await this.watchWatcher();
266
+ await this.sync();
267
+ return this.exported;
268
+ } catch (err) {
269
+ // A desktop that answers the bus but not this protocol is not an error
270
+ // for an app whose tray is a convenience. Reported, not thrown.
271
+ this.onError(err);
272
+ await this.teardown();
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Follow the watcher's ownership for the life of the item.
279
+ *
280
+ * Unlike the global menu's registrar, a watcher **is** the feature rather
281
+ * than a directory something else reads — but the same restart problem
282
+ * applies, and the same `arg0` narrowing keeps the daemon from waking this
283
+ * process for every name on the session.
284
+ */
285
+ async watchWatcher() {
286
+ const { bus } = this.ref;
287
+ const subscription = await bus.watch(
288
+ "type='signal',sender='org.freedesktop.DBus'," +
289
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
290
+ `arg0='${WATCHER_NAME}'`,
291
+ );
292
+ // `AddMatch` is a round trip, and an item that mounts and unmounts inside
293
+ // one — StrictMode, a fast remount — leaves `teardown()` already finished
294
+ // by the time it lands. See `GlobalMenuExport.watchRegistrar`, which has
295
+ // the long version of why installing it anyway leaks for the life of the
296
+ // process.
297
+ if (this.stopped) {
298
+ await subscription.remove().catch(() => {});
299
+ return;
300
+ }
301
+ this.subscription = subscription;
302
+ const key = bus.mangle(
303
+ '/org/freedesktop/DBus',
304
+ 'org.freedesktop.DBus',
305
+ 'NameOwnerChanged',
306
+ );
307
+ this.onOwnerChanged = () => {
308
+ if (!this.stopped) this.sync().catch(() => {});
309
+ };
310
+ bus.signals.on(key, this.onOwnerChanged);
311
+ }
312
+
313
+ /** Serialised publish/withdraw. See `GlobalMenuExport.sync`. */
314
+ sync() {
315
+ const done = (this.syncing ?? Promise.resolve()).then(
316
+ () => this._sync(),
317
+ () => this._sync(),
318
+ );
319
+ this.syncing = done.catch(() => {});
320
+ return done;
321
+ }
322
+
323
+ async _sync() {
324
+ if (this.stopped || !this.ref) return;
325
+ const live = await this.watcherIsLive();
326
+ if (this.stopped) return;
327
+ if (live && !this.exported) await this.publish();
328
+ else if (!live && this.exported) await this.withdraw();
329
+ }
330
+
331
+ /**
332
+ * A **live owner**, not an activatable name — the `globalmenu.js` rule, for
333
+ * the same reason. `org.kde.StatusNotifierWatcher` ships as an activatable
334
+ * service on some desktops (this box has `org.x.StatusNotifierWatcher` as
335
+ * one), and starting a watcher nobody is hosting would register the icon
336
+ * into a directory no panel reads: an icon that exists and is drawn nowhere.
337
+ */
338
+ async watcherIsLive() {
339
+ try {
340
+ return await this.ref.bus.nameHasOwner(WATCHER_NAME);
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ async publish() {
347
+ const { bus } = this.ref;
348
+ this.dbus ??= await loadTransport();
349
+ if (this.stopped) return;
350
+
351
+ // The menu is exported **before** the item, because the item's `Menu`
352
+ // property names it: a host that reads the property the instant the item
353
+ // registers would otherwise be told about a path that is not there yet.
354
+ const options = this.options;
355
+ if (options.menu) await this.publishMenu();
356
+
357
+ this.iface = this.defineItem(this.dbus);
358
+ this.registration = await bus.export(this.path, this.iface);
359
+ if (this.stopped) return void (await this.teardownExports());
360
+
361
+ const watcher = await bus.getInterface(
362
+ WATCHER_NAME,
363
+ WATCHER_PATH,
364
+ WATCHER_IFACE,
365
+ );
366
+ // The path form, sender-attributed — see the header.
367
+ await new Promise((resolve, reject) => {
368
+ watcher.RegisterStatusNotifierItem(this.path, (err) =>
369
+ err ? reject(err) : resolve(),
370
+ );
371
+ });
372
+ if (this.stopped) return void (await this.teardownExports());
373
+ this.withdrawing = false;
374
+ this.exported = true;
375
+ this.announceAll();
376
+ }
377
+
378
+ /**
379
+ * Say everything once, immediately after registering.
380
+ *
381
+ * A host that already knew this `sender@path` — the icon was switched off
382
+ * and on again, so the id is the same by design — does **not** re-read us
383
+ * on the way back. gnome-shell's watcher answers a repeat registration with
384
+ * `item.reset()`, which is one event and no property fetch, and its
385
+ * `Status` is a cached proxy property. So the `Passive` that
386
+ * `announcePassive()` correctly told it on the way out is still what it
387
+ * believes, and the icon stays hidden however healthy the object is.
388
+ *
389
+ * `update()` is right to emit only the field that moved — that path runs on
390
+ * every render and a host re-reads per signal. This one runs once per
391
+ * publish, where the opposite is true: nothing about the host's cache can
392
+ * be assumed, so every field is announced and the cost is one burst.
393
+ */
394
+ announceAll() {
395
+ if (!this.exported || !this.iface) return;
396
+ const emit = this.iface.emit;
397
+ try {
398
+ emit.NewStatus(statusOf(this.options));
399
+ emit.NewIcon();
400
+ emit.NewAttentionIcon();
401
+ emit.NewOverlayIcon();
402
+ emit.NewTitle();
403
+ emit.NewToolTip();
404
+ } catch {
405
+ // A connection on its way down owes us nothing.
406
+ }
407
+ }
408
+
409
+ async publishMenu() {
410
+ if (this.menuRegistration) return;
411
+ const menu = new DbusMenuExport({
412
+ getMenus: () => this.options.menu ?? [],
413
+ onSelect: (item) => item.onSelect?.(),
414
+ onAboutToShow: (item) => item.onAboutToShow?.(),
415
+ });
416
+ const iface = menu.defineMenu(this.dbus);
417
+ this.menuRegistration = await this.ref.bus.export(this.menuPath, iface);
418
+ menu.iface = iface;
419
+ menu.exported = true;
420
+ this.menu = menu;
421
+ }
422
+
423
+ async withdraw() {
424
+ await this.announcePassive();
425
+ this.exported = false;
426
+ await this.teardownExports();
427
+ }
428
+
429
+ /**
430
+ * Tell the host the icon is going before the object stops answering.
431
+ *
432
+ * **This is the whole of taking a tray icon down**, and it is not obvious.
433
+ * There is no `UnregisterStatusNotifierItem`: the spec's removal signal is
434
+ * the item's *bus name* losing its owner, and a host watches exactly that.
435
+ * Our name is the app's shared connection (see the header), which outlives
436
+ * any one icon — so simply un-exporting the object removes nothing. The
437
+ * host keeps drawing an icon backed by a dead path, and the next mount adds
438
+ * a *second* one beside it.
439
+ *
440
+ * gnome-shell's appindicator names this failure in a comment on its own
441
+ * workaround: "some applications just remove the indicator object from bus
442
+ * after hiding it, without closing its bus name, so we are not able to
443
+ * understand when they're gone". That workaround is a ten-second liveness
444
+ * probe, and it only runs for an item that is already `Passive`.
445
+ *
446
+ * So: go `Passive` first and say so. `Passive` is the spec's own word for
447
+ * "do not show this", every host honours it immediately, and on this one it
448
+ * is also what arms the reaper. The export is then held for one settle so
449
+ * the re-read the signal provokes finds `Passive` rather than an error.
450
+ *
451
+ * The pair to this is the **stable path** (`slot`): coming back re-registers
452
+ * the same id, which a host dedupes to a reset rather than a second icon.
453
+ */
454
+ async announcePassive() {
455
+ if (!this.exported || !this.iface) return;
456
+ this.withdrawing = true;
457
+ try {
458
+ this.iface.emit.NewStatus('Passive');
459
+ } catch {
460
+ // A connection already on its way down owes us nothing here.
461
+ return;
462
+ }
463
+ // One turn for the signal to reach the socket, and a short window for the
464
+ // host's `Get('Status')` to come back before the object goes away. Cheap,
465
+ // and the difference between an icon that disappears and one that lingers
466
+ // until the process exits.
467
+ await new Promise((resolve) => setTimeout(resolve, 60));
468
+ }
469
+
470
+ async teardownExports() {
471
+ const item = this.registration;
472
+ const menu = this.menuRegistration;
473
+ this.registration = null;
474
+ this.menuRegistration = null;
475
+ this.iface = null;
476
+ this.menu = null;
477
+ await item?.remove?.().catch(() => {});
478
+ await menu?.remove?.().catch(() => {});
479
+ }
480
+
481
+ async stop() {
482
+ // Announced **before** `stopped`, which gates `_sync()` and every other
483
+ // path that could tear the export out from under the signal.
484
+ await this.announcePassive();
485
+ this.stopped = true;
486
+ await this.syncing;
487
+ await this.teardown();
488
+ }
489
+
490
+ async teardown() {
491
+ this.exported = false;
492
+ await this.teardownExports();
493
+ if (this.subscription) {
494
+ const key = this.ref?.bus.mangle(
495
+ '/org/freedesktop/DBus',
496
+ 'org.freedesktop.DBus',
497
+ 'NameOwnerChanged',
498
+ );
499
+ if (key && this.onOwnerChanged) {
500
+ this.ref.bus.signals.removeListener(key, this.onOwnerChanged);
501
+ }
502
+ await this.subscription.remove().catch(() => {});
503
+ this.subscription = null;
504
+ }
505
+ // Dropped as well as removed: it closes over this item, so leaving it on
506
+ // the instance keeps every handler reachable.
507
+ this.onOwnerChanged = undefined;
508
+ await this.ref?.release();
509
+ this.ref = null;
510
+ }
511
+
512
+ // ----------------------------------------------------------------- update
513
+
514
+ /**
515
+ * New options. The protocol has **no general property-changed signal** —
516
+ * each field has its own `New*` signal and hosts re-read the property when
517
+ * they see one, so an update is "emit the signals whose fields moved".
518
+ *
519
+ * Emitting all of them on every render would make a host re-read six
520
+ * properties for a tooltip change, which on Plasma is six round trips per
521
+ * keystroke of whatever produced it. Hence the comparison.
522
+ */
523
+ update(prev) {
524
+ if (!this.exported || !this.iface) return;
525
+ const next = this.options;
526
+ const emit = this.iface.emit;
527
+
528
+ if (prev.icon !== next.icon) emit.NewIcon();
529
+ if (prev.attentionIcon !== next.attentionIcon) emit.NewAttentionIcon();
530
+ if (prev.overlayIcon !== next.overlayIcon) emit.NewOverlayIcon();
531
+ if (prev.title !== next.title) emit.NewTitle();
532
+ if (prev.tooltip !== next.tooltip) emit.NewToolTip();
533
+ if (statusOf(prev) !== statusOf(next)) emit.NewStatus(statusOf(next));
534
+
535
+ // The menu is its own protocol and diffs itself — see `DbusMenuExport`.
536
+ if (this.menu) this.menu.update(next.menu ?? []);
537
+ }
538
+
539
+ // ------------------------------------------------------------------ click
540
+
541
+ /**
542
+ * A click's `(x, y)` in logical screen pixels — the unit a `<popup>`'s
543
+ * `x`/`y` and `anchor={{ rect }}` take. Read both ways and settled by the
544
+ * monitors, then the pointer, then as sent: the header has why.
545
+ */
546
+ async clickPoint(x, y) {
547
+ const scale = scaleOf(this.app);
548
+ if (scale === 1) return { x, y };
549
+ const readings = clickReadings(
550
+ x,
551
+ y,
552
+ scale,
553
+ screensSnapshot(this.app).screens,
554
+ );
555
+ if (readings.length === 1) return readings[0].logical;
556
+ const pointer = readings.length ? await this.queryPointer() : null;
557
+ if (!pointer) return { x, y };
558
+ let near = null;
559
+ let nearest = CLICK_REACH * scale;
560
+ for (const reading of readings) {
561
+ const d = Math.hypot(
562
+ reading.device.x - pointer.x,
563
+ reading.device.y - pointer.y,
564
+ );
565
+ if (d <= nearest) {
566
+ near = reading;
567
+ nearest = d;
568
+ }
569
+ }
570
+ return near?.logical ?? { x, y };
571
+ }
572
+
573
+ /**
574
+ * Where the pointer is on the X screen, in device pixels, or null where
575
+ * nothing can say: a backend with no X server behind it, a failed request,
576
+ * a pointer on another screen.
577
+ */
578
+ queryPointer() {
579
+ const X = this.app?.X;
580
+ const root = X?.display?.screen?.[0]?.root;
581
+ if (typeof X?.QueryPointer !== 'function' || root == null) {
582
+ return Promise.resolve(null);
583
+ }
584
+ return new Promise((resolve) => {
585
+ try {
586
+ X.QueryPointer(root, (err, reply) =>
587
+ resolve(
588
+ err || !reply?.sameScreen
589
+ ? null
590
+ : { x: reply.rootX, y: reply.rootY },
591
+ ),
592
+ );
593
+ } catch {
594
+ resolve(null);
595
+ }
596
+ });
597
+ }
598
+
599
+ // --------------------------------------------------------------- protocol
600
+
601
+ defineItem(dbus) {
602
+ const opts = () => this.options;
603
+ const icon = () => iconOf(opts().icon, this.decodeIcon);
604
+ // No click count, no modifiers, no item rect: the protocol has none of
605
+ // them. Reported as zero rather than invented — see the header. The
606
+ // position it does have is put into logical pixels first, and the call is
607
+ // answered once the app has had the click, so a throw in `onClick` still
608
+ // reaches the host as the error it was.
609
+ const click = (button) => (args) =>
610
+ this.clickPoint(args?.x ?? 0, args?.y ?? 0).then(({ x, y }) => {
611
+ opts().onClick?.({
612
+ button,
613
+ x,
614
+ y,
615
+ width: 0,
616
+ height: 0,
617
+ clickCount: 1,
618
+ shift: false,
619
+ control: false,
620
+ option: false,
621
+ command: false,
622
+ });
623
+ });
624
+
625
+ return dbus.defineInterface({
626
+ name: ITEM_IFACE,
627
+ methods: {
628
+ Activate: { in: { x: 'i', y: 'i' }, out: {}, handler: click('left') },
629
+ SecondaryActivate: {
630
+ in: { x: 'i', y: 'i' },
631
+ out: {},
632
+ handler: click('middle'),
633
+ },
634
+ // A host that renders the menu itself never calls this; one that does
635
+ // not (or an item with no menu) does, and it is the right-click.
636
+ ContextMenu: {
637
+ in: { x: 'i', y: 'i' },
638
+ out: {},
639
+ handler: click('right'),
640
+ },
641
+ Scroll: {
642
+ in: { delta: 'i', orientation: 's' },
643
+ out: {},
644
+ handler: ({ delta, orientation }) =>
645
+ opts().onScroll?.({ delta, orientation }),
646
+ },
647
+ // Wayland's "this click is why you may take focus" token. Accepted and
648
+ // ignored: nothing here raises a window, and refusing the call makes
649
+ // some hosts log an error on every activation.
650
+ ProvideXdgActivationToken: {
651
+ in: { token: 's' },
652
+ out: {},
653
+ handler: () => {},
654
+ },
655
+ },
656
+ properties: {
657
+ Category: {
658
+ type: 's',
659
+ access: 'read',
660
+ get: () => opts().category ?? 'ApplicationStatus',
661
+ },
662
+ // Stable for the life of the item and unique within the app: hosts key
663
+ // their "which icons has the user hidden" setting on it, so an id that
664
+ // changed between runs would forget the user's choice.
665
+ Id: { type: 's', access: 'read', get: () => this.appId },
666
+ Title: {
667
+ type: 's',
668
+ access: 'read',
669
+ get: () => opts().title ?? opts().tooltip ?? this.appId,
670
+ },
671
+ Status: {
672
+ type: 's',
673
+ access: 'read',
674
+ // `withdrawing` wins: the icon is on its way out, whatever the
675
+ // last render asked for.
676
+ get: () => (this.withdrawing ? 'Passive' : statusOf(opts())),
677
+ },
678
+ // 0, always: the item is not tied to a window, and on Wayland there is
679
+ // no X id to give even when it is.
680
+ WindowId: { type: 'i', access: 'read', get: () => 0 },
681
+ IconName: { type: 's', access: 'read', get: () => icon().name },
682
+ IconPixmap: {
683
+ type: PIXMAP_SIGNATURE,
684
+ access: 'read',
685
+ get: () => icon().pixmap,
686
+ },
687
+ OverlayIconName: {
688
+ type: 's',
689
+ access: 'read',
690
+ get: () => iconOf(opts().overlayIcon, this.decodeIcon).name,
691
+ },
692
+ OverlayIconPixmap: {
693
+ type: PIXMAP_SIGNATURE,
694
+ access: 'read',
695
+ get: () => iconOf(opts().overlayIcon, this.decodeIcon).pixmap,
696
+ },
697
+ AttentionIconName: {
698
+ type: 's',
699
+ access: 'read',
700
+ get: () => iconOf(opts().attentionIcon, this.decodeIcon).name,
701
+ },
702
+ AttentionIconPixmap: {
703
+ type: PIXMAP_SIGNATURE,
704
+ access: 'read',
705
+ get: () => iconOf(opts().attentionIcon, this.decodeIcon).pixmap,
706
+ },
707
+ AttentionMovieName: { type: 's', access: 'read', get: () => '' },
708
+ // `(name, pixmap, title, description)`. The title is the bold line.
709
+ ToolTip: {
710
+ type: `(s${PIXMAP_SIGNATURE}ss)`,
711
+ access: 'read',
712
+ get: () => ['', [], opts().tooltip ?? '', ''],
713
+ },
714
+ IconThemePath: {
715
+ type: 's',
716
+ access: 'read',
717
+ get: () => opts().iconThemePath ?? '',
718
+ },
719
+ Menu: {
720
+ type: 'o',
721
+ access: 'read',
722
+ // A path is always advertised, even with no menu: the property is
723
+ // not optional in several hosts' proxies, and an item that answers
724
+ // an error here fails to appear at all on them.
725
+ get: () => this.menuPath,
726
+ },
727
+ // "A click *is* the menu" — true when the app gave a menu and no
728
+ // click handler, and it is what stops a host sending `Activate` into
729
+ // a void on a left click.
730
+ ItemIsMenu: {
731
+ type: 'b',
732
+ access: 'read',
733
+ get: () =>
734
+ Boolean(opts().menu) && typeof opts().onClick !== 'function',
735
+ },
736
+ },
737
+ signals: {
738
+ NewIcon: { args: {} },
739
+ NewAttentionIcon: { args: {} },
740
+ NewOverlayIcon: { args: {} },
741
+ NewTitle: { args: {} },
742
+ NewToolTip: { args: {} },
743
+ NewStatus: { args: { status: 's' } },
744
+ },
745
+ });
746
+ }
747
+ }
748
+
749
+ /** Test seam, not public: make paths predictable across test files. */
750
+ export function _resetItemIndex() {
751
+ nextItemIndex = 1;
752
+ }