react-x11 2.15.2 → 2.15.3

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.
@@ -0,0 +1,605 @@
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 the pointer 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
+ import { loadTransport, sessionBus } from './bus.js';
47
+ import { DbusMenuExport } from './dbusmenuexport.js';
48
+
49
+ export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
50
+ export const WATCHER_PATH = '/StatusNotifierWatcher';
51
+ export const WATCHER_IFACE = 'org.kde.StatusNotifierWatcher';
52
+ export const ITEM_IFACE = 'org.kde.StatusNotifierItem';
53
+
54
+ /** dbusmenu's own struct, repeated here so the tooltip signature reads. */
55
+ const PIXMAP_SIGNATURE = 'a(iiay)';
56
+
57
+ /** One process, many trays: the path counter behind `/StatusNotifierItem/<n>`.
58
+ *
59
+ * A **slot**, not a mount counter. A hook that is toggled off and on again is
60
+ * the *same* tray icon and must come back on the same path — see `stop()`. */
61
+ let nextItemIndex = 1;
62
+
63
+ /** Claim a tray slot. `useTray()` takes one per hook instance, for its life. */
64
+ export function allocateItemSlot() {
65
+ return nextItemIndex++;
66
+ }
67
+
68
+ /**
69
+ * Straight RGBA pixels → the ARGB32 pixmap array the spec wants.
70
+ *
71
+ * Pure, and exported for the test: the byte order is the single most
72
+ * get-wrong-able thing in this file. The spec says ARGB32 in **network byte
73
+ * order**, i.e. big-endian, so a pixel is the bytes `A R G B` in that order —
74
+ * *not* the little-endian `B G R A` that a Cairo/`getImageData` buffer holds
75
+ * when read as a 32-bit word. Getting it backwards produces an icon that is
76
+ * recognisably the right shape in the wrong colours, which is why it is worth
77
+ * a test rather than a squint.
78
+ */
79
+ export function toPixmapArray(image) {
80
+ if (!image) return [];
81
+ const { width, height, data } = image;
82
+ if (!width || !height || !data) return [];
83
+ const out = Buffer.alloc(width * height * 4);
84
+ for (let i = 0; i < width * height; i += 1) {
85
+ const s = i * 4;
86
+ out[s] = data[s + 3]; // A
87
+ out[s + 1] = data[s]; // R
88
+ out[s + 2] = data[s + 1]; // G
89
+ out[s + 3] = data[s + 2]; // B
90
+ }
91
+ return [[width, height, out]];
92
+ }
93
+
94
+ /** `visible: false` is `Passive`, which is how the spec spells "hidden". */
95
+ function statusOf(options) {
96
+ if (options?.visible === false) return 'Passive';
97
+ return options?.attention ? 'NeedsAttention' : 'Active';
98
+ }
99
+
100
+ /**
101
+ * The icon fields, resolved once per update.
102
+ *
103
+ * A string is a **themed icon name**, which is what the desktop wants and
104
+ * what scales: the panel picks the size and the theme picks light or dark.
105
+ * Bytes are decoded and sent as a pixmap — correct, but a fixed size, so the
106
+ * name is the better answer wherever the app can ship an icon in a theme.
107
+ * (On the cocoa rung the same string is an SF Symbol name. One field, two
108
+ * vocabularies, and no app has to branch — which is the point.)
109
+ */
110
+ function iconOf(icon, decode) {
111
+ if (typeof icon === 'string' && icon) return { name: icon, pixmap: [] };
112
+ if (icon && typeof icon === 'object') {
113
+ try {
114
+ return { name: '', pixmap: toPixmapArray(decode(icon)) };
115
+ } catch {
116
+ // Corrupt bytes are a content failure, not a reason to have no tray.
117
+ return { name: '', pixmap: [] };
118
+ }
119
+ }
120
+ return { name: '', pixmap: [] };
121
+ }
122
+
123
+ /**
124
+ * One tray icon on the session bus, for as long as it is started.
125
+ *
126
+ * Shaped like `GlobalMenuExport`, and for the same reasons: the watcher is a
127
+ * thing that restarts, so ownership is followed rather than sampled, and
128
+ * publish/withdraw are serialised behind one promise so two ownership changes
129
+ * in quick succession cannot put two registrations in flight.
130
+ */
131
+ export class StatusNotifierItem {
132
+ constructor({ getOptions, appId, decodeIcon, onError, slot } = {}) {
133
+ this.getOptions = getOptions ?? (() => null);
134
+ this.appId = appId ?? 'react-x11';
135
+ this.decodeIcon = decodeIcon ?? (() => null);
136
+ this.onError = onError ?? (() => {});
137
+
138
+ // The caller's slot when it has one. A hook that is switched off and on
139
+ // again must re-register the **same** `sender@path`, because that string
140
+ // is the host's identity for the icon: a fresh path reads as a second,
141
+ // additional tray icon rather than as the first one coming back.
142
+ this.index = slot ?? nextItemIndex++;
143
+ this.path = `/StatusNotifierItem/${this.index}`;
144
+ this.menuPath = `${this.path}/Menu`;
145
+
146
+ this.stopped = false;
147
+ this.exported = false;
148
+ this.syncing = null;
149
+ /** Set while withdrawing, so `Status` reads `Passive` on the way out —
150
+ * see `announcePassive()`. */
151
+ this.withdrawing = false;
152
+
153
+ this.ref = null;
154
+ this.dbus = null;
155
+ this.iface = null;
156
+ this.registration = null;
157
+ this.menuRegistration = null;
158
+ this.subscription = null;
159
+ this.onOwnerChanged = undefined;
160
+
161
+ this.menu = null;
162
+ }
163
+
164
+ /** The options the *current* render supplied — never the mounting one's. */
165
+ get options() {
166
+ return this.getOptions() ?? {};
167
+ }
168
+
169
+ // ------------------------------------------------------------------ setup
170
+
171
+ async start() {
172
+ const ref = await sessionBus();
173
+ // No bus is a first-class configuration, not a degraded one: ssh, a bare
174
+ // startx, CI, Node 20 without the transport. There is simply no tray.
175
+ if (!ref) return false;
176
+ if (this.stopped) {
177
+ await ref.release();
178
+ return false;
179
+ }
180
+ this.ref = ref;
181
+ try {
182
+ await this.watchWatcher();
183
+ await this.sync();
184
+ return this.exported;
185
+ } catch (err) {
186
+ // A desktop that answers the bus but not this protocol is not an error
187
+ // for an app whose tray is a convenience. Reported, not thrown.
188
+ this.onError(err);
189
+ await this.teardown();
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Follow the watcher's ownership for the life of the item.
196
+ *
197
+ * Unlike the global menu's registrar, a watcher **is** the feature rather
198
+ * than a directory something else reads — but the same restart problem
199
+ * applies, and the same `arg0` narrowing keeps the daemon from waking this
200
+ * process for every name on the session.
201
+ */
202
+ async watchWatcher() {
203
+ const { bus } = this.ref;
204
+ const subscription = await bus.watch(
205
+ "type='signal',sender='org.freedesktop.DBus'," +
206
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
207
+ `arg0='${WATCHER_NAME}'`,
208
+ );
209
+ // `AddMatch` is a round trip, and an item that mounts and unmounts inside
210
+ // one — StrictMode, a fast remount — leaves `teardown()` already finished
211
+ // by the time it lands. See `GlobalMenuExport.watchRegistrar`, which has
212
+ // the long version of why installing it anyway leaks for the life of the
213
+ // process.
214
+ if (this.stopped) {
215
+ await subscription.remove().catch(() => {});
216
+ return;
217
+ }
218
+ this.subscription = subscription;
219
+ const key = bus.mangle(
220
+ '/org/freedesktop/DBus',
221
+ 'org.freedesktop.DBus',
222
+ 'NameOwnerChanged',
223
+ );
224
+ this.onOwnerChanged = () => {
225
+ if (!this.stopped) this.sync().catch(() => {});
226
+ };
227
+ bus.signals.on(key, this.onOwnerChanged);
228
+ }
229
+
230
+ /** Serialised publish/withdraw. See `GlobalMenuExport.sync`. */
231
+ sync() {
232
+ const done = (this.syncing ?? Promise.resolve()).then(
233
+ () => this._sync(),
234
+ () => this._sync(),
235
+ );
236
+ this.syncing = done.catch(() => {});
237
+ return done;
238
+ }
239
+
240
+ async _sync() {
241
+ if (this.stopped || !this.ref) return;
242
+ const live = await this.watcherIsLive();
243
+ if (this.stopped) return;
244
+ if (live && !this.exported) await this.publish();
245
+ else if (!live && this.exported) await this.withdraw();
246
+ }
247
+
248
+ /**
249
+ * A **live owner**, not an activatable name — the `globalmenu.js` rule, for
250
+ * the same reason. `org.kde.StatusNotifierWatcher` ships as an activatable
251
+ * service on some desktops (this box has `org.x.StatusNotifierWatcher` as
252
+ * one), and starting a watcher nobody is hosting would register the icon
253
+ * into a directory no panel reads: an icon that exists and is drawn nowhere.
254
+ */
255
+ async watcherIsLive() {
256
+ try {
257
+ return await this.ref.bus.nameHasOwner(WATCHER_NAME);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ async publish() {
264
+ const { bus } = this.ref;
265
+ this.dbus ??= await loadTransport();
266
+ if (this.stopped) return;
267
+
268
+ // The menu is exported **before** the item, because the item's `Menu`
269
+ // property names it: a host that reads the property the instant the item
270
+ // registers would otherwise be told about a path that is not there yet.
271
+ const options = this.options;
272
+ if (options.menu) await this.publishMenu();
273
+
274
+ this.iface = this.defineItem(this.dbus);
275
+ this.registration = await bus.export(this.path, this.iface);
276
+ if (this.stopped) return void (await this.teardownExports());
277
+
278
+ const watcher = await bus.getInterface(
279
+ WATCHER_NAME,
280
+ WATCHER_PATH,
281
+ WATCHER_IFACE,
282
+ );
283
+ // The path form, sender-attributed — see the header.
284
+ await new Promise((resolve, reject) => {
285
+ watcher.RegisterStatusNotifierItem(this.path, (err) =>
286
+ err ? reject(err) : resolve(),
287
+ );
288
+ });
289
+ if (this.stopped) return void (await this.teardownExports());
290
+ this.withdrawing = false;
291
+ this.exported = true;
292
+ this.announceAll();
293
+ }
294
+
295
+ /**
296
+ * Say everything once, immediately after registering.
297
+ *
298
+ * A host that already knew this `sender@path` — the icon was switched off
299
+ * and on again, so the id is the same by design — does **not** re-read us
300
+ * on the way back. gnome-shell's watcher answers a repeat registration with
301
+ * `item.reset()`, which is one event and no property fetch, and its
302
+ * `Status` is a cached proxy property. So the `Passive` that
303
+ * `announcePassive()` correctly told it on the way out is still what it
304
+ * believes, and the icon stays hidden however healthy the object is.
305
+ *
306
+ * `update()` is right to emit only the field that moved — that path runs on
307
+ * every render and a host re-reads per signal. This one runs once per
308
+ * publish, where the opposite is true: nothing about the host's cache can
309
+ * be assumed, so every field is announced and the cost is one burst.
310
+ */
311
+ announceAll() {
312
+ if (!this.exported || !this.iface) return;
313
+ const emit = this.iface.emit;
314
+ try {
315
+ emit.NewStatus(statusOf(this.options));
316
+ emit.NewIcon();
317
+ emit.NewAttentionIcon();
318
+ emit.NewOverlayIcon();
319
+ emit.NewTitle();
320
+ emit.NewToolTip();
321
+ } catch {
322
+ // A connection on its way down owes us nothing.
323
+ }
324
+ }
325
+
326
+ async publishMenu() {
327
+ if (this.menuRegistration) return;
328
+ const menu = new DbusMenuExport({
329
+ getMenus: () => this.options.menu ?? [],
330
+ onSelect: (item) => item.onSelect?.(),
331
+ onAboutToShow: (item) => item.onAboutToShow?.(),
332
+ });
333
+ const iface = menu.defineMenu(this.dbus);
334
+ this.menuRegistration = await this.ref.bus.export(this.menuPath, iface);
335
+ menu.iface = iface;
336
+ menu.exported = true;
337
+ this.menu = menu;
338
+ }
339
+
340
+ async withdraw() {
341
+ await this.announcePassive();
342
+ this.exported = false;
343
+ await this.teardownExports();
344
+ }
345
+
346
+ /**
347
+ * Tell the host the icon is going before the object stops answering.
348
+ *
349
+ * **This is the whole of taking a tray icon down**, and it is not obvious.
350
+ * There is no `UnregisterStatusNotifierItem`: the spec's removal signal is
351
+ * the item's *bus name* losing its owner, and a host watches exactly that.
352
+ * Our name is the app's shared connection (see the header), which outlives
353
+ * any one icon — so simply un-exporting the object removes nothing. The
354
+ * host keeps drawing an icon backed by a dead path, and the next mount adds
355
+ * a *second* one beside it.
356
+ *
357
+ * gnome-shell's appindicator names this failure in a comment on its own
358
+ * workaround: "some applications just remove the indicator object from bus
359
+ * after hiding it, without closing its bus name, so we are not able to
360
+ * understand when they're gone". That workaround is a ten-second liveness
361
+ * probe, and it only runs for an item that is already `Passive`.
362
+ *
363
+ * So: go `Passive` first and say so. `Passive` is the spec's own word for
364
+ * "do not show this", every host honours it immediately, and on this one it
365
+ * is also what arms the reaper. The export is then held for one settle so
366
+ * the re-read the signal provokes finds `Passive` rather than an error.
367
+ *
368
+ * The pair to this is the **stable path** (`slot`): coming back re-registers
369
+ * the same id, which a host dedupes to a reset rather than a second icon.
370
+ */
371
+ async announcePassive() {
372
+ if (!this.exported || !this.iface) return;
373
+ this.withdrawing = true;
374
+ try {
375
+ this.iface.emit.NewStatus('Passive');
376
+ } catch {
377
+ // A connection already on its way down owes us nothing here.
378
+ return;
379
+ }
380
+ // One turn for the signal to reach the socket, and a short window for the
381
+ // host's `Get('Status')` to come back before the object goes away. Cheap,
382
+ // and the difference between an icon that disappears and one that lingers
383
+ // until the process exits.
384
+ await new Promise((resolve) => setTimeout(resolve, 60));
385
+ }
386
+
387
+ async teardownExports() {
388
+ const item = this.registration;
389
+ const menu = this.menuRegistration;
390
+ this.registration = null;
391
+ this.menuRegistration = null;
392
+ this.iface = null;
393
+ this.menu = null;
394
+ await item?.remove?.().catch(() => {});
395
+ await menu?.remove?.().catch(() => {});
396
+ }
397
+
398
+ async stop() {
399
+ // Announced **before** `stopped`, which gates `_sync()` and every other
400
+ // path that could tear the export out from under the signal.
401
+ await this.announcePassive();
402
+ this.stopped = true;
403
+ await this.syncing;
404
+ await this.teardown();
405
+ }
406
+
407
+ async teardown() {
408
+ this.exported = false;
409
+ await this.teardownExports();
410
+ if (this.subscription) {
411
+ const key = this.ref?.bus.mangle(
412
+ '/org/freedesktop/DBus',
413
+ 'org.freedesktop.DBus',
414
+ 'NameOwnerChanged',
415
+ );
416
+ if (key && this.onOwnerChanged) {
417
+ this.ref.bus.signals.removeListener(key, this.onOwnerChanged);
418
+ }
419
+ await this.subscription.remove().catch(() => {});
420
+ this.subscription = null;
421
+ }
422
+ // Dropped as well as removed: it closes over this item, so leaving it on
423
+ // the instance keeps every handler reachable.
424
+ this.onOwnerChanged = undefined;
425
+ await this.ref?.release();
426
+ this.ref = null;
427
+ }
428
+
429
+ // ----------------------------------------------------------------- update
430
+
431
+ /**
432
+ * New options. The protocol has **no general property-changed signal** —
433
+ * each field has its own `New*` signal and hosts re-read the property when
434
+ * they see one, so an update is "emit the signals whose fields moved".
435
+ *
436
+ * Emitting all of them on every render would make a host re-read six
437
+ * properties for a tooltip change, which on Plasma is six round trips per
438
+ * keystroke of whatever produced it. Hence the comparison.
439
+ */
440
+ update(prev) {
441
+ if (!this.exported || !this.iface) return;
442
+ const next = this.options;
443
+ const emit = this.iface.emit;
444
+
445
+ if (prev.icon !== next.icon) emit.NewIcon();
446
+ if (prev.attentionIcon !== next.attentionIcon) emit.NewAttentionIcon();
447
+ if (prev.overlayIcon !== next.overlayIcon) emit.NewOverlayIcon();
448
+ if (prev.title !== next.title) emit.NewTitle();
449
+ if (prev.tooltip !== next.tooltip) emit.NewToolTip();
450
+ if (statusOf(prev) !== statusOf(next)) emit.NewStatus(statusOf(next));
451
+
452
+ // The menu is its own protocol and diffs itself — see `DbusMenuExport`.
453
+ if (this.menu) this.menu.update(next.menu ?? []);
454
+ }
455
+
456
+ // --------------------------------------------------------------- protocol
457
+
458
+ defineItem(dbus) {
459
+ const opts = () => this.options;
460
+ const icon = () => iconOf(opts().icon, this.decodeIcon);
461
+ const click = (button) => (args) => {
462
+ // No click count, no modifiers, no item rect: the protocol has none of
463
+ // them. Reported as zero rather than invented — see the header.
464
+ opts().onClick?.({
465
+ button,
466
+ x: args?.x ?? 0,
467
+ y: args?.y ?? 0,
468
+ width: 0,
469
+ height: 0,
470
+ clickCount: 1,
471
+ shift: false,
472
+ control: false,
473
+ option: false,
474
+ command: false,
475
+ });
476
+ };
477
+
478
+ return dbus.defineInterface({
479
+ name: ITEM_IFACE,
480
+ methods: {
481
+ Activate: { in: { x: 'i', y: 'i' }, out: {}, handler: click('left') },
482
+ SecondaryActivate: {
483
+ in: { x: 'i', y: 'i' },
484
+ out: {},
485
+ handler: click('middle'),
486
+ },
487
+ // A host that renders the menu itself never calls this; one that does
488
+ // not (or an item with no menu) does, and it is the right-click.
489
+ ContextMenu: {
490
+ in: { x: 'i', y: 'i' },
491
+ out: {},
492
+ handler: click('right'),
493
+ },
494
+ Scroll: {
495
+ in: { delta: 'i', orientation: 's' },
496
+ out: {},
497
+ handler: ({ delta, orientation }) =>
498
+ opts().onScroll?.({ delta, orientation }),
499
+ },
500
+ // Wayland's "this click is why you may take focus" token. Accepted and
501
+ // ignored: nothing here raises a window, and refusing the call makes
502
+ // some hosts log an error on every activation.
503
+ ProvideXdgActivationToken: {
504
+ in: { token: 's' },
505
+ out: {},
506
+ handler: () => {},
507
+ },
508
+ },
509
+ properties: {
510
+ Category: {
511
+ type: 's',
512
+ access: 'read',
513
+ get: () => opts().category ?? 'ApplicationStatus',
514
+ },
515
+ // Stable for the life of the item and unique within the app: hosts key
516
+ // their "which icons has the user hidden" setting on it, so an id that
517
+ // changed between runs would forget the user's choice.
518
+ Id: { type: 's', access: 'read', get: () => this.appId },
519
+ Title: {
520
+ type: 's',
521
+ access: 'read',
522
+ get: () => opts().title ?? opts().tooltip ?? this.appId,
523
+ },
524
+ Status: {
525
+ type: 's',
526
+ access: 'read',
527
+ // `withdrawing` wins: the icon is on its way out, whatever the
528
+ // last render asked for.
529
+ get: () => (this.withdrawing ? 'Passive' : statusOf(opts())),
530
+ },
531
+ // 0, always: the item is not tied to a window, and on Wayland there is
532
+ // no X id to give even when it is.
533
+ WindowId: { type: 'i', access: 'read', get: () => 0 },
534
+ IconName: { type: 's', access: 'read', get: () => icon().name },
535
+ IconPixmap: {
536
+ type: PIXMAP_SIGNATURE,
537
+ access: 'read',
538
+ get: () => icon().pixmap,
539
+ },
540
+ OverlayIconName: {
541
+ type: 's',
542
+ access: 'read',
543
+ get: () => iconOf(opts().overlayIcon, this.decodeIcon).name,
544
+ },
545
+ OverlayIconPixmap: {
546
+ type: PIXMAP_SIGNATURE,
547
+ access: 'read',
548
+ get: () => iconOf(opts().overlayIcon, this.decodeIcon).pixmap,
549
+ },
550
+ AttentionIconName: {
551
+ type: 's',
552
+ access: 'read',
553
+ get: () => iconOf(opts().attentionIcon, this.decodeIcon).name,
554
+ },
555
+ AttentionIconPixmap: {
556
+ type: PIXMAP_SIGNATURE,
557
+ access: 'read',
558
+ get: () => iconOf(opts().attentionIcon, this.decodeIcon).pixmap,
559
+ },
560
+ AttentionMovieName: { type: 's', access: 'read', get: () => '' },
561
+ // `(name, pixmap, title, description)`. The title is the bold line.
562
+ ToolTip: {
563
+ type: `(s${PIXMAP_SIGNATURE}ss)`,
564
+ access: 'read',
565
+ get: () => ['', [], opts().tooltip ?? '', ''],
566
+ },
567
+ IconThemePath: {
568
+ type: 's',
569
+ access: 'read',
570
+ get: () => opts().iconThemePath ?? '',
571
+ },
572
+ Menu: {
573
+ type: 'o',
574
+ access: 'read',
575
+ // A path is always advertised, even with no menu: the property is
576
+ // not optional in several hosts' proxies, and an item that answers
577
+ // an error here fails to appear at all on them.
578
+ get: () => this.menuPath,
579
+ },
580
+ // "A click *is* the menu" — true when the app gave a menu and no
581
+ // click handler, and it is what stops a host sending `Activate` into
582
+ // a void on a left click.
583
+ ItemIsMenu: {
584
+ type: 'b',
585
+ access: 'read',
586
+ get: () =>
587
+ Boolean(opts().menu) && typeof opts().onClick !== 'function',
588
+ },
589
+ },
590
+ signals: {
591
+ NewIcon: { args: {} },
592
+ NewAttentionIcon: { args: {} },
593
+ NewOverlayIcon: { args: {} },
594
+ NewTitle: { args: {} },
595
+ NewToolTip: { args: {} },
596
+ NewStatus: { args: { status: 's' } },
597
+ },
598
+ });
599
+ }
600
+ }
601
+
602
+ /** Test seam, not public: make paths predictable across test files. */
603
+ export function _resetItemIndex() {
604
+ nextItemIndex = 1;
605
+ }