react-x11 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
// The global menu: handing a window's menu bar to the desktop's panel.
|
|
2
|
+
//
|
|
3
|
+
// On a desktop that shows application menus in its own panel — Unity's
|
|
4
|
+
// heritage, KDE Plasma's Application Menu widget, `vala-panel-appmenu`,
|
|
5
|
+
// several GNOME extensions — an app that draws its own menu bar is drawing a
|
|
6
|
+
// second one. This module notices, exports the menu over
|
|
7
|
+
// `com.canonical.dbusmenu` (`dbusmenu.js`), and tells `MenuBar` to stop
|
|
8
|
+
// drawing. **Nothing is asked of the app author**: `<MenuBar menus={…}/>` is
|
|
9
|
+
// the whole of the integration, and it is the same line either way.
|
|
10
|
+
//
|
|
11
|
+
// ## Detection is "is a panel running", not "could one be started"
|
|
12
|
+
//
|
|
13
|
+
// This is the one place where the rule `hasService()` follows is exactly
|
|
14
|
+
// wrong, and the mistake is silent and total — an app with no menu at all.
|
|
15
|
+
//
|
|
16
|
+
// `hasService()` counts an **activatable** name as present, and it is right to:
|
|
17
|
+
// `org.freedesktop.portal.Desktop` ships a `.service` file, so on a healthy
|
|
18
|
+
// GNOME session where nothing has opened a file dialog yet the name has no
|
|
19
|
+
// owner, and a feature gated on ownership would take the fallback path
|
|
20
|
+
// forever. Activation is what makes it appear.
|
|
21
|
+
//
|
|
22
|
+
// The registrar is the mirror image. Ubuntu ships
|
|
23
|
+
// `com.canonical.AppMenu.Registrar` as an activatable service too, but a
|
|
24
|
+
// registrar is not the feature — it is a *directory* the panel reads. Nothing
|
|
25
|
+
// activates it except a panel starting up, and starting one ourselves would
|
|
26
|
+
// leave the menu registered with a registrar that no panel is reading. The bar
|
|
27
|
+
// would vanish from the window and appear nowhere else.
|
|
28
|
+
//
|
|
29
|
+
// So: **a live owner, and only a live owner.** Which is also why the answer is
|
|
30
|
+
// not cached — a panel is exactly the kind of thing that gets restarted, and
|
|
31
|
+
// `NameOwnerChanged` moves the menu back into the window and out again with
|
|
32
|
+
// it.
|
|
33
|
+
//
|
|
34
|
+
// ## Why the KDE properties are set *as well*, not instead
|
|
35
|
+
//
|
|
36
|
+
// Plasma's applet reads `_KDE_NET_WM_APPMENU_SERVICE_NAME` and
|
|
37
|
+
// `_KDE_NET_WM_APPMENU_OBJECT_PATH` off the window rather than asking a
|
|
38
|
+
// registrar. They are not an alternative route for desktops without a
|
|
39
|
+
// registrar: Plasma runs one *and* reads the properties, and which of the two
|
|
40
|
+
// a given version prefers is not something to bet a menu on. They cost two
|
|
41
|
+
// `ChangeProperty` round trips.
|
|
42
|
+
//
|
|
43
|
+
// ## Where it deliberately does nothing
|
|
44
|
+
//
|
|
45
|
+
// Stock GNOME has no global menu bar, so nothing owns the registrar name,
|
|
46
|
+
// `exported` stays false and the in-window bar stays. That is not a
|
|
47
|
+
// degradation and it needs no code — but it does need saying, or
|
|
48
|
+
// "my global menu doesn't show under GNOME" is the first issue filed.
|
|
49
|
+
//
|
|
50
|
+
// We also do **not** export `org.gtk.Menus`/`org.gtk.Actions`. Handling both
|
|
51
|
+
// serialisations is right for a *host* and wrong for an app: traffic flows
|
|
52
|
+
// towards dbusmenu, not away from it — Plasma ships `gmenu-dbusmenu-proxy` to
|
|
53
|
+
// convert GTK's menus *into* dbusmenu, and the GNOME extensions that render
|
|
54
|
+
// third-party menus consume dbusmenu. A second exporter is roughly double the
|
|
55
|
+
// work for no consumer that the first one does not already reach.
|
|
56
|
+
|
|
57
|
+
import { useEffect, useRef, useState } from 'react';
|
|
58
|
+
|
|
59
|
+
import { loadTransport, sessionBus } from './bus.js';
|
|
60
|
+
import {
|
|
61
|
+
DBUSMENU_IFACE,
|
|
62
|
+
PROPERTY_TYPES,
|
|
63
|
+
diffSnapshots,
|
|
64
|
+
groupProperties,
|
|
65
|
+
layoutOf,
|
|
66
|
+
snapshot,
|
|
67
|
+
IdAllocator,
|
|
68
|
+
} from './dbusmenu.js';
|
|
69
|
+
import { useTopLevelWindow, windowIdOf } from './windowid.js';
|
|
70
|
+
|
|
71
|
+
export const REGISTRAR_NAME = 'com.canonical.AppMenu.Registrar';
|
|
72
|
+
export const REGISTRAR_PATH = '/com/canonical/AppMenu/Registrar';
|
|
73
|
+
|
|
74
|
+
const KDE_SERVICE_PROPERTY = '_KDE_NET_WM_APPMENU_SERVICE_NAME';
|
|
75
|
+
const KDE_PATH_PROPERTY = '_KDE_NET_WM_APPMENU_OBJECT_PATH';
|
|
76
|
+
|
|
77
|
+
/** dbusmenu's recursive layout struct: `(id, properties, children)`. */
|
|
78
|
+
const LAYOUT_SIGNATURE = '(ia{sv}av)';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How long the registrar gets to answer.
|
|
82
|
+
*
|
|
83
|
+
* Short, and explicit, because dbus-native's default is 25 seconds and the
|
|
84
|
+
* reply timer is **not** unref'd: a panel that dies between our `ListNames`
|
|
85
|
+
* and our `RegisterWindow` — or one whose name we still hold when the window
|
|
86
|
+
* closes — would otherwise hold the event loop open long enough that an app
|
|
87
|
+
* quitting looks hung. There is nothing useful to do with a slow answer here
|
|
88
|
+
* either; the bar is drawn in the meantime and stays drawn.
|
|
89
|
+
*/
|
|
90
|
+
const REGISTRAR_TIMEOUT = 4000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `NO_AUTO_START`, D-Bus's own message flag, on every call we make to the
|
|
94
|
+
* registrar.
|
|
95
|
+
*
|
|
96
|
+
* Without it this feature starts the very thing it is careful never to start.
|
|
97
|
+
* `com.canonical.AppMenu.Registrar` is activatable on Ubuntu, and a method
|
|
98
|
+
* call to an unowned activatable name **launches the service** — so a panel
|
|
99
|
+
* that exits while a window is registered turns our tidy-up `UnregisterWindow`
|
|
100
|
+
* into `exec /usr/libexec/vala-panel/appmenu-registrar`. The name then has an
|
|
101
|
+
* owner again, every app on the session hands its menu over, and nothing is
|
|
102
|
+
* drawing any of them.
|
|
103
|
+
*
|
|
104
|
+
* Observed, not theorised: killing the host in `scripts/globalmenu-host.mjs`
|
|
105
|
+
* spawned Ubuntu's registrar from exactly this call.
|
|
106
|
+
*
|
|
107
|
+
* The liveness check below is the first line of defence and this is the
|
|
108
|
+
* second, because between the two there is a window in which the panel can
|
|
109
|
+
* die. A call that fails because nothing is there is the outcome we want:
|
|
110
|
+
* `exported` stays false and the menu stays in the window.
|
|
111
|
+
*/
|
|
112
|
+
const NO_AUTO_START = 2;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* D-Bus object paths are `[A-Za-z0-9_/]`, so the package's own name cannot
|
|
116
|
+
* appear in one — `react-x11` has a hyphen. Hence the underscore.
|
|
117
|
+
*/
|
|
118
|
+
const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The feature's off switch.
|
|
122
|
+
*
|
|
123
|
+
* An embedder that owns the toplevel, or an app that exports its own menu on
|
|
124
|
+
* its own terms, needs the renderer to keep its hands off. `MenuBar` also
|
|
125
|
+
* takes `globalMenu={false}` for one bar; this is the process-wide form, and
|
|
126
|
+
* it is the one that works without touching application code.
|
|
127
|
+
*/
|
|
128
|
+
function globalMenuEnabled() {
|
|
129
|
+
const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
|
|
130
|
+
return !flag || flag === '0';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Is a panel listening *right now*? See the header for why not `hasService`. */
|
|
134
|
+
async function registrarIsLive(bus) {
|
|
135
|
+
try {
|
|
136
|
+
const names = await bus.listNames();
|
|
137
|
+
return names.includes(REGISTRAR_NAME);
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The ntk window behind whatever `useTopLevelWindow()`/a ref is holding. */
|
|
144
|
+
function ntkWindowOf(target) {
|
|
145
|
+
const node = target && 'current' in target ? target.current : target;
|
|
146
|
+
if (!node) return null;
|
|
147
|
+
if (typeof node.setProperty === 'function') return node;
|
|
148
|
+
return node.window ?? node.root?.window ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One window's menu on the bus: the exported object, the registration, and the
|
|
153
|
+
* two X properties.
|
|
154
|
+
*
|
|
155
|
+
* Imperative and framework-free on purpose — `useGlobalMenu` is a thin lid on
|
|
156
|
+
* it, and the parts that are awkward (a panel restarting, a window closing
|
|
157
|
+
* mid-call, no bus at all) are awkward in ways React has nothing to say about.
|
|
158
|
+
*/
|
|
159
|
+
export class GlobalMenuExport {
|
|
160
|
+
constructor({ getMenus, onSelect, onAboutToShow, target, onChange }) {
|
|
161
|
+
this.getMenus = getMenus;
|
|
162
|
+
this.onSelect = onSelect;
|
|
163
|
+
this.onAboutToShow = onAboutToShow;
|
|
164
|
+
this.target = target;
|
|
165
|
+
this.onChange = onChange ?? (() => {});
|
|
166
|
+
|
|
167
|
+
this.alloc = new IdAllocator();
|
|
168
|
+
this.nodes = snapshot(getMenus(), this.alloc);
|
|
169
|
+
this.revision = 1;
|
|
170
|
+
|
|
171
|
+
this.stopped = false;
|
|
172
|
+
this.exported = false;
|
|
173
|
+
/** The in-flight `sync()`, which the next one queues behind. */
|
|
174
|
+
this.syncing = null;
|
|
175
|
+
this.ref = null;
|
|
176
|
+
this.registration = null;
|
|
177
|
+
this.subscription = null;
|
|
178
|
+
this.iface = null;
|
|
179
|
+
this.path = null;
|
|
180
|
+
this.xid = null;
|
|
181
|
+
this.window = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ------------------------------------------------------------------ setup
|
|
185
|
+
|
|
186
|
+
async start() {
|
|
187
|
+
if (!globalMenuEnabled()) return;
|
|
188
|
+
const ref = await sessionBus();
|
|
189
|
+
// No bus is a first-class configuration, not a degraded one: ssh, a bare
|
|
190
|
+
// startx, CI, Node 20 without the transport. The bar draws itself and
|
|
191
|
+
// nothing is logged.
|
|
192
|
+
if (!ref) return;
|
|
193
|
+
if (this.stopped) return void ref.release();
|
|
194
|
+
this.ref = ref;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await this.watchRegistrar();
|
|
198
|
+
await this.sync();
|
|
199
|
+
} catch {
|
|
200
|
+
// A desktop that answers the bus but not this protocol is not an error
|
|
201
|
+
// condition for an app that has a menu bar it can draw itself.
|
|
202
|
+
await this.teardown();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Follow the registrar's ownership for as long as this menu exists.
|
|
208
|
+
*
|
|
209
|
+
* `arg0` narrows the match to the one name, so the daemon does not wake this
|
|
210
|
+
* process for every service on the session appearing and disappearing.
|
|
211
|
+
*/
|
|
212
|
+
async watchRegistrar() {
|
|
213
|
+
const { bus } = this.ref;
|
|
214
|
+
const subscription = await bus.watch(
|
|
215
|
+
"type='signal',sender='org.freedesktop.DBus'," +
|
|
216
|
+
"interface='org.freedesktop.DBus',member='NameOwnerChanged'," +
|
|
217
|
+
`arg0='${REGISTRAR_NAME}'`,
|
|
218
|
+
);
|
|
219
|
+
// `AddMatch` is a round trip, and a window that opens and closes inside
|
|
220
|
+
// one — an unmount, a StrictMode remount — leaves `teardown()` already
|
|
221
|
+
// finished by the time it lands. Installing the rule and the listener now
|
|
222
|
+
// would leave both behind for good, since `stop()` is idempotent and has
|
|
223
|
+
// nothing left to run: the daemon would keep waking the process, and the
|
|
224
|
+
// listener would keep this exporter, its snapshot and every item's
|
|
225
|
+
// `onSelect` closure alive for the life of it.
|
|
226
|
+
if (this.stopped) {
|
|
227
|
+
await subscription.remove().catch(() => {});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.subscription = subscription;
|
|
231
|
+
const key = bus.mangle(
|
|
232
|
+
'/org/freedesktop/DBus',
|
|
233
|
+
'org.freedesktop.DBus',
|
|
234
|
+
'NameOwnerChanged',
|
|
235
|
+
);
|
|
236
|
+
this.onOwnerChanged = () => {
|
|
237
|
+
if (!this.stopped) this.sync().catch(() => {});
|
|
238
|
+
};
|
|
239
|
+
bus.signals.on(key, this.onOwnerChanged);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Bring the export into line with whether a panel is currently listening,
|
|
244
|
+
* **one at a time**.
|
|
245
|
+
*
|
|
246
|
+
* `exported` only turns true at the *end* of `publish()`, which is several
|
|
247
|
+
* awaits long — the transport import, the export, the registrar round trip.
|
|
248
|
+
* A panel that restarts quickly, or any two `NameOwnerChanged` signals close
|
|
249
|
+
* together, would otherwise put two `publish()` runs in flight against the
|
|
250
|
+
* same window: two `RegisterWindow` calls, and the first `iface` and
|
|
251
|
+
* registration orphaned by the second. `withdraw()` has the mirror version
|
|
252
|
+
* of it. Serialising is the whole fix, and it costs a promise.
|
|
253
|
+
*/
|
|
254
|
+
sync() {
|
|
255
|
+
// Two promises, deliberately. `syncing` is the queue and must never
|
|
256
|
+
// reject, or one failed sync would poison every later one; the returned
|
|
257
|
+
// promise is the caller's and must, or `start()` cannot tell that
|
|
258
|
+
// publishing failed and would leave a bus ref, a match rule and an orphan
|
|
259
|
+
// `com.canonical.dbusmenu` object behind for a registration that did not
|
|
260
|
+
// happen. The signal handler discards its own.
|
|
261
|
+
const done = (this.syncing ?? Promise.resolve()).then(
|
|
262
|
+
() => this._sync(),
|
|
263
|
+
() => this._sync(),
|
|
264
|
+
);
|
|
265
|
+
this.syncing = done.catch(() => {});
|
|
266
|
+
return done;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async _sync() {
|
|
270
|
+
if (this.stopped || !this.ref) return;
|
|
271
|
+
const live = await registrarIsLive(this.ref.bus);
|
|
272
|
+
if (this.stopped) return;
|
|
273
|
+
if (live && !this.exported) await this.publish();
|
|
274
|
+
else if (!live && this.exported) await this.withdraw();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async publish() {
|
|
278
|
+
const { bus } = this.ref;
|
|
279
|
+
// Resolved **once**, and both answers taken from the same read.
|
|
280
|
+
// `useTopLevelWindow()` is a live getter — it recomputes from the app's
|
|
281
|
+
// window list and the X focus every time — so asking again after the
|
|
282
|
+
// registrar round trip can name a different window than the xid just
|
|
283
|
+
// registered, and the KDE properties would land on the wrong one.
|
|
284
|
+
const node =
|
|
285
|
+
this.target && 'current' in this.target
|
|
286
|
+
? this.target.current
|
|
287
|
+
: this.target;
|
|
288
|
+
const xid = windowIdOf(node);
|
|
289
|
+
const wnd = ntkWindowOf(node);
|
|
290
|
+
// A window that is not realized yet has no id to register. `sync()` runs
|
|
291
|
+
// again on the next ownership change, and the effect that owns this runs
|
|
292
|
+
// after the commit that creates the window, so this is the "closed mid
|
|
293
|
+
// call" case rather than the "too early" one.
|
|
294
|
+
if (!xid) return;
|
|
295
|
+
|
|
296
|
+
const dbus = await loadTransport();
|
|
297
|
+
const path = menuPathFor(xid);
|
|
298
|
+
if (!dbus.isValidObjectPath(path)) return;
|
|
299
|
+
|
|
300
|
+
this.iface = this.defineMenu(dbus);
|
|
301
|
+
this.registration = await bus.export(path, this.iface);
|
|
302
|
+
this.path = path;
|
|
303
|
+
this.xid = xid;
|
|
304
|
+
|
|
305
|
+
// `exported` becomes true only once the registrar has *answered*, so the
|
|
306
|
+
// in-window bar disappears on evidence rather than on a guess. A registrar
|
|
307
|
+
// that refuses the call — or one that never answers — leaves the menu
|
|
308
|
+
// drawn where the user can reach it.
|
|
309
|
+
await bus.invoke(
|
|
310
|
+
{
|
|
311
|
+
destination: REGISTRAR_NAME,
|
|
312
|
+
path: REGISTRAR_PATH,
|
|
313
|
+
interface: REGISTRAR_NAME,
|
|
314
|
+
member: 'RegisterWindow',
|
|
315
|
+
signature: 'uo',
|
|
316
|
+
body: [xid, path],
|
|
317
|
+
flags: NO_AUTO_START,
|
|
318
|
+
},
|
|
319
|
+
{ timeout: REGISTRAR_TIMEOUT },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// The window can close, or the panel exit, while that call is in flight.
|
|
323
|
+
// Bailing *before* the properties are written is what keeps a torn-down
|
|
324
|
+
// window from being left advertising an object path that is about to be
|
|
325
|
+
// unexported — Plasma prefers those properties to the registrar, so it
|
|
326
|
+
// would show that window an empty menu for as long as it lives.
|
|
327
|
+
if (this.stopped) return void (await this.unpublish());
|
|
328
|
+
|
|
329
|
+
await this.setWindowProperties(wnd, bus.name, path);
|
|
330
|
+
if (this.stopped) return void (await this.unpublish());
|
|
331
|
+
this.exported = true;
|
|
332
|
+
this.onChange(true);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Plasma reads these instead of asking the registrar.
|
|
337
|
+
*
|
|
338
|
+
* Written as `STRING`/format 8 explicitly: ntk defaults a string property to
|
|
339
|
+
* `UTF8_STRING`, and KDE writes and expects `STRING`. A bus name and an
|
|
340
|
+
* object path are both ASCII by construction, so nothing is lost.
|
|
341
|
+
*
|
|
342
|
+
* The window is **remembered**, not looked up again when it is time to
|
|
343
|
+
* clear: `useTopLevelWindow()` answers from the tree's live window list, and
|
|
344
|
+
* by the time an unmount runs this module's cleanup the window it is asking
|
|
345
|
+
* about is already off it. Properties are cleared on the window they were
|
|
346
|
+
* set on or not at all.
|
|
347
|
+
*/
|
|
348
|
+
async setWindowProperties(wnd, serviceName, path) {
|
|
349
|
+
if (!wnd?.setProperty) return;
|
|
350
|
+
this.window = wnd;
|
|
351
|
+
const opts = { type: 'STRING', format: 8 };
|
|
352
|
+
await wnd.setProperty(KDE_SERVICE_PROPERTY, serviceName, opts);
|
|
353
|
+
await wnd.setProperty(KDE_PATH_PROPERTY, path, opts);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async clearWindowProperties() {
|
|
357
|
+
const wnd = this.window;
|
|
358
|
+
this.window = null;
|
|
359
|
+
if (!wnd?.deleteProperty) return;
|
|
360
|
+
// A window on its way out takes its properties with it, and asking the
|
|
361
|
+
// server about a dead one is an X error rather than an exception.
|
|
362
|
+
await wnd.deleteProperty(KDE_SERVICE_PROPERTY).catch(() => {});
|
|
363
|
+
await wnd.deleteProperty(KDE_PATH_PROPERTY).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The panel went away: take the menu back into the window. */
|
|
367
|
+
async withdraw() {
|
|
368
|
+
this.exported = false;
|
|
369
|
+
this.onChange(false);
|
|
370
|
+
await this.unpublish();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async unpublish() {
|
|
374
|
+
const bus = this.ref?.bus;
|
|
375
|
+
// Ask only a registrar that is still there. Half the time the reason we
|
|
376
|
+
// are unpublishing is that it went away, and a call to a name nobody owns
|
|
377
|
+
// would start one — see NO_AUTO_START.
|
|
378
|
+
if (bus && this.xid != null && (await registrarIsLive(bus))) {
|
|
379
|
+
await bus
|
|
380
|
+
.invoke(
|
|
381
|
+
{
|
|
382
|
+
destination: REGISTRAR_NAME,
|
|
383
|
+
path: REGISTRAR_PATH,
|
|
384
|
+
interface: REGISTRAR_NAME,
|
|
385
|
+
member: 'UnregisterWindow',
|
|
386
|
+
signature: 'u',
|
|
387
|
+
body: [this.xid],
|
|
388
|
+
flags: NO_AUTO_START,
|
|
389
|
+
},
|
|
390
|
+
{ timeout: REGISTRAR_TIMEOUT },
|
|
391
|
+
)
|
|
392
|
+
.catch(() => {});
|
|
393
|
+
}
|
|
394
|
+
await this.clearWindowProperties();
|
|
395
|
+
await this.registration?.remove().catch(() => {});
|
|
396
|
+
this.registration = null;
|
|
397
|
+
this.iface = null;
|
|
398
|
+
this.path = null;
|
|
399
|
+
this.xid = null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async stop() {
|
|
403
|
+
if (this.stopped) return;
|
|
404
|
+
// Set first, so a publish already in flight sees it and undoes itself at
|
|
405
|
+
// its next checkpoint rather than finishing.
|
|
406
|
+
this.stopped = true;
|
|
407
|
+
// Then wait for it. `teardown()` reads `registration`, `xid` and `window`
|
|
408
|
+
// across several awaits of its own, so running it *beside* a publish lets
|
|
409
|
+
// each undo the other's work: the classic end state is `exported === true`
|
|
410
|
+
// with the object unexported and the window unregistered — no bar in the
|
|
411
|
+
// window, nothing in the panel, and no event left that could recover it.
|
|
412
|
+
await this.syncing?.catch(() => {});
|
|
413
|
+
await this.teardown();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async teardown() {
|
|
417
|
+
if (this.exported) {
|
|
418
|
+
this.exported = false;
|
|
419
|
+
this.onChange(false);
|
|
420
|
+
}
|
|
421
|
+
await this.unpublish();
|
|
422
|
+
if (this.subscription) {
|
|
423
|
+
const key = this.ref?.bus.mangle(
|
|
424
|
+
'/org/freedesktop/DBus',
|
|
425
|
+
'org.freedesktop.DBus',
|
|
426
|
+
'NameOwnerChanged',
|
|
427
|
+
);
|
|
428
|
+
if (key && this.onOwnerChanged) {
|
|
429
|
+
this.ref.bus.signals.removeListener(key, this.onOwnerChanged);
|
|
430
|
+
}
|
|
431
|
+
await this.subscription.remove().catch(() => {});
|
|
432
|
+
this.subscription = null;
|
|
433
|
+
}
|
|
434
|
+
// Dropped as well as removed: it closes over this exporter, so leaving it
|
|
435
|
+
// on the instance keeps the snapshot and every item's handler reachable.
|
|
436
|
+
this.onOwnerChanged = undefined;
|
|
437
|
+
await this.ref?.release();
|
|
438
|
+
this.ref = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ----------------------------------------------------------------- update
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A new `menus` array. Serialise, diff, and send the *narrower* of the two
|
|
445
|
+
* signals the protocol has — see `diffSnapshots`, where the choice is made
|
|
446
|
+
* and why it matters.
|
|
447
|
+
*/
|
|
448
|
+
update(menus) {
|
|
449
|
+
const next = snapshot(menus, this.alloc);
|
|
450
|
+
const change = diffSnapshots(this.nodes, next);
|
|
451
|
+
this.nodes = next;
|
|
452
|
+
if (!this.exported || change.kind === 'none') return;
|
|
453
|
+
|
|
454
|
+
if (change.kind === 'properties') {
|
|
455
|
+
this.iface?.emit.ItemsPropertiesUpdated(
|
|
456
|
+
change.updated.map(([id, props]) => [id, this.wrap(props)]),
|
|
457
|
+
change.removed,
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
this.revision += 1;
|
|
462
|
+
this.iface?.emit.LayoutUpdated(this.revision, change.parent);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Plain property values → the `a{sv}` the wire wants. */
|
|
466
|
+
wrap(props) {
|
|
467
|
+
const out = {};
|
|
468
|
+
for (const [name, value] of Object.entries(props)) {
|
|
469
|
+
const type = PROPERTY_TYPES[name];
|
|
470
|
+
if (type) out[name] = new this.dbus.Variant(type, value);
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
|
|
476
|
+
wrapLayout(node) {
|
|
477
|
+
const [id, props, children] = node;
|
|
478
|
+
return [
|
|
479
|
+
id,
|
|
480
|
+
this.wrap(props),
|
|
481
|
+
children.map(
|
|
482
|
+
(child) =>
|
|
483
|
+
new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
|
|
484
|
+
),
|
|
485
|
+
];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// --------------------------------------------------------------- protocol
|
|
489
|
+
|
|
490
|
+
itemFor(id) {
|
|
491
|
+
return this.nodes.get(id)?.item ?? null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
defineMenu(dbus) {
|
|
495
|
+
this.dbus = dbus;
|
|
496
|
+
const fire = (id, eventId) => {
|
|
497
|
+
const item = this.itemFor(id);
|
|
498
|
+
if (!item) return;
|
|
499
|
+
if (eventId === 'clicked') this.onSelect?.(item);
|
|
500
|
+
else if (eventId === 'opened') this.onAboutToShow?.(item);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
return dbus.defineInterface({
|
|
504
|
+
name: DBUSMENU_IFACE,
|
|
505
|
+
methods: {
|
|
506
|
+
GetLayout: {
|
|
507
|
+
in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
|
|
508
|
+
out: { revision: 'u', layout: LAYOUT_SIGNATURE },
|
|
509
|
+
handler: ({ parentId, recursionDepth, propertyNames }) => {
|
|
510
|
+
const tree = layoutOf(
|
|
511
|
+
this.nodes,
|
|
512
|
+
parentId,
|
|
513
|
+
recursionDepth ?? -1,
|
|
514
|
+
propertyNames,
|
|
515
|
+
);
|
|
516
|
+
// An id the shell remembers from before a structural change is the
|
|
517
|
+
// normal way to arrive here, not a protocol violation: answer with
|
|
518
|
+
// an empty item rather than an error, and let the LayoutUpdated it
|
|
519
|
+
// has already been sent bring it back for the real tree.
|
|
520
|
+
return {
|
|
521
|
+
revision: this.revision,
|
|
522
|
+
layout: this.wrapLayout(tree ?? [parentId, {}, []]),
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
GetGroupProperties: {
|
|
527
|
+
in: { ids: 'ai', propertyNames: 'as' },
|
|
528
|
+
out: { properties: 'a(ia{sv})' },
|
|
529
|
+
handler: ({ ids, propertyNames }) =>
|
|
530
|
+
groupProperties(this.nodes, ids, propertyNames).map(
|
|
531
|
+
([id, props]) => [id, this.wrap(props)],
|
|
532
|
+
),
|
|
533
|
+
},
|
|
534
|
+
GetProperty: {
|
|
535
|
+
in: { id: 'i', name: 's' },
|
|
536
|
+
out: { value: 'v' },
|
|
537
|
+
handler: ({ id, name }) => {
|
|
538
|
+
const props = this.nodes.get(id)?.props;
|
|
539
|
+
// `hasOwn` on both, so a name like `constructor` reads as absent
|
|
540
|
+
// rather than as a function that then fails to marshal.
|
|
541
|
+
const known = Object.hasOwn(PROPERTY_TYPES, name);
|
|
542
|
+
const value =
|
|
543
|
+
props && Object.hasOwn(props, name) ? props[name] : undefined;
|
|
544
|
+
// A property this item does not carry is at its default, and the
|
|
545
|
+
// spec's own advice is to answer with it rather than to error.
|
|
546
|
+
if (value === undefined || !known) return new dbus.Variant('s', '');
|
|
547
|
+
return new dbus.Variant(PROPERTY_TYPES[name], value);
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
Event: {
|
|
551
|
+
in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
|
|
552
|
+
out: {},
|
|
553
|
+
handler: ({ id, eventId }) => fire(id, eventId),
|
|
554
|
+
},
|
|
555
|
+
EventGroup: {
|
|
556
|
+
in: { events: 'a(isvu)' },
|
|
557
|
+
out: { idErrors: 'ai' },
|
|
558
|
+
handler: ({ events }) => {
|
|
559
|
+
const errors = [];
|
|
560
|
+
for (const [id, eventId] of events ?? []) {
|
|
561
|
+
if (this.itemFor(id)) fire(id, eventId);
|
|
562
|
+
else errors.push(id);
|
|
563
|
+
}
|
|
564
|
+
return errors;
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
AboutToShow: {
|
|
568
|
+
in: { id: 'i' },
|
|
569
|
+
out: { needUpdate: 'b' },
|
|
570
|
+
handler: ({ id }) => this.aboutToShow(id),
|
|
571
|
+
},
|
|
572
|
+
AboutToShowGroup: {
|
|
573
|
+
in: { ids: 'ai' },
|
|
574
|
+
out: { updatesNeeded: 'ai', idErrors: 'ai' },
|
|
575
|
+
handler: ({ ids }) => {
|
|
576
|
+
const errors = [];
|
|
577
|
+
for (const id of ids ?? []) {
|
|
578
|
+
if (this.itemFor(id)) this.aboutToShow(id);
|
|
579
|
+
else errors.push(id);
|
|
580
|
+
}
|
|
581
|
+
return { updatesNeeded: [], idErrors: errors };
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
properties: {
|
|
586
|
+
Version: { type: 'u', access: 'read', get: () => 3 },
|
|
587
|
+
Status: { type: 's', access: 'read', get: () => 'normal' },
|
|
588
|
+
TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
|
|
589
|
+
// Empty, and not a placeholder: `icon-name` is looked up in the
|
|
590
|
+
// desktop's own theme, which is where an app's icons should come from.
|
|
591
|
+
// A path here would be for icons shipped beside the app.
|
|
592
|
+
IconThemePath: { type: 'as', access: 'read', get: () => [] },
|
|
593
|
+
},
|
|
594
|
+
signals: {
|
|
595
|
+
LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
|
|
596
|
+
ItemsPropertiesUpdated: {
|
|
597
|
+
args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
|
|
598
|
+
},
|
|
599
|
+
ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The reply has to go out **now**, and React has not rendered yet.
|
|
606
|
+
*
|
|
607
|
+
* `setState` from here does not produce a new tree before this function
|
|
608
|
+
* returns, so there is no honest way to answer `true` — and answering `true`
|
|
609
|
+
* dishonestly is worse than answering `false`: the shell then blocks on a
|
|
610
|
+
* `GetLayout` for a subtree that has not been built, gets the old one, and
|
|
611
|
+
* caches it.
|
|
612
|
+
*
|
|
613
|
+
* So: answer `false`, let the handler run, and let the ordinary update path
|
|
614
|
+
* emit `LayoutUpdated` when the new items actually serialise. Shells listen
|
|
615
|
+
* for that unconditionally — it is the same signal a menu changing while
|
|
616
|
+
* open produces — so a lazily-built submenu still fills in, one round trip
|
|
617
|
+
* later than a synchronous toolkit would manage.
|
|
618
|
+
*/
|
|
619
|
+
aboutToShow(id) {
|
|
620
|
+
const item = this.itemFor(id);
|
|
621
|
+
if (item) this.onAboutToShow?.(item);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Is this window's menu bar being drawn by the desktop?
|
|
628
|
+
*
|
|
629
|
+
* `false` until proven otherwise, and proof is the registrar answering
|
|
630
|
+
* `RegisterWindow` — so a machine with no bus, no panel, or a panel that
|
|
631
|
+
* refuses the registration all render the menu where the app put it.
|
|
632
|
+
*
|
|
633
|
+
* ```jsx
|
|
634
|
+
* const exported = useGlobalMenu(menus, { onSelect });
|
|
635
|
+
* if (exported) return null; // the panel has it
|
|
636
|
+
* ```
|
|
637
|
+
*
|
|
638
|
+
* `MenuBar` calls this itself, which is why an app needs no code for any of
|
|
639
|
+
* it. It is exported for a component that draws its own bar and wants the
|
|
640
|
+
* same behaviour.
|
|
641
|
+
*/
|
|
642
|
+
export function useGlobalMenu(
|
|
643
|
+
menus,
|
|
644
|
+
{ onSelect, onAboutToShow, enabled = true } = {},
|
|
645
|
+
) {
|
|
646
|
+
const target = useTopLevelWindow();
|
|
647
|
+
const [exported, setExported] = useState(false);
|
|
648
|
+
const exportRef = useRef(null);
|
|
649
|
+
|
|
650
|
+
// The handlers a D-Bus reply reaches for are read at call time, not captured
|
|
651
|
+
// at export time: a click on the panel three minutes from now must run the
|
|
652
|
+
// handler from the *current* render, not the one that happened to be current
|
|
653
|
+
// when the registrar answered.
|
|
654
|
+
const live = useRef({ menus, onSelect, onAboutToShow });
|
|
655
|
+
live.current = { menus, onSelect, onAboutToShow };
|
|
656
|
+
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
if (!enabled) return undefined;
|
|
659
|
+
const owner = new GlobalMenuExport({
|
|
660
|
+
getMenus: () => live.current.menus ?? [],
|
|
661
|
+
onSelect: (item) => {
|
|
662
|
+
item.onSelect?.(item);
|
|
663
|
+
live.current.onSelect?.(item);
|
|
664
|
+
},
|
|
665
|
+
onAboutToShow: (item) => live.current.onAboutToShow?.(item),
|
|
666
|
+
target,
|
|
667
|
+
onChange: setExported,
|
|
668
|
+
});
|
|
669
|
+
exportRef.current = owner;
|
|
670
|
+
owner.start().catch(() => {});
|
|
671
|
+
return () => {
|
|
672
|
+
exportRef.current = null;
|
|
673
|
+
setExported(false);
|
|
674
|
+
owner.stop().catch(() => {});
|
|
675
|
+
};
|
|
676
|
+
}, [enabled, target]);
|
|
677
|
+
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
exportRef.current?.update(menus ?? []);
|
|
680
|
+
}, [menus]);
|
|
681
|
+
|
|
682
|
+
return enabled && exported;
|
|
683
|
+
}
|