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/package.json +2 -1
- package/src/application.js +25 -1
- package/src/capabilities.js +324 -0
- package/src/cocoa/app.js +13 -0
- package/src/cocoa/context2d.js +116 -6
- package/src/dbusmenuexport.js +243 -0
- package/src/desktopcapabilityhooks.js +137 -0
- package/src/globalmenu.js +3 -205
- package/src/imagesource.js +15 -2
- package/src/index.d.ts +1 -0
- package/src/index.js +8 -2
- package/src/launcher.js +235 -32
- package/src/launcherhooks.js +47 -28
- package/src/nodes/image.js +2 -1
- package/src/statusnotifier.js +605 -0
- package/src/trayhooks.js +177 -29
- package/src/types/capabilities.d.ts +127 -0
- package/src/types/launcher.d.ts +50 -4
- package/src/types/tray.d.ts +52 -6
|
@@ -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
|
+
}
|