react-x11 2.51.0 → 2.52.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 +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/xkb.js +137 -0
package/docs/desktop.md
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
# Desktop integration
|
|
2
|
+
|
|
3
|
+
What an app has to tell the desktop about itself, beyond drawing. Today that
|
|
4
|
+
is startup notification, which is on by default and has nothing to call; the
|
|
5
|
+
launcher's view of the app — a badge on its icon, a bounce for attention, the
|
|
6
|
+
Dock's menu; what a password field has to do to be reachable by the desktop's
|
|
7
|
+
password managers; and the one switch that turns off everything here that
|
|
8
|
+
talks over D-Bus.
|
|
9
|
+
|
|
10
|
+
The menu bar is the other half of this and has a page of its own —
|
|
11
|
+
[globalmenu.md](globalmenu.md) — because on a desktop that shows application
|
|
12
|
+
menus in its panel, `MenuBar` hands the menu over and stops drawing. Same
|
|
13
|
+
default shape as startup notification: on with no configuration, and one
|
|
14
|
+
switch to turn it off.
|
|
15
|
+
|
|
16
|
+
Everything on this page is the app talking _outwards_. The one thing that
|
|
17
|
+
comes the other way — the desktop handing the app a `myapp://…` link to open,
|
|
18
|
+
and the single-instance question that goes with it — is
|
|
19
|
+
[uri-schemes.md](uri-schemes.md). It shares the launch timestamp described
|
|
20
|
+
below: `ctx.timestamp` there is the same `_TIME` suffix `launchTimestamp()`
|
|
21
|
+
parses here, and for the same reason.
|
|
22
|
+
|
|
23
|
+
## Startup notification
|
|
24
|
+
|
|
25
|
+
When a launcher spawns an app it opens a **startup sequence** — that is what
|
|
26
|
+
the busy cursor is — and closes it when the app says it is up. An app that
|
|
27
|
+
never says so leaves the sequence running until the desktop gives up on it:
|
|
28
|
+
mutter's `STARTUP_TIMEOUT_MS` is 15 seconds, long after the window is on
|
|
29
|
+
screen and being clicked. The mutter source is blunt about what that looks
|
|
30
|
+
like, in a comment on the constant itself: people assume the launch failed
|
|
31
|
+
and start it again.
|
|
32
|
+
|
|
33
|
+
Two other things ride on the same handshake:
|
|
34
|
+
|
|
35
|
+
- **Focus.** `_NET_WM_USER_TIME` is the evidence focus-stealing prevention
|
|
36
|
+
weighs when deciding whether a new window may come to the front. With
|
|
37
|
+
none, a strict desktop opens the window behind whatever the user was
|
|
38
|
+
doing — correctly, from its point of view, since nothing said a user asked
|
|
39
|
+
for this.
|
|
40
|
+
- **Placement.** The sequence records which workspace the launch happened
|
|
41
|
+
on. Without one the window goes wherever new windows go.
|
|
42
|
+
|
|
43
|
+
react-x11 does all of it with no configuration:
|
|
44
|
+
|
|
45
|
+
```jsx
|
|
46
|
+
const root = await createRoot(); // that is the whole of it
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`DESKTOP_STARTUP_ID` is read from the environment and **removed from it**.
|
|
50
|
+
That is deliberate and it matters: the variable names one launch, so a child
|
|
51
|
+
process that inherits it would claim a sequence that is not its own and end
|
|
52
|
+
it early — the parent's cursor stops when the child starts. Every toolkit
|
|
53
|
+
that gets this wrong produces that same bug.
|
|
54
|
+
|
|
55
|
+
With no id in the environment — a terminal, CI, XQuartz — nothing is set and
|
|
56
|
+
nothing is sent.
|
|
57
|
+
|
|
58
|
+
### When is an app "started"?
|
|
59
|
+
|
|
60
|
+
The default is **the first frame that actually painted**.
|
|
61
|
+
|
|
62
|
+
GTK ends the sequence when the toplevel maps, and copying that here would be
|
|
63
|
+
subtly wrong. This renderer does not paint on map: `invalidate()` schedules
|
|
64
|
+
through the frame clock and the drawing lands in `flush()` a frame later. So
|
|
65
|
+
a mapped window is an empty rectangle, and stopping the busy cursor there is
|
|
66
|
+
compliant and dishonest — it says "ready" over a blank window.
|
|
67
|
+
|
|
68
|
+
A suspense fallback counts as painted, and should. If the first frame is a
|
|
69
|
+
spinner because the tree is waiting on data, that is exactly the right
|
|
70
|
+
moment to stop the _system's_ spinner: the app is up and is telling the user
|
|
71
|
+
what it is doing. There is no signal for "finished loading", and guessing at
|
|
72
|
+
one is how this ends back at fifteen seconds.
|
|
73
|
+
|
|
74
|
+
`completeOn` is there for apps the default does not suit:
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
await createRoot({ startupNotification: { completeOn: 'map' } });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| `completeOn` | |
|
|
81
|
+
| ------------ | ------------------------------------------------------- |
|
|
82
|
+
| `'paint'` | the first frame that drew (default) |
|
|
83
|
+
| `'map'` | the first toplevel mapping — earlier, and what GTK does |
|
|
84
|
+
| `'manual'` | nothing automatic; call `notifyStartupComplete()` |
|
|
85
|
+
|
|
86
|
+
`'map'` suits an app whose first frame is expensive enough that it would
|
|
87
|
+
rather the cursor stopped before it. `'manual'` suits one that is not up
|
|
88
|
+
until it says so — restoring a session behind a splash, say:
|
|
89
|
+
|
|
90
|
+
```jsx
|
|
91
|
+
import { notifyStartupComplete } from 'react-x11';
|
|
92
|
+
|
|
93
|
+
await createRoot({ startupNotification: { completeOn: 'manual' } });
|
|
94
|
+
await restoreSession();
|
|
95
|
+
notifyStartupComplete(); // idempotent, and a no-op if there is no sequence
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**A backstop ends the sequence regardless**, ten seconds after the window
|
|
99
|
+
maps, whichever mode is in force. An app that never paints, or that forgets
|
|
100
|
+
to call, cannot leave the cursor spinning — which would be this feature
|
|
101
|
+
reproducing the bug it exists to fix. Ten is chosen to beat mutter's fifteen
|
|
102
|
+
by a margin while being far longer than any honest first frame.
|
|
103
|
+
|
|
104
|
+
### Turning it off, and supplying an id
|
|
105
|
+
|
|
106
|
+
```jsx
|
|
107
|
+
await createRoot({ startupNotification: false });
|
|
108
|
+
await createRoot({ startupNotification: 'launcher/app/1-0_TIME9876' });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`false` is for an app that runs its own sequence, or an embedder that owns
|
|
112
|
+
the toplevel. Note that opting out leaves `DESKTOP_STARTUP_ID` in the
|
|
113
|
+
environment — if you are managing the sequence yourself, the id is yours to
|
|
114
|
+
read and yours to clear.
|
|
115
|
+
|
|
116
|
+
A string supplies the id for a launch where it did not arrive in the
|
|
117
|
+
environment. A D-Bus-activated app gets it in `platform_data` instead.
|
|
118
|
+
|
|
119
|
+
### `launchTimestamp()`
|
|
120
|
+
|
|
121
|
+
```jsx
|
|
122
|
+
import { launchTimestamp } from 'react-x11';
|
|
123
|
+
|
|
124
|
+
const when = launchTimestamp(); // number | null
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The X server timestamp of the user action that launched the app, parsed from
|
|
128
|
+
the id's `_TIME` suffix. **`null` is a real answer**, not a failure: an app
|
|
129
|
+
started from a shell has no launch timestamp and never will.
|
|
130
|
+
|
|
131
|
+
It is the "when" that any later request to come forward is weighed against.
|
|
132
|
+
Do not substitute `0` for a missing one — EWMH gives zero its own meaning,
|
|
133
|
+
"do not focus this window when it maps".
|
|
134
|
+
|
|
135
|
+
### On macOS
|
|
136
|
+
|
|
137
|
+
quartz-wm implements none of this, so on XQuartz the messages go to a root
|
|
138
|
+
window nobody is listening at and `_NET_WM_USER_TIME` is ignored. Harmless,
|
|
139
|
+
and worth knowing before concluding from a Mac that the feature is broken.
|
|
140
|
+
|
|
141
|
+
### What is deliberately not here
|
|
142
|
+
|
|
143
|
+
- **The launcher half.** An app that spawns _another_ app should generate an
|
|
144
|
+
id, send `new:`, put `DESKTOP_STARTUP_ID` in the child's environment and
|
|
145
|
+
close the sequence itself. Same encoder, different persona.
|
|
146
|
+
- **Ongoing `_NET_WM_USER_TIME` maintenance.** Setting it once at launch is
|
|
147
|
+
this. Keeping it current on every keypress is a separate design with a
|
|
148
|
+
real cost — EWMH is explicit that storing a frequently-changing property
|
|
149
|
+
on the toplevel wakes every client watching that window, which is what
|
|
150
|
+
`_NET_WM_USER_TIME_WINDOW` exists to avoid.
|
|
151
|
+
|
|
152
|
+
### Security
|
|
153
|
+
|
|
154
|
+
The id is broadcast to the root window, so every client on the display sees
|
|
155
|
+
it. On X11 that is the pre-existing no-isolation story rather than a new
|
|
156
|
+
exposure — see [security.md](security.md) — but it is the reason the
|
|
157
|
+
messages carry the id the launcher gave us and nothing invented, and the
|
|
158
|
+
reason the variable's value is never logged.
|
|
159
|
+
|
|
160
|
+
## The launcher: a badge, attention, and the Dock menu
|
|
161
|
+
|
|
162
|
+
The one thing an app says to the desktop that the user reads without opening
|
|
163
|
+
it is the mark on its icon — an unread count on the Dock tile, a dot on the
|
|
164
|
+
taskbar entry. What lives here is on a backend only where that backend has a
|
|
165
|
+
mechanism for it:
|
|
166
|
+
|
|
167
|
+
| | Linux (`launcherentry`) | macOS (`cocoa`) | Windows (`taskbar`) |
|
|
168
|
+
| --------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
169
|
+
| a **badge** | `com.canonical.Unity.LauncherEntry` — KDE, elementary, Cairo-Dock listen; GNOME needs an extension | `NSDockTile.badgeLabel` | an overlay icon with the label drawn into it |
|
|
170
|
+
| badge **text** | — the protocol carries a count and nothing else | any label | up to three glyphs; longer clamps to `99+`, and it is ~16px on screen |
|
|
171
|
+
| **progress** | `useProgress(0…1)` — a bar across the tile | — `NSDockTile` has none | `ITaskbarList3::SetProgressValue`, across the taskbar button |
|
|
172
|
+
| **attention** | `states={['demands_attention']}`, or `setUrgent()` for the launcher entry itself | the same prop: the Dock icon bounces until activated | `FlashWindowEx` |
|
|
173
|
+
| the **launcher menu** | `useLauncherMenu(items)` — the launcher protocol's quicklist | `useLauncherMenu(items)` | — the taskbar button's menu is the jump list, whose entries start a _new_ process, so it is `features.tasks` and `useJumpList` rather than this ([windows-integrations.md](windows-integrations.md)) |
|
|
174
|
+
| **tasks** | — `Actions=` in the `.desktop` file, not driven from here | — | `useJumpList(tasks)` — the jump list's Tasks category |
|
|
175
|
+
| a **hover toolbar** | — | — | `useThumbnailToolbar(buttons)` — up to seven, under the preview |
|
|
176
|
+
| **recent documents** | — | — the platform has one; this backend does not use it | `useRecentDocument(path)` — the shell's Recent lists |
|
|
177
|
+
|
|
178
|
+
### The badge
|
|
179
|
+
|
|
180
|
+
```jsx
|
|
181
|
+
import { setBadge, useBadge } from 'react-x11';
|
|
182
|
+
|
|
183
|
+
useBadge(unread); // a count: 3 shows "3", 0 clears
|
|
184
|
+
await setBadge(null); // the imperative twin, for code with no component
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A badge is a **count**, because that is the one shape both desktops agree
|
|
188
|
+
on: a number shows on both. A string (`'•'`, `'!'`) shows on macOS, where the
|
|
189
|
+
tile takes any label, and is a visible count of nothing on Linux, where the
|
|
190
|
+
protocol has no text field — pass one only where the Mac is the audience.
|
|
191
|
+
`0`, `null` and `''` all clear it. `useBadge` clears on unmount; `setBadge`
|
|
192
|
+
resolves to whether a launcher was told and never rejects for anything about
|
|
193
|
+
the machine.
|
|
194
|
+
|
|
195
|
+
On Linux the count travels as one D-Bus signal, `LauncherEntry.Update`,
|
|
196
|
+
attributed to the app by `application://<appId>.desktop` — so it needs the
|
|
197
|
+
identity [`registerApplication({ appId })`](uri-schemes.md) establishes and
|
|
198
|
+
a `.desktop` file of that name, or the launcher has nothing to pin the
|
|
199
|
+
count to and `setBadge` resolves `false`. The entry stays on the bus while
|
|
200
|
+
a badge is shown and is released when it is cleared. A launcher that starts
|
|
201
|
+
after the badge was set will not see it until the next `setBadge`; the
|
|
202
|
+
protocol has no query.
|
|
203
|
+
|
|
204
|
+
### Progress
|
|
205
|
+
|
|
206
|
+
```jsx
|
|
207
|
+
import { useProgress, setProgress } from 'react-x11';
|
|
208
|
+
|
|
209
|
+
useProgress(downloaded / total); // 0…1; null or undefined clears it
|
|
210
|
+
await setProgress(null); // the imperative twin
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A bar drawn across the app's icon, for a download, an export, a long build.
|
|
214
|
+
Out-of-range values are clamped rather than refused — a caller dividing by a
|
|
215
|
+
total that briefly went to zero should not get an exception.
|
|
216
|
+
|
|
217
|
+
**Linux launchers only.** `NSDockTile` has no progress bar; an app that wants
|
|
218
|
+
one on a Mac draws it into a custom tile view, which is app-side art rather
|
|
219
|
+
than a call. `useDesktopCapability('launcher').features.progress` is the
|
|
220
|
+
honest answer to branch on.
|
|
221
|
+
|
|
222
|
+
### Attention
|
|
223
|
+
|
|
224
|
+
Nothing new to call: `<window states={['demands_attention']}>` is the
|
|
225
|
+
existing request, and on the cocoa backend it is
|
|
226
|
+
`NSApp.requestUserAttention` — the Dock icon bounces until the user
|
|
227
|
+
activates the app, and stops when the prop drops the state or the window
|
|
228
|
+
goes away. AppKit ignores the request while the app is already active, which
|
|
229
|
+
is the same outcome a window manager gives a focused window's urgency hint.
|
|
230
|
+
`fullscreen`, `maximized` and `hidden` are AppKit's full-screen Space, zoom
|
|
231
|
+
and miniaturize there (@windowkit/appkit 0.30.0), and the other `states`
|
|
232
|
+
names are inert on that backend.
|
|
233
|
+
|
|
234
|
+
### The Dock menu
|
|
235
|
+
|
|
236
|
+
```jsx
|
|
237
|
+
useLauncherMenu([
|
|
238
|
+
{ label: 'New Window', onSelect: openWindow },
|
|
239
|
+
{ type: 'separator' },
|
|
240
|
+
{
|
|
241
|
+
label: 'Recent',
|
|
242
|
+
items: recent.map((r) => ({ label: r.name, onSelect: () => open(r) })),
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The same `items` vocabulary `MenuBar` and `ContextMenu` take ([menuitem
|
|
248
|
+
names](globalmenu.md)), so one authoring model covers the window's menu, the
|
|
249
|
+
panel's, the tray's and the Dock's. Installed while the component is mounted,
|
|
250
|
+
replaced when `items` changes, taken down on unmount.
|
|
251
|
+
|
|
252
|
+
`NSDockTile`'s menu on the cocoa backend; on Linux it is the launcher
|
|
253
|
+
protocol's **quicklist** — an object path to a `com.canonical.dbusmenu` tree,
|
|
254
|
+
which is the same menu protocol the tray and the global menu already speak.
|
|
255
|
+
ubuntu-dock/Dash-to-Dock, Plank and the Unity-heritage launchers render it.
|
|
256
|
+
|
|
257
|
+
> An earlier version of this page said the freedesktop counterpart was
|
|
258
|
+
> `Actions=` in the `.desktop` file, an install step rather than runtime code.
|
|
259
|
+
> That is a different feature wearing a similar hat: desktop actions are the
|
|
260
|
+
> right place for entries that must work **while the app is not running**, and
|
|
261
|
+
> the quicklist is the right place for a menu that follows the app's state.
|
|
262
|
+
|
|
263
|
+
Like the badge, it needs the identity
|
|
264
|
+
[`registerApplication({ appId })`](uri-schemes.md) establishes and a
|
|
265
|
+
`.desktop` file of that name for a launcher to attach it to.
|
|
266
|
+
|
|
267
|
+
### The app's presence
|
|
268
|
+
|
|
269
|
+
Two things the Dock and the ⌘-Tab switcher show that a bare `node` process
|
|
270
|
+
gets wrong, as root options on the cocoa backend:
|
|
271
|
+
|
|
272
|
+
```js
|
|
273
|
+
await createRoot({
|
|
274
|
+
cocoa: {
|
|
275
|
+
appName: 'Notes', // what the Dock, ⌘-Tab and the app menu print (was "node")
|
|
276
|
+
activationPolicy: 'regular', // 'accessory' for a menu-bar app: no tile, no ⌘-Tab entry
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`activationPolicy` is fixed before the app finishes launching — a Regular
|
|
282
|
+
launch registers a Dock tile, and an agent app that switched afterwards would
|
|
283
|
+
already have flashed its icon — so it is a root option rather than a hook.
|
|
284
|
+
`appName` renames LaunchServices' record of an unbundled process; a bundle's
|
|
285
|
+
`Info.plist` wins, as it should.
|
|
286
|
+
|
|
287
|
+
## The tray
|
|
288
|
+
|
|
289
|
+
```jsx
|
|
290
|
+
import { useTray } from 'react-x11';
|
|
291
|
+
|
|
292
|
+
const { available, backend, features, error } = useTray({
|
|
293
|
+
icon: 'mail-unread', // a themed icon name (an SF Symbol on macOS), or PNG bytes
|
|
294
|
+
tooltip: 'Notifications',
|
|
295
|
+
menu: [
|
|
296
|
+
{ label: 'Open', onSelect: open },
|
|
297
|
+
{ type: 'separator' },
|
|
298
|
+
{ label: 'Quit', onSelect: quit },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
An icon in the system tray for as long as the component is mounted. With
|
|
304
|
+
`menu` a click opens it — the same `items` vocabulary as `MenuBar` and the
|
|
305
|
+
Dock menu, so the three menus an app puts on the desktop are one authoring
|
|
306
|
+
model. Without one, `onClick` gets the button, where the click was and, where
|
|
307
|
+
the backend knows it, the item's screen rect — all in logical screen pixels,
|
|
308
|
+
the unit a `<popup>`'s `x`/`y` are in, and what a popover under the item
|
|
309
|
+
anchors to ([elements.md](elements.md#a-popover-under-a-tray-item)). `title` shows text beside the icon or alone;
|
|
310
|
+
`visible`, `tooltip`, `attention` and the rest follow their values while
|
|
311
|
+
mounted, and the item is removed on unmount. `null` means no item.
|
|
312
|
+
|
|
313
|
+
Several trays coexist: each is its own item.
|
|
314
|
+
|
|
315
|
+
| | Linux (`statusnotifier`) | macOS (`cocoa`) | Windows (`shellnotifyicon`) |
|
|
316
|
+
| --------------- | --------------------------------------- | ---------------------------- | --------------------------- |
|
|
317
|
+
| mechanism | `org.kde.StatusNotifierItem` over D-Bus | `NSStatusItem` | `Shell_NotifyIcon` |
|
|
318
|
+
| menu | `com.canonical.dbusmenu` | `NSMenu` | a tracked popup menu |
|
|
319
|
+
| icon by name | the desktop's icon theme | SF Symbols | — no icon theme to name |
|
|
320
|
+
| icon by bytes | ARGB32 pixmap | a template image | an `HICON` from RGBA |
|
|
321
|
+
| title | text beside the icon | text beside the icon | — an icon and a tooltip |
|
|
322
|
+
| attention | `Status = NeedsAttention` | — | — |
|
|
323
|
+
| click rect | — | the item's frame | — |
|
|
324
|
+
| click modifiers | — | shift/control/option/command | — |
|
|
325
|
+
| scroll | `Scroll(delta, orientation)` | — | — |
|
|
326
|
+
|
|
327
|
+
**On Linux** this is StatusNotifierItem, which is D-Bus only — so it works
|
|
328
|
+
identically under X11, XWayland and Wayland. It needs something _hosting_ a
|
|
329
|
+
tray: KDE Plasma and most panels do out of the box, and GNOME needs an
|
|
330
|
+
AppIndicator extension. A stock GNOME session with no extension has no tray,
|
|
331
|
+
`available` is `false`, and nothing is logged, because that is a
|
|
332
|
+
configuration rather than a fault.
|
|
333
|
+
|
|
334
|
+
**A click's position on Linux** is a point whose unit the protocol never
|
|
335
|
+
names, and hosts differ. Plasma sends device pixels, and so does GNOME in its
|
|
336
|
+
physical layout, the default up to GNOME 49. GNOME's logical layout — the
|
|
337
|
+
default from 50, and on Fedora and Debian 13 before it — sends logical
|
|
338
|
+
pixels, as xfce4-panel, LXQt and Cinnamon do. So the point is read both
|
|
339
|
+
ways: a reading that lands on no monitor is dropped, then the one the pointer
|
|
340
|
+
is on is kept, and otherwise the numbers are taken as sent. What that cannot
|
|
341
|
+
tell apart is a device-pixel host under XWayland whose tray, read as logical
|
|
342
|
+
pixels, still lands on a monitor — in the top-left quarter of the screen, or
|
|
343
|
+
with another monitor beyond it — and there the point is `scale` times as far
|
|
344
|
+
from the screen's origin as the click was.
|
|
345
|
+
|
|
346
|
+
**On the cocoa backend** this is `NSStatusItem`, the menu-bar extra. A
|
|
347
|
+
menu-bar app that wants no Dock tile pairs it with
|
|
348
|
+
`createRoot({ cocoa: { activationPolicy: 'accessory' } })` above. A PNG icon
|
|
349
|
+
is drawn as a **template** — its shape in the bar's ink, so it follows light
|
|
350
|
+
and dark the way a symbol does; `template: false` keeps its colours. AppKit
|
|
351
|
+
is handed a new `NSMenu` only when the menu's items change, so a title that
|
|
352
|
+
ticks every second leaves the menu alone, whether `menu` is memoized or
|
|
353
|
+
written inline.
|
|
354
|
+
|
|
355
|
+
### What the hook hands back
|
|
356
|
+
|
|
357
|
+
`available` **settles**. Finding a freedesktop tray takes a bus round trip, so
|
|
358
|
+
it is `false` on the first frame and `true` a tick later if a host is there —
|
|
359
|
+
render the fallback first and upgrade, never the other way round. It follows
|
|
360
|
+
the host for the life of the item, so a panel that exits flips it back.
|
|
361
|
+
|
|
362
|
+
**`settled` says whether `available` is an answer yet.** For a feature inside
|
|
363
|
+
a window the order above is all an app needs. For an app whose whole UI is its
|
|
364
|
+
tray item, the fallback _is_ a window, and rendering it first flashes a window
|
|
365
|
+
up and away on every start on a desktop that has a tray. So wait for the
|
|
366
|
+
answer instead:
|
|
367
|
+
|
|
368
|
+
```jsx
|
|
369
|
+
const tray = useTray({ icon: 'waveform', onClick: open });
|
|
370
|
+
if (!tray.settled) return null; // a bus round trip, not a timeout
|
|
371
|
+
if (!tray.available) return <window title="Hush">{controls}</window>;
|
|
372
|
+
return popover;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
It is `true` on the first frame on macOS, where the status item is made there
|
|
376
|
+
and then, and for `useTray(null)`; on the freedesktop tray it turns `true` once
|
|
377
|
+
the host has taken or refused the item, and stays so.
|
|
378
|
+
|
|
379
|
+
`features` is the same vocabulary [feature discovery](#feature-discovery)
|
|
380
|
+
uses, for the rung that answered. Read it instead of testing the platform:
|
|
381
|
+
|
|
382
|
+
```jsx
|
|
383
|
+
// Right: ask about the thing you need.
|
|
384
|
+
{
|
|
385
|
+
features.clickModifiers && <ShiftClickHint />;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`error` is the fourth field because **"this desktop has no tray" and "there is
|
|
390
|
+
a tray and it refused me" are different facts**, and only one of them has a
|
|
391
|
+
fix. A missing tray leaves `error` null; a host that rejected the registration
|
|
392
|
+
puts the reason there.
|
|
393
|
+
|
|
394
|
+
## Feature discovery
|
|
395
|
+
|
|
396
|
+
Everything on this page is a _ladder_, and which rung a machine lands on
|
|
397
|
+
changes what the app may do. `available` alone is not enough to branch on,
|
|
398
|
+
because it is true on machines that mean very different things by it — a
|
|
399
|
+
notification daemon with `actions` gives you a banner with buttons that call
|
|
400
|
+
back, and `notify-send` gives you one-way text with an urgency and an icon.
|
|
401
|
+
An app that posts reply buttons on the second one gets a banner with no
|
|
402
|
+
buttons and no callback, silently.
|
|
403
|
+
|
|
404
|
+
So the unit of discovery is a **feature set**, not a flag:
|
|
405
|
+
|
|
406
|
+
```jsx
|
|
407
|
+
import { useDesktopCapability } from 'react-x11';
|
|
408
|
+
|
|
409
|
+
const notifications = useDesktopCapability('notifications');
|
|
410
|
+
|
|
411
|
+
if (notifications.features.actions) return <ReplyFromBanner />;
|
|
412
|
+
if (notifications.available) return <BannerThenOpenApp />;
|
|
413
|
+
return <InAppToastOnly />;
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`{ available, backend, features, settled }`. `backend` names the **mechanism** —
|
|
417
|
+
`'dbus'`, `'cocoa'`, `'statusnotifier'`, `'launcherentry'`, `'notify-send'`,
|
|
418
|
+
`'osascript'`, `'win32'`, `'shellnotifyicon'`, `'taskbar'` — and never the
|
|
419
|
+
platform, so that a second Linux mechanism does not need a second name for
|
|
420
|
+
Linux.
|
|
421
|
+
|
|
422
|
+
A backend **declares** its mechanism rather than having one inferred from it,
|
|
423
|
+
because backends install the same method names for the same rung on purpose —
|
|
424
|
+
that sharing is what makes `useTray` one hook — so there is nothing in the
|
|
425
|
+
methods to tell two of them apart. Branch on `features`; `backend` is for logs
|
|
426
|
+
and bug reports.
|
|
427
|
+
|
|
428
|
+
`settled` is `false` until the first probe answers and `true` from then on, so
|
|
429
|
+
an app can hold its fallback back rather than flash it; it is `true` on the
|
|
430
|
+
first frame for the answers that need nothing asked, the tray and the Dock tile
|
|
431
|
+
on macOS.
|
|
432
|
+
|
|
433
|
+
Capability names are `'notifications'`, `'tray'` and `'launcher'`.
|
|
434
|
+
`desktopCapability(name)` is the imperative twin for code with no component.
|
|
435
|
+
|
|
436
|
+
### Which one to ask
|
|
437
|
+
|
|
438
|
+
A hook that _does_ the thing already knows whether it worked, and that
|
|
439
|
+
measurement beats any prediction:
|
|
440
|
+
|
|
441
|
+
- **`useTray(options)`** — "did my icon get taken?" It tried; it knows.
|
|
442
|
+
- **`useDesktopCapability('tray')`** — "would one be taken if I asked?" This
|
|
443
|
+
is what a settings screen needs: a "Show tray icon" checkbox has to render
|
|
444
|
+
correctly _without_ putting an icon in the tray to find out.
|
|
445
|
+
|
|
446
|
+
Prefer the feature hook wherever the feature is actually mounted. Both read
|
|
447
|
+
the same probe, so they cannot disagree.
|
|
448
|
+
|
|
449
|
+
### Degrading well
|
|
450
|
+
|
|
451
|
+
Three rules, in the order they matter:
|
|
452
|
+
|
|
453
|
+
1. **Render the fallback first.** Every desktop answer takes a round trip, so
|
|
454
|
+
the first frame has none. Upgrading a fallback is invisible; downgrading a
|
|
455
|
+
feature the user already reached for is not.
|
|
456
|
+
2. **Never disable a feature permanently on a `false`.** A panel restarts, an
|
|
457
|
+
extension is enabled, a daemon is installed. The hooks re-probe on
|
|
458
|
+
`NameOwnerChanged` and nothing is cached — an app that latched the first
|
|
459
|
+
answer would outlive every one of those fixes.
|
|
460
|
+
3. **A missing desktop service is not an error.** No bus, no tray host, no
|
|
461
|
+
notification daemon and no launcher are all ordinary states of a healthy
|
|
462
|
+
machine. Nothing is logged and nothing throws; the feature is simply not
|
|
463
|
+
there. Reserve `error` for a service that answered and then refused.
|
|
464
|
+
|
|
465
|
+
### All of it, in one app
|
|
466
|
+
|
|
467
|
+
`examples/desktop.jsx` (`npm run examples:desktop`) is a backup agent that
|
|
468
|
+
uses every hook on this page at once — the tray icon with its attention state
|
|
469
|
+
and its overlay, the badge, the progress bar, the quicklist, the urgency flag,
|
|
470
|
+
and a banner whose buttons are offered only where the daemon has `actions` —
|
|
471
|
+
and whose second pane renders `useDesktopCapability()` for each of
|
|
472
|
+
`CAPABILITIES` beside the live answers, so the prediction and the measurement
|
|
473
|
+
are on screen together. It is the one to read when the question is what an app
|
|
474
|
+
will get on a machine you do not have. The three feature-sized examples are
|
|
475
|
+
`badge.jsx` (the launcher), `tray.jsx` (the tray) and `notify.jsx` (the
|
|
476
|
+
notification).
|
|
477
|
+
|
|
478
|
+
## Turning the desktop off
|
|
479
|
+
|
|
480
|
+
Three things react-x11 turns on for you reach the **session bus**, and none
|
|
481
|
+
of them was asked for:
|
|
482
|
+
|
|
483
|
+
| | | |
|
|
484
|
+
| ------------ | ----------------------------------------------------------------------- | ------------------------------------ |
|
|
485
|
+
| `appearance` | following the desktop's light/dark, accent, contrast and reduced motion | [appearance.md](appearance.md) |
|
|
486
|
+
| `a11y` | the AT-SPI bridge — whether a screen reader can see the app | [accessibility.md](accessibility.md) |
|
|
487
|
+
| `globalMenu` | a `MenuBar` handing its menu to the panel instead of drawing it | [globalmenu.md](globalmenu.md) |
|
|
488
|
+
|
|
489
|
+
Each is the right default: an app that follows the desktop looks like it
|
|
490
|
+
belongs there, one a screen reader can read is one more person can use, and a
|
|
491
|
+
menu in the panel is what the panel is for. But sooner or later an embedder
|
|
492
|
+
owns the thing we assumed was ours, and until
|
|
493
|
+
[#417](https://github.com/sidorares/react-x11/issues/417) the only way out was
|
|
494
|
+
an **environment variable** — `NO_AT_BRIDGE=1`,
|
|
495
|
+
`REACT_X11_NO_GLOBAL_MENU=1`, or unsetting `DBUS_SESSION_BUS_ADDRESS`. That is
|
|
496
|
+
a seam an app cannot reach for itself: the environment is inherited, so
|
|
497
|
+
setting one before `createRoot()` sets it for every child process too, and the
|
|
498
|
+
D-Bus one takes the portals and the app's own exported services down with the
|
|
499
|
+
follower.
|
|
500
|
+
|
|
501
|
+
```jsx
|
|
502
|
+
await createRoot({ desktop: false }); // none of the three
|
|
503
|
+
await createRoot({ desktop: { appearance: false } }); // just that one
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
With all three off, **nothing dials the session bus at startup** — no
|
|
507
|
+
connection, no subprocess, no portal probe. That is the shape an embedder
|
|
508
|
+
wants, and a kiosk, and a test that needs the same answer on every machine.
|
|
509
|
+
|
|
510
|
+
Turning `appearance` off means the app draws in its own palette rather than
|
|
511
|
+
the desktop's: `useSystemAppearance()` reports `'no-preference'` with
|
|
512
|
+
`source: null`, and the remembered answer on disk is not read either — an
|
|
513
|
+
opted-out app that started in whatever colours the machine was in last time
|
|
514
|
+
would be the opposite of what the switch is for.
|
|
515
|
+
|
|
516
|
+
**Off is process-wide, and it latches.** There is one desktop, one D-Bus
|
|
517
|
+
identity and one AT-SPI bridge per process, so the policy cannot honestly be
|
|
518
|
+
per-root: a second root turning back on what the first turned off would be a
|
|
519
|
+
bug rather than a feature. Nothing turns an integration back on, and a
|
|
520
|
+
feature already started stays started — pass this on the first root.
|
|
521
|
+
|
|
522
|
+
## Not blocking the first frame on the bus
|
|
523
|
+
|
|
524
|
+
Worth knowing because it is invisible when it works, and was worth ~150 ms
|
|
525
|
+
when it did not.
|
|
526
|
+
|
|
527
|
+
On macOS the session bus address is not in the environment; D-Bus advertises
|
|
528
|
+
it through **launchd**, and finding it means running `launchctl getenv`.
|
|
529
|
+
`dbus-native`'s entry point is synchronous, so it has to do that with
|
|
530
|
+
`spawnSync` — 120–150 ms of _blocked event loop_ on a cold page cache, landing
|
|
531
|
+
in front of the X handshake, the layout engine's WebAssembly and the first
|
|
532
|
+
paint, all so the accessibility bridge could find out there was nothing to
|
|
533
|
+
connect to.
|
|
534
|
+
|
|
535
|
+
Every dial now goes through one asynchronous, once-per-process lookup instead,
|
|
536
|
+
and hands `dbus-native` a resolved `unix:path=…`. The same wall clock is
|
|
537
|
+
spent, none of it on the loop: the handshake and the layout engine run
|
|
538
|
+
straight through it. There is nothing to configure — it is the same address,
|
|
539
|
+
found the same way, off the critical path.
|
|
540
|
+
|
|
541
|
+
Linux is unaffected: `$DBUS_SESSION_BUS_ADDRESS` or `$XDG_RUNTIME_DIR/bus`
|
|
542
|
+
answers without a subprocess, and always did.
|
|
543
|
+
|
|
544
|
+
## Password fields and password managers
|
|
545
|
+
|
|
546
|
+
There is **no password-field protocol on the Linux desktop.** No toolkit
|
|
547
|
+
publishes "this is a password field, fill it", and no manager asks. What
|
|
548
|
+
exists instead are four seams, none of which a widget can opt into by
|
|
549
|
+
declaring itself — they are things an application either supports or does
|
|
550
|
+
not. `PasswordInput` supports the two that reach a field, and this is what
|
|
551
|
+
they turn out to be.
|
|
552
|
+
|
|
553
|
+
### 1. Typing — XTEST auto-type, and the keymap race
|
|
554
|
+
|
|
555
|
+
The mechanism nearly every desktop manager uses is **auto-type**: KeePassXC
|
|
556
|
+
matches the focused window's **title** against its entries, then synthesises
|
|
557
|
+
the keystrokes. Its X11 backend is the whole of the story — `SendKeyEvent()`
|
|
558
|
+
sends `XTestFakeKeyEvent()`, and for a character the current layout cannot
|
|
559
|
+
type, `RemapKeycode()` writes the keysym into a spare keycode with
|
|
560
|
+
`XkbSetMap()` first, `XSync`ing before it types.
|
|
561
|
+
|
|
562
|
+
For us that is good news and one hazard:
|
|
563
|
+
|
|
564
|
+
- **A faked key is an ordinary key.** XTEST events are delivered by the
|
|
565
|
+
server through the normal event path, so `PasswordInput` — and every other
|
|
566
|
+
focusable node — cannot tell auto-type from a person, and nothing has to be
|
|
567
|
+
done to support it. An auto-type sequence of `{USERNAME}{TAB}{PASSWORD}
|
|
568
|
+
{ENTER}` walks a react-x11 form because Tab moves focus and Enter reaches
|
|
569
|
+
`onSubmit`.
|
|
570
|
+
- **The window needs a title worth matching.** Auto-type's default matching is
|
|
571
|
+
on the window title, so `<window title="…">` is the integration surface.
|
|
572
|
+
A window titled after the document with nothing identifying the app is one
|
|
573
|
+
a user cannot write an auto-type rule for; `appId` is worth setting too,
|
|
574
|
+
since a manager that grew a smarter matcher would read that.
|
|
575
|
+
- **The keymap race is real.** ntk refetches the mapping when the server sends
|
|
576
|
+
`MappingNotify`, which is what makes a remapped keycode decode correctly —
|
|
577
|
+
but the refetch is a round trip, and a manager that remaps, syncs and types
|
|
578
|
+
immediately can land its key before the reply does. The window is small and
|
|
579
|
+
only affects characters outside the user's layout, but a password of ASCII
|
|
580
|
+
is not the case that fails. Nothing here can close it; the fix belongs where
|
|
581
|
+
the map lives.
|
|
582
|
+
|
|
583
|
+
### 2. Pasting — the clipboard, and the hint that keeps it out of history
|
|
584
|
+
|
|
585
|
+
The other path every manager offers is copy-to-clipboard, usually with a
|
|
586
|
+
countdown before it clears. That is ordinary `CLIPBOARD` interop
|
|
587
|
+
([clipboard.md](clipboard.md)); `PasswordInput` takes Ctrl+V and
|
|
588
|
+
Shift+Insert, and strips control characters so a manager's trailing newline
|
|
589
|
+
does not end up inside the secret.
|
|
590
|
+
|
|
591
|
+
If your app ever puts a secret **on** the clipboard — this widget never does
|
|
592
|
+
— offer `x-kde-passwordManagerHint` with the value `secret` beside the text.
|
|
593
|
+
Klipper drops such an offer from its history, KDE Connect refuses to forward
|
|
594
|
+
it, and `wl-clipboard` marks the state sensitive. It is a convention rather
|
|
595
|
+
than a specification, and it is the only thing standing between a copied
|
|
596
|
+
password and a clipboard manager's on-disk history:
|
|
597
|
+
|
|
598
|
+
```js
|
|
599
|
+
clipboard.write({ UTF8_STRING: secret, 'x-kde-passwordManagerHint': 'secret' });
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
`PasswordInput` also never takes the **PRIMARY** selection, which a
|
|
603
|
+
`<textinput>` does on every selection: PRIMARY is pasted by a middle click in
|
|
604
|
+
any window on the display, and a secret does not belong in a selection that
|
|
605
|
+
can be spent by accident.
|
|
606
|
+
|
|
607
|
+
### 3. Fetching it yourself — the Secret Service
|
|
608
|
+
|
|
609
|
+
When the app has an account of its own to unlock, the seam is not a field at
|
|
610
|
+
all: the **Secret Service** D-Bus API, `org.freedesktop.secrets`, which
|
|
611
|
+
gnome-keyring, KWallet and KeePassXC all implement, and which `libsecret`
|
|
612
|
+
speaks for C applications. react-x11 has no wrapper for it and does not need
|
|
613
|
+
one — `useSessionBus()` reaches it directly ([dbus.md](dbus.md)) — and the
|
|
614
|
+
shape is: search by attributes, unlock the collection if it is locked, read
|
|
615
|
+
the secret back. An app that does this shows no password field on most
|
|
616
|
+
launches, which is a better outcome than any field can offer.
|
|
617
|
+
|
|
618
|
+
Inside a sandbox that name is not there. Flatpak's answer is
|
|
619
|
+
`org.freedesktop.portal.Secret`, whose `RetrieveSecret` hands the app a
|
|
620
|
+
per-application master secret down a pipe; libsecret switches to it
|
|
621
|
+
automatically and encrypts a local store with it.
|
|
622
|
+
|
|
623
|
+
### 4. Reading the field — AT-SPI, which does not mark it yet
|
|
624
|
+
|
|
625
|
+
The accessibility bus is the only channel through which an outside program
|
|
626
|
+
can see a field's _contents and role_ rather than guess at a window title:
|
|
627
|
+
AT-SPI2 gives a password entry the role `password text`, which is how a
|
|
628
|
+
screen reader knows to announce "bullet" instead of the character. Some
|
|
629
|
+
automation leans on the same tree. react-x11 does publish one
|
|
630
|
+
([accessibility.md](accessibility.md)), but `PasswordInput` is a plain
|
|
631
|
+
`role="textbox"` in it and nothing maps a node to `password text`
|
|
632
|
+
(`src/a11y.js` declares the role and no node takes it), so this seam is still
|
|
633
|
+
closed — worth opening as a role in the shared model rather than as a
|
|
634
|
+
password-shaped hole beside it.
|
|
635
|
+
|
|
636
|
+
### What a field can still do wrong
|
|
637
|
+
|
|
638
|
+
None of the above stops the field itself from leaking. What `PasswordInput`
|
|
639
|
+
does about that, and what it cannot:
|
|
640
|
+
|
|
641
|
+
- the masked value is **never laid out or drawn** — no glyphs of the secret
|
|
642
|
+
reach ntk's shaping cache or the X server, and the mask's width is measured
|
|
643
|
+
from one reference character;
|
|
644
|
+
- there is **no copy, no selection, no undo history**;
|
|
645
|
+
- the value is still a JavaScript string, and strings are immutable. It
|
|
646
|
+
cannot be zeroed, it lives until the garbage collector takes it, and every
|
|
647
|
+
intermediate value typed on the way lives alongside it. A design that needs
|
|
648
|
+
more than that needs the secret to never enter this process.
|