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.
package/src/launcher.js CHANGED
@@ -1,35 +1,55 @@
1
- // The launcher's view of the app: the badge on its icon.
1
+ // The launcher's view of the app: the badge on its icon, a progress bar
2
+ // across it, an attention flag, and the menu behind a right-click.
2
3
  //
3
4
  // A count on the Dock tile, a dot on the taskbar entry — the one thing an
4
5
  // app says to the desktop that the user reads without opening it. Two
5
- // desktops, two mechanisms, one call:
6
+ // desktops, two mechanisms, one call each:
6
7
  //
7
- // 1. **the app's own tile** — the cocoa backend's `NSDockTile.badgeLabel`,
8
- // reached through the app object (`setDockBadge`, src/cocoa/app.js).
9
- // Any string shows, and it is the whole of the API on a Mac.
8
+ // 1. **the app's own tile** — the cocoa backend's `NSDockTile`, reached
9
+ // through the app object (`setDockBadge`, `setDockMenu`, src/cocoa/
10
+ // app.js).
10
11
  // 2. **`com.canonical.Unity.LauncherEntry`** over the session bus — the
11
- // protocol Unity defined and the KDE, elementary and Cairo-Dock
12
- // launchers still listen for. One signal, `Update(app_uri, a{sv})`,
13
- // carrying a `count` and whether it is visible, attributed to the app
14
- // by `application://<id>.desktop` — which is why it needs the identity
12
+ // protocol Unity defined and the KDE, elementary, Cairo-Dock and
13
+ // Dash-to-Dock launchers still listen for. One signal,
14
+ // `Update(app_uri, a{sv})`, attributed to the app by
15
+ // `application://<id>.desktop` — which is why it needs the identity
15
16
  // `registerApplication({ appId })` established: with no app id there is
16
- // nothing for a launcher to pin the count to, and the call resolves
17
+ // nothing for a launcher to pin anything to, and the calls resolve
17
18
  // false rather than guessing.
18
19
  //
19
- // The two disagree on what a badge *is*, and the API takes the stricter
20
- // shape: a **count**. A number shows on both; a string shows on macOS and is
21
- // a visible count of nothing on Linux (the protocol has no text field), so
22
- // pass a string only where the Mac is the audience. `0`, `null` and `''`
23
- // all clear it, because a badge of zero is the badge nobody wanted.
20
+ // The badge takes the stricter of the two shapes: a **count**. A number shows
21
+ // on both; a string shows on macOS and is a visible count of nothing on Linux
22
+ // (the protocol has no text field), so pass a string only where the Mac is
23
+ // the audience. `0`, `null` and `''` all clear it, because a badge of zero is
24
+ // the badge nobody wanted.
24
25
  //
25
- // Nothing here imports react: `useBadge` (launcherhooks.js) is the hook, and
26
- // this is the function under it, callable from host-side code with no tree.
27
- // The entry on the bus is held for as long as the badge is shown and
28
- // released when it is cleareda held bus ref is a ref()'d socket, and an
29
- // app that cleared its badge on the way out should be free to exit.
26
+ // ## The quicklist is not "desktop actions"
27
+ //
28
+ // This module used to say the freedesktop counterpart of the Dock menu was
29
+ // `Actions=` in the `.desktop` filean install step, not runtime code and
30
+ // that the hook was therefore cocoa-only. That was half the story and the
31
+ // wrong half. `LauncherEntry` carries a **`quicklist`** property: an object
32
+ // path to a `com.canonical.dbusmenu` tree, which is the same menu protocol
33
+ // the global menu and the tray already speak, built from the same items. It
34
+ // is runtime, it follows state, and Dash-to-Dock/ubuntu-dock, Plank and the
35
+ // Unity heritage launchers all render it.
36
+ //
37
+ // So `useDockMenu()` has a Linux rung after all, and the three menus an app
38
+ // puts on the desktop — panel, tray, launcher — are now one authoring model
39
+ // on both backends. `.desktop` actions are still the right place for entries
40
+ // that must work *while the app is not running*; they are a different feature
41
+ // wearing a similar hat.
42
+ //
43
+ // Nothing here imports react: `useBadge`/`useDockMenu` (launcherhooks.js) are
44
+ // the hooks, and these are the functions under them, callable from host-side
45
+ // code with no tree. The entry on the bus is held for as long as anything is
46
+ // shown and released when the last of it is cleared — a held bus ref is a
47
+ // ref()'d socket, and an app that cleared its badge on the way out should be
48
+ // free to exit.
30
49
 
