react-x11 2.15.1 → 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,243 @@
1
+ // Serving `com.canonical.dbusmenu`: the half of the protocol that is the same
2
+ // wherever the menu is hung.
3
+ //
4
+ // `dbusmenu.js` is the pure part — ids, snapshots, the diff — and says why
5
+ // each of those is hard. This is the object that answers for it on a bus:
6
+ // `GetLayout`, the property reads, `Event`, `AboutToShow`, and the two update
7
+ // signals. Nothing here knows *why* a menu is exported, and that is the point.
8
+ //
9
+ // It was extracted from `GlobalMenuExport` when the tray arrived
10
+ // (react-x11#353), because a panel menu and a tray menu differ only in how the
11
+ // desktop is told where the menu is:
12
+ //
13
+ // - a **global menu** registers a *window* with `AppMenu.Registrar`, and
14
+ // follows the registrar's ownership so the bar moves back into the window
15
+ // when the panel exits (`globalmenu.js`);
16
+ // - a **tray menu** is named by the `Menu` property of a StatusNotifierItem
17
+ // and has no window at all (`statusnotifier.js`).
18
+ //
19
+ // Both serve the identical tree. Subclasses own the bus lifecycle — when to
20
+ // export, what to register, when to withdraw — and inherit everything below.
21
+
22
+ import {
23
+ DBUSMENU_IFACE,
24
+ PROPERTY_TYPES,
25
+ diffSnapshots,
26
+ groupProperties,
27
+ layoutOf,
28
+ snapshot,
29
+ IdAllocator,
30
+ } from './dbusmenu.js';
31
+
32
+ /** dbusmenu's recursive layout struct: `(id, properties, children)`. */
33
+ export const LAYOUT_SIGNATURE = '(ia{sv}av)';
34
+
35
+ /**
36
+ * A menu tree, serialised and served.
37
+ *
38
+ * `getMenus()` is read at construction and on every `update()`; `onSelect` and
39
+ * `onAboutToShow` receive the *item object* the app authored, not an id, so a
40
+ * caller never deals in dbusmenu's numbering.
41
+ */
42
+ export class DbusMenuExport {
43
+ constructor({ getMenus, onSelect, onAboutToShow }) {
44
+ this.getMenus = getMenus;
45
+ this.onSelect = onSelect;
46
+ this.onAboutToShow = onAboutToShow;
47
+
48
+ this.alloc = new IdAllocator();
49
+ this.nodes = snapshot(getMenus(), this.alloc);
50
+ this.revision = 1;
51
+
52
+ /** Set by the subclass once the object is actually on a bus: until then
53
+ * `update()` must not emit, because nobody is listening and the
54
+ * revision would run ahead of the first `GetLayout`. */
55
+ this.exported = false;
56
+ this.iface = null;
57
+ this.dbus = null;
58
+ }
59
+
60
+ // ----------------------------------------------------------------- update
61
+
62
+ /**
63
+ * A new `menus` array. Serialise, diff, and send the *narrower* of the two
64
+ * signals the protocol has — see `diffSnapshots`, where the choice is made
65
+ * and why it matters.
66
+ */
67
+ update(menus) {
68
+ const next = snapshot(menus, this.alloc);
69
+ const change = diffSnapshots(this.nodes, next);
70
+ this.nodes = next;
71
+ if (!this.exported || change.kind === 'none') return;
72
+
73
+ if (change.kind === 'properties') {
74
+ this.iface?.emit.ItemsPropertiesUpdated(
75
+ change.updated.map(([id, props]) => [id, this.wrap(props)]),
76
+ change.removed,
77
+ );
78
+ return;
79
+ }
80
+ this.revision += 1;
81
+ this.iface?.emit.LayoutUpdated(this.revision, change.parent);
82
+ }
83
+
84
+ /** Plain property values → the `a{sv}` the wire wants. */
85
+ wrap(props) {
86
+ const out = {};
87
+ for (const [name, value] of Object.entries(props)) {
88
+ const type = PROPERTY_TYPES[name];
89
+ if (type) out[name] = new this.dbus.Variant(type, value);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
95
+ wrapLayout(node) {
96
+ const [id, props, children] = node;
97
+ return [
98
+ id,
99
+ this.wrap(props),
100
+ children.map(
101
+ (child) =>
102
+ new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
103
+ ),
104
+ ];
105
+ }
106
+
107
+ // --------------------------------------------------------------- protocol
108
+
109
+ itemFor(id) {
110
+ return this.nodes.get(id)?.item ?? null;
111
+ }
112
+
113
+ defineMenu(dbus) {
114
+ this.dbus = dbus;
115
+ const fire = (id, eventId) => {
116
+ const item = this.itemFor(id);
117
+ if (!item) return;
118
+ if (eventId === 'clicked') this.onSelect?.(item);
119
+ else if (eventId === 'opened') this.onAboutToShow?.(item);
120
+ };
121
+
122
+ return dbus.defineInterface({
123
+ name: DBUSMENU_IFACE,
124
+ methods: {
125
+ GetLayout: {
126
+ in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
127
+ out: { revision: 'u', layout: LAYOUT_SIGNATURE },
128
+ handler: ({ parentId, recursionDepth, propertyNames }) => {
129
+ const tree = layoutOf(
130
+ this.nodes,
131
+ parentId,
132
+ recursionDepth ?? -1,
133
+ propertyNames,
134
+ );
135
+ // An id the shell remembers from before a structural change is the
136
+ // normal way to arrive here, not a protocol violation: answer with
137
+ // an empty item rather than an error, and let the LayoutUpdated it
138
+ // has already been sent bring it back for the real tree.
139
+ return {
140
+ revision: this.revision,
141
+ layout: this.wrapLayout(tree ?? [parentId, {}, []]),
142
+ };
143
+ },
144
+ },
145
+ GetGroupProperties: {
146
+ in: { ids: 'ai', propertyNames: 'as' },
147
+ out: { properties: 'a(ia{sv})' },
148
+ handler: ({ ids, propertyNames }) =>
149
+ groupProperties(this.nodes, ids, propertyNames).map(
150
+ ([id, props]) => [id, this.wrap(props)],
151
+ ),
152
+ },
153
+ GetProperty: {
154
+ in: { id: 'i', name: 's' },
155
+ out: { value: 'v' },
156
+ handler: ({ id, name }) => {
157
+ const props = this.nodes.get(id)?.props;
158
+ // `hasOwn` on both, so a name like `constructor` reads as absent
159
+ // rather than as a function that then fails to marshal.
160
+ const known = Object.hasOwn(PROPERTY_TYPES, name);
161
+ const value =
162
+ props && Object.hasOwn(props, name) ? props[name] : undefined;
163
+ // A property this item does not carry is at its default, and the
164
+ // spec's own advice is to answer with it rather than to error.
165
+ if (value === undefined || !known) return new dbus.Variant('s', '');
166
+ return new dbus.Variant(PROPERTY_TYPES[name], value);
167
+ },
168
+ },
169
+ Event: {
170
+ in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
171
+ out: {},
172
+ handler: ({ id, eventId }) => fire(id, eventId),
173
+ },
174
+ EventGroup: {
175
+ in: { events: 'a(isvu)' },
176
+ out: { idErrors: 'ai' },
177
+ handler: ({ events }) => {
178
+ const errors = [];
179
+ for (const [id, eventId] of events ?? []) {
180
+ if (this.itemFor(id)) fire(id, eventId);
181
+ else errors.push(id);
182
+ }
183
+ return errors;
184
+ },
185
+ },
186
+ AboutToShow: {
187
+ in: { id: 'i' },
188
+ out: { needUpdate: 'b' },
189
+ handler: ({ id }) => this.aboutToShow(id),
190
+ },
191
+ AboutToShowGroup: {
192
+ in: { ids: 'ai' },
193
+ out: { updatesNeeded: 'ai', idErrors: 'ai' },
194
+ handler: ({ ids }) => {
195
+ const errors = [];
196
+ for (const id of ids ?? []) {
197
+ if (this.itemFor(id)) this.aboutToShow(id);
198
+ else errors.push(id);
199
+ }
200
+ return { updatesNeeded: [], idErrors: errors };
201
+ },
202
+ },
203
+ },
204
+ properties: {
205
+ Version: { type: 'u', access: 'read', get: () => 3 },
206
+ Status: { type: 's', access: 'read', get: () => 'normal' },
207
+ TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
208
+ // Empty, and not a placeholder: `icon-name` is looked up in the
209
+ // desktop's own theme, which is where an app's icons should come from.
210
+ // A path here would be for icons shipped beside the app.
211
+ IconThemePath: { type: 'as', access: 'read', get: () => [] },
212
+ },
213
+ signals: {
214
+ LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
215
+ ItemsPropertiesUpdated: {
216
+ args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
217
+ },
218
+ ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
219
+ },
220
+ });
221
+ }
222
+
223
+ /**
224
+ * The reply has to go out **now**, and React has not rendered yet.
225
+ *
226
+ * `setState` from here does not produce a new tree before this function
227
+ * returns, so there is no honest way to answer `true` — and answering `true`
228
+ * dishonestly is worse than answering `false`: the shell then blocks on a
229
+ * `GetLayout` for a subtree that has not been built, gets the old one, and
230
+ * caches it.
231
+ *
232
+ * So: answer `false`, let the handler run, and let the ordinary update path
233
+ * emit `LayoutUpdated` when the new items actually serialise. Shells listen
234
+ * for that unconditionally — it is the same signal a menu changing while
235
+ * open produces — so a lazily-built submenu still fills in, one round trip
236
+ * later than a synchronous toolkit would manage.
237
+ */
238
+ aboutToShow(id) {
239
+ const item = this.itemFor(id);
240
+ if (item) this.onAboutToShow?.(item);
241
+ return false;
242
+ }
243
+ }
@@ -0,0 +1,137 @@
1
+ // `useDesktopCapability()` — what this desktop can do, as render state.
2
+ //
3
+ // The vocabulary and the probes are `capabilities.js`; this is the part that
4
+ // makes them safe to branch on inside a component. Three things it has to get
5
+ // right, and each is a bug an app would otherwise hit:
6
+ //
7
+ // 1. **The first frame has no answer.** Probing takes a bus round trip, so
8
+ // the hook returns `NO_CAPABILITY` — available false, empty features —
9
+ // until one arrives. An app therefore renders its fallback first and
10
+ // upgrades, which is the right way round: the opposite order flashes a
11
+ // feature that turns out not to exist.
12
+ //
13
+ // 2. **The answer changes.** A panel restarts, an AppIndicator extension is
14
+ // toggled, a notification daemon is installed. The hook follows
15
+ // `NameOwnerChanged` for the whole session rather than sampling once, for
16
+ // the same reason `globalmenu.js` does: a cached "no" outlives the fix.
17
+ //
18
+ // 3. **The object identity must be stable.** A probe that returned a fresh
19
+ // object every time would re-render every consumer on every bus event,
20
+ // and a `features` object in a dependency array would never compare
21
+ // equal. Results are frozen and replaced only when they differ by value.
22
+
23
+ import { useEffect, useState } from 'react';
24
+
25
+ import { sessionBus } from './bus.js';
26
+ import { NO_CAPABILITY, desktopCapability } from './capabilities.js';
27
+
28
+ /** Value equality over the two levels a capability result has. */
29
+ function same(a, b) {
30
+ if (a === b) return true;
31
+ if (!a || !b) return false;
32
+ if (a.available !== b.available || a.backend !== b.backend) return false;
33
+ if (a.reason !== b.reason) return false;
34
+ const ka = Object.keys(a.features);
35
+ const kb = Object.keys(b.features);
36
+ if (ka.length !== kb.length) return false;
37
+ return ka.every((k) => a.features[k] === b.features[k]);
38
+ }
39
+
40
+ /**
41
+ * What this desktop can do for one feature, as a value a component branches
42
+ * on.
43
+ *
44
+ * ```jsx
45
+ * const notifications = useDesktopCapability('notifications');
46
+ *
47
+ * // The portable question is about the feature, never about the platform.
48
+ * if (notifications.features.actions) {
49
+ * return <ReplyFromBanner />;
50
+ * }
51
+ * return <OpenAppToReply available={notifications.available} />;
52
+ * ```
53
+ *
54
+ * `{ available, backend, features }`, starting at
55
+ * {@link NO_CAPABILITY} and settling a tick later — see the header for why
56
+ * that order is deliberate. It re-probes whenever a name appears or vanishes
57
+ * on the session bus, so a panel that starts after the app does is picked up.
58
+ *
59
+ * Capability names: `'notifications'`, `'tray'`, `'launcher'`.
60
+ */
61
+ export function useDesktopCapability(name) {
62
+ const [state, setState] = useState(NO_CAPABILITY);
63
+
64
+ useEffect(() => {
65
+ let cancelled = false;
66
+ let subscription = null;
67
+ let ref = null;
68
+ let onChanged = null;
69
+
70
+ const probe = () => {
71
+ desktopCapability(name)
72
+ .then((next) => {
73
+ if (cancelled) return;
74
+ // Replaced only when it differs by value: see the header.
75
+ setState((prev) => (same(prev, next) ? prev : next));
76
+ })
77
+ .catch(() => {});
78
+ };
79
+
80
+ probe();
81
+
82
+ // Follow the session for anything appearing or going away. Deliberately
83
+ // *not* narrowed by `arg0`: one capability can depend on several names
84
+ // (the tray watcher, the notification daemon, a launcher), and the set is
85
+ // a detail of `capabilities.js` rather than of this hook. The handler is
86
+ // a re-probe, so the cost of a wide match is a bus round trip on an event
87
+ // that is rare in a settled session.
88
+ void (async () => {
89
+ ref = await sessionBus();
90
+ if (!ref || cancelled) {
91
+ await ref?.release();
92
+ ref = null;
93
+ return;
94
+ }
95
+ try {
96
+ subscription = await ref.bus.watch(
97
+ "type='signal',sender='org.freedesktop.DBus'," +
98
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'",
99
+ );
100
+ } catch {
101
+ return;
102
+ }
103
+ // The mount/unmount race `AddMatch` always has — see
104
+ // `GlobalMenuExport.watchRegistrar` for the long version.
105
+ if (cancelled) {
106
+ await subscription.remove().catch(() => {});
107
+ subscription = null;
108
+ return;
109
+ }
110
+ const key = ref.bus.mangle(
111
+ '/org/freedesktop/DBus',
112
+ 'org.freedesktop.DBus',
113
+ 'NameOwnerChanged',
114
+ );
115
+ onChanged = () => probe();
116
+ ref.bus.signals.on(key, onChanged);
117
+ })();
118
+
119
+ return () => {
120
+ cancelled = true;
121
+ void (async () => {
122
+ if (ref && onChanged) {
123
+ const key = ref.bus.mangle(
124
+ '/org/freedesktop/DBus',
125
+ 'org.freedesktop.DBus',
126
+ 'NameOwnerChanged',
127
+ );
128
+ ref.bus.signals.removeListener(key, onChanged);
129
+ }
130
+ await subscription?.remove().catch(() => {});
131
+ await ref?.release();
132
+ })();
133
+ };
134
+ }, [name]);
135
+
136
+ return state;
137
+ }
package/src/globalmenu.js CHANGED
@@ -58,15 +58,7 @@ import { useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
60
  import { desktopIntegrationEnabled } from './desktopintegration.js';
61
- import {
62
- DBUSMENU_IFACE,
63
- PROPERTY_TYPES,
64
- diffSnapshots,
65
- groupProperties,
66
- layoutOf,
67
- snapshot,
68
- IdAllocator,
69
- } from './dbusmenu.js';
61
+ import { DbusMenuExport } from './dbusmenuexport.js';
70
62
  import { useAppOrNull } from './appcontext.js';
71
63
  import { useTopLevelWindow, windowIdOf } from './windowid.js';
72
64
 
@@ -76,9 +68,6 @@ export const REGISTRAR_PATH = '/com/canonical/AppMenu/Registrar';
76
68
  const KDE_SERVICE_PROPERTY = '_KDE_NET_WM_APPMENU_SERVICE_NAME';
77
69
  const KDE_PATH_PROPERTY = '_KDE_NET_WM_APPMENU_OBJECT_PATH';
78
70
 
79
- /** dbusmenu's recursive layout struct: `(id, properties, children)`. */
80
- const LAYOUT_SIGNATURE = '(ia{sv}av)';
81
-
82
71
  /**
83
72
  * How long the registrar gets to answer.
84
73
  *
@@ -162,20 +151,13 @@ function ntkWindowOf(target) {
162
151
  * it, and the parts that are awkward (a panel restarting, a window closing
163
152
  * mid-call, no bus at all) are awkward in ways React has nothing to say about.
164
153
  */
165
- export class GlobalMenuExport {
154
+ export class GlobalMenuExport extends DbusMenuExport {
166
155
  constructor({ getMenus, onSelect, onAboutToShow, target, onChange }) {
167
- this.getMenus = getMenus;
168
- this.onSelect = onSelect;
169
- this.onAboutToShow = onAboutToShow;
156
+ super({ getMenus, onSelect, onAboutToShow });
170
157
  this.target = target;
171
158
  this.onChange = onChange ?? (() => {});
172
159
 
173
- this.alloc = new IdAllocator();
174
- this.nodes = snapshot(getMenus(), this.alloc);
175
- this.revision = 1;
176
-
177
160
  this.stopped = false;
178
- this.exported = false;
179
161
  /** The in-flight `sync()`, which the next one queues behind. */
180
162
  this.syncing = null;
181
163
  this.ref = null;
@@ -443,190 +425,6 @@ export class GlobalMenuExport {
443
425
  await this.ref?.release();
444
426
  this.ref = null;
445
427
  }
446
-
447
- // ----------------------------------------------------------------- update
448
-
449
- /**
450
- * A new `menus` array. Serialise, diff, and send the *narrower* of the two
451
- * signals the protocol has — see `diffSnapshots`, where the choice is made
452
- * and why it matters.
453
- */
454
- update(menus) {
455
- const next = snapshot(menus, this.alloc);
456
- const change = diffSnapshots(this.nodes, next);
457
- this.nodes = next;
458
- if (!this.exported || change.kind === 'none') return;
459
-
460
- if (change.kind === 'properties') {
461
- this.iface?.emit.ItemsPropertiesUpdated(
462
- change.updated.map(([id, props]) => [id, this.wrap(props)]),
463
- change.removed,
464
- );
465
- return;
466
- }
467
- this.revision += 1;
468
- this.iface?.emit.LayoutUpdated(this.revision, change.parent);
469
- }
470
-
471
- /** Plain property values → the `a{sv}` the wire wants. */
472
- wrap(props) {
473
- const out = {};
474
- for (const [name, value] of Object.entries(props)) {
475
- const type = PROPERTY_TYPES[name];
476
- if (type) out[name] = new this.dbus.Variant(type, value);
477
- }
478
- return out;
479
- }
480
-
481
- /** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
482
- wrapLayout(node) {
483
- const [id, props, children] = node;
484
- return [
485
- id,
486
- this.wrap(props),
487
- children.map(
488
- (child) =>
489
- new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
490
- ),
491
- ];
492
- }
493
-
494
- // --------------------------------------------------------------- protocol
495
-
496
- itemFor(id) {
497
- return this.nodes.get(id)?.item ?? null;
498
- }
499
-
500
- defineMenu(dbus) {
501
- this.dbus = dbus;
502
- const fire = (id, eventId) => {
503
- const item = this.itemFor(id);
504
- if (!item) return;
505
- if (eventId === 'clicked') this.onSelect?.(item);
506
- else if (eventId === 'opened') this.onAboutToShow?.(item);
507
- };
508
-
509
- return dbus.defineInterface({
510
- name: DBUSMENU_IFACE,
511
- methods: {
512
- GetLayout: {
513
- in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
514
- out: { revision: 'u', layout: LAYOUT_SIGNATURE },
515
- handler: ({ parentId, recursionDepth, propertyNames }) => {
516
- const tree = layoutOf(
517
- this.nodes,
518
- parentId,
519
- recursionDepth ?? -1,
520
- propertyNames,
521
- );
522
- // An id the shell remembers from before a structural change is the
523
- // normal way to arrive here, not a protocol violation: answer with
524
- // an empty item rather than an error, and let the LayoutUpdated it
525
- // has already been sent bring it back for the real tree.
526
- return {
527
- revision: this.revision,
528
- layout: this.wrapLayout(tree ?? [parentId, {}, []]),
529
- };
530
- },
531
- },
532
- GetGroupProperties: {
533
- in: { ids: 'ai', propertyNames: 'as' },
534
- out: { properties: 'a(ia{sv})' },
535
- handler: ({ ids, propertyNames }) =>
536
- groupProperties(this.nodes, ids, propertyNames).map(
537
- ([id, props]) => [id, this.wrap(props)],
538
- ),
539
- },
540
- GetProperty: {
541
- in: { id: 'i', name: 's' },
542
- out: { value: 'v' },
543
- handler: ({ id, name }) => {
544
- const props = this.nodes.get(id)?.props;
545
- // `hasOwn` on both, so a name like `constructor` reads as absent
546
- // rather than as a function that then fails to marshal.
547
- const known = Object.hasOwn(PROPERTY_TYPES, name);
548
- const value =
549
- props && Object.hasOwn(props, name) ? props[name] : undefined;
550
- // A property this item does not carry is at its default, and the
551
- // spec's own advice is to answer with it rather than to error.
552
- if (value === undefined || !known) return new dbus.Variant('s', '');
553
- return new dbus.Variant(PROPERTY_TYPES[name], value);
554
- },
555
- },
556
- Event: {
557
- in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
558
- out: {},
559
- handler: ({ id, eventId }) => fire(id, eventId),
560
- },
561
- EventGroup: {
562
- in: { events: 'a(isvu)' },
563
- out: { idErrors: 'ai' },
564
- handler: ({ events }) => {
565
- const errors = [];
566
- for (const [id, eventId] of events ?? []) {
567
- if (this.itemFor(id)) fire(id, eventId);
568
- else errors.push(id);
569
- }
570
- return errors;
571
- },
572
- },
573
- AboutToShow: {
574
- in: { id: 'i' },
575
- out: { needUpdate: 'b' },
576
- handler: ({ id }) => this.aboutToShow(id),
577
- },
578
- AboutToShowGroup: {
579
- in: { ids: 'ai' },
580
- out: { updatesNeeded: 'ai', idErrors: 'ai' },
581
- handler: ({ ids }) => {
582
- const errors = [];
583
- for (const id of ids ?? []) {
584
- if (this.itemFor(id)) this.aboutToShow(id);
585
- else errors.push(id);
586
- }
587
- return { updatesNeeded: [], idErrors: errors };
588
- },
589
- },
590
- },
591
- properties: {
592
- Version: { type: 'u', access: 'read', get: () => 3 },
593
- Status: { type: 's', access: 'read', get: () => 'normal' },
594
- TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
595
- // Empty, and not a placeholder: `icon-name` is looked up in the
596
- // desktop's own theme, which is where an app's icons should come from.
597
- // A path here would be for icons shipped beside the app.
598
- IconThemePath: { type: 'as', access: 'read', get: () => [] },
599
- },
600
- signals: {
601
- LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
602
- ItemsPropertiesUpdated: {
603
- args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
604
- },
605
- ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
606
- },
607
- });
608
- }
609
-
610
- /**
611
- * The reply has to go out **now**, and React has not rendered yet.
612
- *
613
- * `setState` from here does not produce a new tree before this function
614
- * returns, so there is no honest way to answer `true` — and answering `true`
615
- * dishonestly is worse than answering `false`: the shell then blocks on a
616
- * `GetLayout` for a subtree that has not been built, gets the old one, and
617
- * caches it.
618
- *
619
- * So: answer `false`, let the handler run, and let the ordinary update path
620
- * emit `LayoutUpdated` when the new items actually serialise. Shells listen
621
- * for that unconditionally — it is the same signal a menu changing while
622
- * open produces — so a lazily-built submenu still fills in, one round trip
623
- * later than a synchronous toolkit would manage.
624
- */
625
- aboutToShow(id) {
626
- const item = this.itemFor(id);
627
- if (item) this.onAboutToShow?.(item);
628
- return false;
629
- }
630
428
  }
631
429
 
632
430
  /**
@@ -250,7 +250,7 @@ export function acquireImageSource(app, key, load) {
250
250
  entry.promise = null;
251
251
  // every holder unmounted while it decoded — free, don't adopt
252
252
  if (entry.released) {
253
- image?.destroy();
253
+ freeImage(app, image);
254
254
  return null;
255
255
  }
256
256
  entry.image = image;
@@ -269,10 +269,23 @@ export function releaseImageSource(app, entry) {
269
269
  if (--entry.refs > 0) return;
270
270
  sourceCaches.get(app)?.delete(entry.key);
271
271
  entry.released = true;
272
- entry.image?.destroy();
272
+ freeImage(app, entry.image);
273
273
  entry.image = null;
274
274
  }
275
275
 
276
+ /**
277
+ * Free an `Image` this module or a node owns, on every backend it may have
278
+ * been drawn on: ntk's `destroy()` frees the pixmaps it uploaded per X
279
+ * connection, and knows nothing of an upload a backend keeps for itself —
280
+ * the Cocoa app's CG bitmap — which that app's `releaseImage` seam frees.
281
+ * An X app has no such seam, so there it is `destroy()` alone.
282
+ */
283
+ export function freeImage(app, image) {
284
+ if (!image) return;
285
+ image.destroy();
286
+ app?.releaseImage?.(image);
287
+ }
288
+
276
289
  // --- server-side sources ----------------------------------------------------
277
290
 
278
291
  /** RENDER's depth-implied standard formats — the ones a drawable can be
package/src/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from './types/screencolor.js';
25
25
  export * from './types/appearance.js';
26
26
  export * from './types/fonts.js';
27
27
  export * from './types/system.js';
28
+ export * from './types/capabilities.js';
28
29
  export * from './types/launcher.js';
29
30
  export * from './types/tray.js';
30
31
  export * from './types/permissions.js';
package/src/index.js CHANGED
@@ -14,9 +14,15 @@ export {
14
14
  registerApplication,
15
15
  } from './application.js';
16
16
  export { useAppActivate, useAppOpen } from './apphooks.js';
17
- export { setBadge } from './launcher.js';
18
- export { useBadge, useDockMenu } from './launcherhooks.js';
17
+ export { setBadge, setProgress, setQuicklist, setUrgent } from './launcher.js';
18
+ export { useBadge, useDockMenu, useProgress } from './launcherhooks.js';
19
19
  export { useTray } from './trayhooks.js';
20
+ export {
21
+ CAPABILITIES,
22
+ NO_CAPABILITY,
23
+ desktopCapability,
24
+ } from './capabilities.js';
25
+ export { useDesktopCapability } from './desktopcapabilityhooks.js';
20
26
  export {
21
27
  NoPermissionServiceError,
22
28
  openPrivacySettings,