31
50
  import { currentRegistration } from './application.js';
32
51
  import { loadTransport, sessionBus } from './bus.js';
52
+ import { DbusMenuExport } from './dbusmenuexport.js';
33
53
  import { liveApps } from './trace-registry.js';
34
54
 
35
55
  export const LAUNCHER_ENTRY_IFACE = 'com.canonical.Unity.LauncherEntry';
@@ -78,6 +98,29 @@ function soleApp() {
78
98
  /** The one exported entry per process — an app has one icon. */
79
99
  let entry = null;
80
100
 
101
+ /**
102
+ * What the launcher currently believes, so an `Update` can carry the whole of
103
+ * it. The protocol's dict is a *patch* and every launcher merges it, but
104
+ * sending the full state makes the call idempotent and means a launcher that
105
+ * restarted and missed a patch is corrected by the next one of any kind.
106
+ */
107
+ function blankState() {
108
+ return {
109
+ count: 0,
110
+ countVisible: false,
111
+ progress: 0,
112
+ progressVisible: false,
113
+ urgent: false,
114
+ };
115
+ }
116
+
117
+ /** Is anything still being shown? When not, the entry can go. */
118
+ function stateIsEmpty(state, hasMenu) {
119
+ return (
120
+ !hasMenu && !state.countVisible && !state.progressVisible && !state.urgent
121
+ );
122
+ }
123
+
81
124
  async function launcherEntry(appUri) {
82
125
  if (entry && entry.appUri === appUri) return entry;
83
126
  if (entry) await releaseEntry();
@@ -104,7 +147,17 @@ async function launcherEntry(appUri) {
104
147
  await ref.release();
105
148
  return null;
106
149
  }
107
- entry = { appUri, ref, dbus, iface, registration };
150
+ entry = {
151
+ appUri,
152
+ ref,
153
+ dbus,
154
+ iface,
155
+ registration,
156
+ state: blankState(),
157
+ menu: null,
158
+ menuRegistration: null,
159
+ menuPath: `${launcherEntryPath(appUri)}/Menu`,
160
+ };
108
161
  return entry;
109
162
  }
110
163
 
@@ -112,10 +165,53 @@ async function releaseEntry() {
112
165
  const held = entry;
113
166
  entry = null;
114
167
  if (!held) return;
168
+ await held.menuRegistration?.remove?.().catch(() => {});
115
169
  await held.registration?.remove?.().catch(() => {});
116
170
  await held.ref.release();
117
171
  }
118
172
 
173
+ /** Emit the whole of the current state under the app's URI. */
174
+ function emit(held) {
175
+ const V = held.dbus.Variant;
176
+ const props = {
177
+ count: new V('x', held.state.count),
178
+ 'count-visible': new V('b', held.state.countVisible),
179
+ progress: new V('d', held.state.progress),
180
+ 'progress-visible': new V('b', held.state.progressVisible),
181
+ urgent: new V('b', held.state.urgent),
182
+ };
183
+ // Only advertised when there is one: a path to an object that is not
184
+ // exported makes a launcher build a menu client against nothing, and
185
+ // Dash-to-Dock keeps the stale client until the path *changes*.
186
+ if (held.menuRegistration) props.quicklist = new V('o', held.menuPath);
187
+ held.iface.emit.Update(held.appUri, props);
188
+ }
189
+
190
+ /**
191
+ * The shared half of every setter: find the identity, take or make the entry,
192
+ * let `mutate` move the state, emit, and drop the entry if nothing is left.
193
+ *
194
+ * `mutate` returns false to mean "nothing changed and nothing was being
195
+ * shown" — the case where clearing something that was already clear must not
196
+ * dial the bus.
197
+ */
198
+ async function withEntry(mutate, { clearing }) {
199
+ const appId = currentRegistration()?.appId;
200
+ if (!appId) {
201
+ if (clearing && entry) await releaseEntry();
202
+ return false;
203
+ }
204
+ if (clearing && !entry) return false; // nothing shown, nothing to clear
205
+ const held = await launcherEntry(launcherAppUri(appId));
206
+ if (!held) return false;
207
+ mutate(held.state);
208
+ emit(held);
209
+ if (stateIsEmpty(held.state, Boolean(held.menuRegistration))) {
210
+ await releaseEntry();
211
+ }
212
+ return true;
213
+ }
214
+
119
215
  /**
120
216
  * Show `value` on the app's icon, or clear it.
121
217
  *
@@ -144,23 +240,130 @@ export async function setBadge(value, { app } = {}) {
144
240
  }
145
241
 
146
242
  // Rung 2: the launcher protocol, which needs an identity to badge.
243
+ const count =
244
+ typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0;
245
+ return withEntry(
246
+ (state) => {
247
+ state.count = label === null ? 0 : count;
248
+ state.countVisible = label !== null;
249
+ },
250
+ { clearing: label === null },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * A progress bar across the app's icon: `0`…`1`, or `null` to clear it.
256
+ *
257
+ * ```js
258
+ * await setProgress(downloaded / total);
259
+ * await setProgress(null); // done
260
+ * ```
261
+ *
262
+ * The launcher protocol's `progress`, which ubuntu-dock, Plank and the
263
+ * Unity-heritage launchers draw across the tile. **Inert on the cocoa
264
+ * backend**: `NSDockTile` has no progress of its own — an app that wants one
265
+ * on a Mac draws it into a custom tile view, which is app-side art rather
266
+ * than a call. Resolves to whether a launcher was told.
267
+ */
268
+ export async function setProgress(value, { app } = {}) {
269
+ const target = app ?? soleApp();
270
+ // No cocoa rung to try: the Dock has no progress. Left deliberately without
271
+ // a `setDockProgress` probe so that adding one to the bridge later is the
272
+ // only change needed here.
273
+ void target;
274
+ const clearing =
275
+ value == null || value === false || !Number.isFinite(Number(value));
276
+ const clamped = clearing ? 0 : Math.min(1, Math.max(0, Number(value)));
277
+ return withEntry(
278
+ (state) => {
279
+ state.progress = clamped;
280
+ state.progressVisible = !clearing;
281
+ },
282
+ { clearing },
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Ask the launcher for the user's attention, or stop asking.
288
+ *
289
+ * The launcher protocol's `urgent`, which is the tile bouncing or glowing.
290
+ * Distinct from `<window states={['demands_attention']}>`, which is the
291
+ * *window's* urgency hint and what a taskbar blinks: this one marks the app's
292
+ * icon in the launcher whether or not any window is open. On the cocoa
293
+ * backend the window state is the mechanism and this is inert.
294
+ */
295
+ export async function setUrgent(urgent, { app } = {}) {
296
+ void (app ?? soleApp());
297
+ return withEntry((state) => void (state.urgent = Boolean(urgent)), {
298
+ clearing: !urgent,
299
+ });
300
+ }
301
+
302
+ /**
303
+ * The menu behind a right-click on the app's launcher icon — the quicklist.
304
+ *
305
+ * Takes `MenuBar`'s item vocabulary and exports it as a `com.canonical.dbusmenu`
306
+ * tree, exactly as the tray and the global menu do. `null` takes it down.
307
+ * Resolves to whether a launcher was told.
308
+ *
309
+ * The menu is exported **before** the property naming it is emitted, so a
310
+ * launcher that builds its client the instant it sees `quicklist` finds an
311
+ * object there.
312
+ */
313
+ export async function setQuicklist(items, { app } = {}) {
314
+ const target = app ?? soleApp();
315
+
316
+ // Rung 1: the app's own tile menu.
317
+ if (typeof target?.setDockMenu === 'function') {
318
+ target.setDockMenu(items ?? null);
319
+ return true;
320
+ }
321
+
147
322
  const appId = currentRegistration()?.appId;
148
323
  if (!appId) {
149
- if (label === null) await releaseEntry();
324
+ if (!items && entry) await releaseEntry();
150
325
  return false;
151
326
  }
152
- const appUri = launcherAppUri(appId);
153
- if (label === null && !entry) return false; // nothing shown, nothing to clear
154
- const held = await launcherEntry(appUri);
327
+ if (!items) {
328
+ // Taking the menu down: drop the export, then say so.
329
+ if (!entry?.menuRegistration) return false;
330
+ const held = entry;
331
+ const registration = held.menuRegistration;
332
+ held.menuRegistration = null;
333
+ held.menu = null;
334
+ await registration?.remove?.().catch(() => {});
335
+ // `quicklist` is omitted rather than set empty — there is no "no menu"
336
+ // value in the protocol, and an object path to nothing is worse than a
337
+ // property a launcher never saw.
338
+ emit(held);
339
+ if (stateIsEmpty(held.state, false)) await releaseEntry();
340
+ return true;
341
+ }
342
+
343
+ const held = await launcherEntry(launcherAppUri(appId));
155
344
  if (!held) return false;
156
- const count =
157
- typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : 0;
158
- const V = held.dbus.Variant;
159
- held.iface.emit.Update(appUri, {
160
- count: new V('x', label === null ? 0 : count),
161
- 'count-visible': new V('b', label !== null),
345
+
346
+ if (held.menu) {
347
+ // Already exported: this is a re-render, and the tree diffs itself.
348
+ held.menu.update(items);
349
+ return true;
350
+ }
351
+
352
+ const menu = new DbusMenuExport({
353
+ getMenus: () => items,
354
+ onSelect: (item) => item.onSelect?.(),
355
+ onAboutToShow: (item) => item.onAboutToShow?.(),
162
356
  });
163
- if (label === null) await releaseEntry();
357
+ const iface = menu.defineMenu(held.dbus);
358
+ try {
359
+ held.menuRegistration = await held.ref.bus.export(held.menuPath, iface);
360
+ } catch {
361
+ return false;
362
+ }
363
+ menu.iface = iface;
364
+ menu.exported = true;
365
+ held.menu = menu;
366
+ emit(held);
164
367
  return true;
165
368
  }
166
369
 
@@ -1,17 +1,21 @@
1
- // `useBadge()` and `useDockMenu()` — the launcher's view of the app, as
2
- // things a component declares rather than manages.
1
+ // `useBadge()`, `useProgress()` and `useDockMenu()` — the launcher's view of
2
+ // the app, as things a component declares rather than manages.
3
3
  //
4
- // The badge lives in `launcher.js` and is cross-backend; the Dock menu is
5
- // the cocoa backend's alone, because its freedesktop counterpart desktop
6
- // actions in the `.desktop` file is an install step and not runtime code.
7
- // Where a hook has no mechanism it is inert, with a one-time development
8
- // note naming the backend: the inert-props policy of docs/macos.md, so
9
- // shared app code stays branch-free.
4
+ // All three now have a rung on both backends except progress, which has one
5
+ // only on Linux (`NSDockTile` has no progress bar; see `setProgress`). Where
6
+ // a hook has no mechanism it is inert and silent: a mark on an icon is not a
7
+ // feature an app should branch on, and a development warning for every
8
+ // desktop that lacks one is a warning nobody can act on.
9
+ //
10
+ // The one that changed shape is `useDockMenu`. It used to be cocoa-only on
11
+ // the grounds that the freedesktop counterpart was an install step — see
12
+ // `launcher.js`, where that reasoning is corrected: the launcher protocol
13
+ // carries a `quicklist` dbusmenu, so the Dock menu is runtime code on both.
10
14
 
11
15
  import { useEffect, useRef } from 'react';
12
16
 
13
17
  import { useAppOrNull } from './appcontext.js';
14
- import { setBadge } from './launcher.js';
18
+ import { setBadge, setProgress, setQuicklist } from './launcher.js';
15
19
 
16
20
  /**
17
21
  * Show `value` on the app's icon while this component is mounted, and clear
@@ -37,12 +41,31 @@ export function useBadge(value) {
37
41
  }, [app]);
38
42
  }
39
43
 
40
- let warnedInert = false;
44
+ /**
45
+ * A progress bar across the app's icon, `0`…`1`, cleared on unmount.
46
+ *
47
+ * ```jsx
48
+ * useProgress(done / total); // null or undefined clears it
49
+ * ```
50
+ *
51
+ * Linux launchers only — the Dock has no progress bar of its own.
52
+ */
53
+ export function useProgress(value) {
54
+ const app = useAppOrNull();
55
+ useEffect(() => {
56
+ setProgress(value, { app }).catch(() => {});
57
+ }, [value, app]);
58
+ useEffect(() => {
59
+ return () => {
60
+ setProgress(null, { app }).catch(() => {});
61
+ };
62
+ }, [app]);
63
+ }
41
64
 
42
65
  /**
43
- * The menu behind a right-click on the Dock icon, from the same `items`
44
- * vocabulary `MenuBar` and `ContextMenu` take — an item's `onSelect` fires
45
- * when the user picks it.
66
+ * The menu behind a right-click on the app's icon in the Dock or launcher,
67
+ * from the same `items` vocabulary `MenuBar` and `ContextMenu` take — an
68
+ * item's `onSelect` fires when the user picks it.
46
69
  *
47
70
  * ```jsx
48
71
  * useDockMenu([
@@ -53,8 +76,10 @@ let warnedInert = false;
53
76
  * ```
54
77
  *
55
78
  * Installed for as long as the component is mounted, replaced when `items`
56
- * changes, and taken down on unmount. Inert off the cocoa backend, with a
57
- * development note the first time.
79
+ * changes, and taken down on unmount. `NSDockTile`'s menu on the cocoa
80
+ * backend; the launcher protocol's `quicklist` on Linux, which needs the
81
+ * identity `registerApplication({ appId })` establishes and a `.desktop` file
82
+ * of that name for a launcher to attach it to.
58
83
  */
59
84
  export function useDockMenu(items) {
60
85
  const app = useAppOrNull();
@@ -64,18 +89,12 @@ export function useDockMenu(items) {
64
89
  live.current = items;
65
90
 
66
91
  useEffect(() => {
67
- if (typeof app?.setDockMenu !== 'function') {
68
- if (process.env.NODE_ENV !== 'production' && !warnedInert && app) {
69
- warnedInert = true;
70
- console.warn(
71
- 'react-x11: useDockMenu() is inert on this backend — only the ' +
72
- 'cocoa backend has a Dock. On a Linux desktop the counterpart ' +
73
- 'is desktop actions in the .desktop file (docs/desktop.md).',
74
- );
75
- }
76
- return undefined;
77
- }
78
- app.setDockMenu(items ?? null);
79
- return () => app.setDockMenu(null);
92
+ setQuicklist(items ?? null, { app }).catch(() => {});
80
93
  }, [items, app]);
94
+
95
+ useEffect(() => {
96
+ return () => {
97
+ setQuicklist(null, { app }).catch(() => {});
98
+ };
99
+ }, [app]);
81
100
  }
@@ -6,6 +6,7 @@ import {
6
6
  PictureSource,
7
7
  acquireImageSource,
8
8
  decodeImageSource,
9
+ freeImage,
9
10
  imageSourceChanged,
10
11
  isDirectImageSource,
11
12
  isPathImageSource,
@@ -215,7 +216,7 @@ export class ImageNode extends Node {
215
216
  }
216
217
  if (this._ownedImage) {
217
218
  // frees the per-app upload; the caller's own Images are never here
218
- this._ownedImage.destroy();
219
+ freeImage(this.app, this._ownedImage);
219
220
  this._ownedImage = null;
220
221
  }
221
222
  if (this._serverSource) {