react-x11 2.51.1 → 2.53.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.
Files changed (121) hide show
  1. package/README.md +94 -61
  2. package/docs/README.md +256 -0
  3. package/docs/accessibility.md +575 -0
  4. package/docs/appearance.md +317 -0
  5. package/docs/click-to-component.md +92 -0
  6. package/docs/clipboard.md +262 -0
  7. package/docs/components.md +1231 -0
  8. package/docs/dbus.md +205 -0
  9. package/docs/debugging.md +371 -0
  10. package/docs/desktop-calendar.md +247 -0
  11. package/docs/desktop.md +523 -0
  12. package/docs/devtools.md +133 -0
  13. package/docs/drag-and-drop.md +585 -0
  14. package/docs/ecosystem/animation.md +360 -0
  15. package/docs/ecosystem/data-fetching.md +186 -0
  16. package/docs/ecosystem/dev-tooling.md +393 -0
  17. package/docs/ecosystem/forms.md +277 -0
  18. package/docs/ecosystem/headless.md +194 -0
  19. package/docs/ecosystem/i18n.md +86 -0
  20. package/docs/ecosystem/icons.md +377 -0
  21. package/docs/ecosystem/layout.md +206 -0
  22. package/docs/ecosystem/routing.md +135 -0
  23. package/docs/ecosystem/state.md +344 -0
  24. package/docs/ecosystem/testing.md +474 -0
  25. package/docs/ecosystem/theming.md +494 -0
  26. package/docs/ecosystem.md +439 -0
  27. package/docs/elements.md +2079 -0
  28. package/docs/embedding.md +239 -0
  29. package/docs/events.md +742 -0
  30. package/docs/extending.md +1700 -0
  31. package/docs/eyedropper.md +164 -0
  32. package/docs/filedialog.md +222 -0
  33. package/docs/frame.md +436 -0
  34. package/docs/gl.md +173 -0
  35. package/docs/globalmenu.md +195 -0
  36. package/docs/macos.md +592 -0
  37. package/docs/notifications.md +150 -0
  38. package/docs/packaging.md +468 -0
  39. package/docs/permissions.md +143 -0
  40. package/docs/react-features.md +405 -0
  41. package/docs/remote.md +179 -0
  42. package/docs/scale.md +225 -0
  43. package/docs/security.md +132 -0
  44. package/docs/styling.md +1710 -0
  45. package/docs/system.md +519 -0
  46. package/docs/testing.md +494 -0
  47. package/docs/typescript.md +75 -0
  48. package/docs/uri-schemes.md +340 -0
  49. package/docs/wayland-backend.md +332 -0
  50. package/docs/windows-integrations.md +177 -0
  51. package/docs/windows.md +380 -0
  52. package/package.json +11 -3
  53. package/src/Reconciler.js +2 -2
  54. package/src/a11y.js +1 -1
  55. package/src/backend/context2d.js +3 -3
  56. package/src/bootstrap.js +1 -1
  57. package/src/bus.js +39 -26
  58. package/src/cli/create.d.ts +43 -0
  59. package/src/cli/create.js +305 -0
  60. package/src/cli/env.js +484 -0
  61. package/src/cli/index.js +29 -2
  62. package/src/cli/templates/AGENTS.template.md +195 -0
  63. package/src/cli/templates/DESIGN.template.md +269 -0
  64. package/src/cocoa/app.js +6 -6
  65. package/src/cocoa/bezels.js +1 -1
  66. package/src/cocoa/fonts.js +1 -1
  67. package/src/cocoa/globalmenu.js +1 -1
  68. package/src/cocoa/keymap.js +1 -1
  69. package/src/cocoa/panewindow.js +1 -1
  70. package/src/cocoa/player.js +1 -1
  71. package/src/cocoa/presenter.js +2 -2
  72. package/src/cocoa/promotion.js +1 -1
  73. package/src/cocoa/relaunch.js +1 -1
  74. package/src/cocoa/threaded.js +1 -1
  75. package/src/cocoa/video.js +1 -1
  76. package/src/cocoa/window.js +4 -4
  77. package/src/components/Menu.js +7 -2
  78. package/src/components/Switch.js +1 -1
  79. package/src/components/native.js +1 -1
  80. package/src/desktopintegration.js +2 -1
  81. package/src/desktopsettings.js +193 -24
  82. package/src/dnd.js +2 -2
  83. package/src/filedialog.js +3 -3
  84. package/src/glnodes.js +1 -1
  85. package/src/grid.js +1 -1
  86. package/src/keyboardstate.js +28 -0
  87. package/src/layouts.js +1 -1
  88. package/src/node.d.ts +10 -10
  89. package/src/nodes/animation.js +1 -1
  90. package/src/nodes/invalidate.js +1 -1
  91. package/src/nodes/layouthost.js +1 -1
  92. package/src/nodes/video.js +19 -8
  93. package/src/nodes/window/droptarget.js +1 -1
  94. package/src/pacing.js +1 -1
  95. package/src/portal.js +31 -0
  96. package/src/refresh/index.js +2 -1
  97. package/src/screencolor.js +5 -10
  98. package/src/types/system.d.ts +8 -6
  99. package/src/types/video.d.ts +5 -2
  100. package/src/videobackend.js +28 -0
  101. package/src/videoframes.js +29 -16
  102. package/src/wayland/app.js +86 -0
  103. package/src/wayland/clipboard.js +14 -7
  104. package/src/wayland/connection.js +3 -0
  105. package/src/wayland/context2d.js +129 -10
  106. package/src/wayland/protocols/index.json +6 -0
  107. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  108. package/src/wayland/seat.js +28 -1
  109. package/src/wayland/swapchain.js +23 -0
  110. package/src/wayland/video.js +376 -0
  111. package/src/wayland/xkb.js +137 -0
  112. package/src/win32/a11y.js +1 -1
  113. package/src/win32/app.js +2 -2
  114. package/src/win32/fonts.js +1 -1
  115. package/src/win32/glarea.js +4 -4
  116. package/src/win32/overlay.js +1 -1
  117. package/src/win32/panewindow.js +3 -3
  118. package/src/win32/shell.js +1 -1
  119. package/src/win32/window.js +2 -2
  120. package/src/windowid.js +1 -1
  121. package/src/xvideo.js +189 -0
@@ -0,0 +1,340 @@
1
+ # Custom URI schemes and single instances
2
+
3
+ Being the app that `com.example.myapp://…` opens: a link clicked in a browser,
4
+ a chat message or a launcher reaches the **already-running** app, and its
5
+ window comes to the front.
6
+
7
+ The flow this exists for is logging in through the browser. The app opens the
8
+ system browser, the provider redirects to
9
+ `com.example.myapp://auth?code=…`, the desktop routes that URI back, and the
10
+ app finishes the login without asking anyone to paste anything.
11
+
12
+ ## Read this first: for a login, you probably want a loopback port
13
+
14
+ RFC 8252 §7.3 has a second answer that needs none of the machinery below —
15
+ redirect to `http://127.0.0.1:<port>/cb` and listen on it:
16
+
17
+ ```js
18
+ import { createServer } from 'node:http';
19
+
20
+ const server = createServer((req, res) => {
21
+ const url = new URL(req.url, 'http://127.0.0.1');
22
+ res.end('You can close this tab.');
23
+ finishLogin(url.searchParams.get('code'));
24
+ });
25
+ server.listen(0); // "Authorization servers MUST allow any port" — RFC 8252 §7.3
26
+ const port = server.address().port;
27
+ ```
28
+
29
+ No desktop registration, no D-Bus, no install step, and it works on XQuartz,
30
+ in a container and over ssh where a scheme handler does not. **For an OAuth
31
+ flow, make this the default and use the rest of this page only when you
32
+ cannot.**
33
+
34
+ Custom schemes are still needed for providers that only accept a registered
35
+ private-use scheme, and for deep links that are not logins
36
+ (`myapp://project/42` from a chat message, a document opened from a launcher).
37
+
38
+ ## Four things have to be true
39
+
40
+ | | | where |
41
+ | ---------------- | ---------------------------------------------------------- | ----------------------- |
42
+ | **Registration** | a `.desktop` entry claiming `x-scheme-handler/…` | an install step |
43
+ | **Dispatch** | the URI reaching the running process | `registerApplication()` |
44
+ | **Delivery** | `org.freedesktop.Application.Open`, answered before the UI | `useAppOpen()` |
45
+ | **The raise** | `_NET_ACTIVE_WINDOW`, with the right timestamp | `activateWindow()` |
46
+
47
+ The raise is the one that decides whether a user calls the feature working;
48
+ see [the raise is the part that fails](#the-raise-is-the-part-that-fails).
49
+
50
+ ## The code
51
+
52
+ ```js
53
+ import { createRoot, registerApplication } from 'react-x11';
54
+
55
+ const app = await registerApplication({
56
+ appId: 'com.example.myapp',
57
+ schemes: ['com.example.myapp'],
58
+ });
59
+ if (app?.role === 'secondary') process.exit(0); // we do not exit for you
60
+
61
+ const root = await createRoot();
62
+ root.render(<App />);
63
+ ```
64
+
65
+ ```jsx
66
+ import { activateWindow, useAppOpen } from 'react-x11';
67
+
68
+ function App() {
69
+ const win = useRef(null);
70
+ useAppOpen((uris, ctx) => {
71
+ activateWindow(win, { timestamp: ctx.timestamp });
72
+ const code = new URL(uris[0]).searchParams.get('code');
73
+ finishLogin(code);
74
+ });
75
+ return <window ref={win}>…</window>;
76
+ }
77
+ ```
78
+
79
+ That is the whole runtime half.
80
+
81
+ ### `registerApplication()`
82
+
83
+ ```ts
84
+ function registerApplication(options: {
85
+ appId: string;
86
+ schemes?: string[];
87
+ onOpen?: (uris: string[], ctx: LaunchContext) => void;
88
+ onActivate?: (ctx: LaunchContext) => void;
89
+ onAction?: (name: string, params: unknown[], ctx: LaunchContext) => void;
90
+ argv?: string[];
91
+ }): Promise<AppRegistration | null>;
92
+
93
+ interface AppRegistration {
94
+ readonly role: 'primary' | 'secondary';
95
+ readonly appId: string;
96
+ readonly objectPath: string;
97
+ release(): Promise<void>;
98
+ }
99
+ ```
100
+
101
+ **Call it before `createRoot()`.** On the D-Bus path the bus _started this
102
+ process_ because someone called `Open`, and that call is outstanding while the
103
+ app boots — registering from inside a component answers it with an
104
+ unknown-method error, and the launch is lost.
105
+
106
+ **`appId` is not a free parameter.** The desktop entry spec ties the
107
+ well-known bus name, the `.desktop` file's basename and (RFC 8252 §7.1) the URI
108
+ scheme to one string. The object path is derived from it: dots become slashes,
109
+ a slash goes on the front, and **a dash becomes an underscore**
110
+ (`com.example.my-app` is served at `/com/example/my_app`).
111
+
112
+ **`null` means there is no session bus** — ssh, a bare `startx`, a container,
113
+ CI, Node 20 without the transport. The app runs as an ordinary single-window
114
+ program. A URI that arrived in `argv` is still delivered to `useAppOpen`: a
115
+ cold-start deep link needs no bus, only a second instance does.
116
+
117
+ **A malformed `appId` or an unusable scheme throws**, which is not a hole in
118
+ [the never-throws rule](dbus.md#the-rule-everything-else-is-subordinate-to):
119
+ `null` means "this machine has no bus", and a typo must not hide behind it.
120
+
121
+ ### Two dispatch paths, and why `role` exists
122
+
123
+ Openers that honour `DBusActivatable=true` (GIO: `gio open`, most of GNOME)
124
+ call `Open` on the running app. `xdg-open`'s generic fallback — which Chromium
125
+ uses, and which every WM-only session gets — ignores it and runs the `Exec`
126
+ line, so **a second copy of your app is spawned with the URI in `argv`**.
127
+ `registerApplication` handles both: the second copy forwards the URI to the
128
+ first and reports its role.
129
+
130
+ | `role` | |
131
+ | ------------- | ------------------------------------------------------------------ |
132
+ | `'primary'` | this process owns the name and will receive `Open` |
133
+ | `'secondary'` | another instance owns it; this launch was forwarded — **exit now** |
134
+
135
+ It does not call `process.exit()` for you; exiting is the app's decision.
136
+
137
+ A second launch with **no** URI forwards `Activate` rather than an empty
138
+ `Open`, so "the user started the app again" and "the user clicked a link"
139
+ arrive as different events.
140
+
141
+ ### Delivery: buffered, and replayed
142
+
143
+ `Open` is answered on the turn it arrives — never when the UI is ready. The
144
+ URIs are buffered and replayed to the **first handler that attaches**, so an
145
+ app whose React tree mounts a second later loses nothing, and an app whose
146
+ first frame is slow does not creep towards the caller's 25-second timeout.
147
+
148
+ Replay happens once. A second subscriber does not get the same login again.
149
+
150
+ ```ts
151
+ function useAppOpen(
152
+ handler: (uris: string[], ctx: LaunchContext) => void,
153
+ ): void;
154
+ function useAppActivate(handler: (ctx: LaunchContext) => void): void;
155
+
156
+ // the same two, for code that has no component to hang off
157
+ function onAppOpen(handler): () => void;
158
+ function onAppActivate(handler): () => void;
159
+
160
+ interface LaunchContext {
161
+ readonly platformData: Readonly<Record<string, unknown>>;
162
+ readonly timestamp: number | null;
163
+ readonly startupId: string | null;
164
+ readonly activationToken: string | null;
165
+ }
166
+ ```
167
+
168
+ There is **nothing to wrap** — no provider, no prop drilling. The registration
169
+ is process-global, so a component library can call these hooks.
170
+
171
+ `schemes` filters what arrives: a URI whose scheme you did not register is
172
+ dropped before your handler sees it. `file:` is always allowed alongside them,
173
+ because `Open` is also how a file manager says "open this document with you".
174
+ Declaring no schemes at all filters nothing.
175
+
176
+ ### The raise is the part that fails
177
+
178
+ ```ts
179
+ function activateWindow(
180
+ target?: NtkWindow | DrawnNode | RefObject<…> | number | null,
181
+ options?: { timestamp?: number | null; source?: 1 | 2 },
182
+ ): boolean;
183
+ ```
184
+
185
+ Sends `_NET_ACTIVE_WINDOW` to the root window: source indication, timestamp,
186
+ and this app's currently active toplevel. `target` is anything
187
+ [`windowIdOf()`](elements.md) accepts, or nothing — in which case this app's
188
+ only top-level window is used, its focused one when it has several.
189
+
190
+ **The timestamp is the whole feature.** A window manager's focus-stealing
191
+ prevention weighs the X timestamp of the user action that triggered the launch
192
+ (the digits after `_TIME` in the startup id). Get it wrong and nothing reports
193
+ an error: the app receives the code, the WM declines to raise it, and the
194
+ taskbar entry blinks.
195
+
196
+ | `timestamp` | |
197
+ | --------------- | ------------------------------------------------------------------------------------------------------------- |
198
+ | omitted | the last input this app saw — EWMH's own definition of the field |
199
+ | `ctx.timestamp` | **what a deep link should pass**: the click in the browser is the user action, not whatever this app last saw |
200
+ | `null` | `CurrentTime`; legal, and most window managers will decline it |
201
+
202
+ The return value is whether the request was **issued**, not whether it worked:
203
+ the window manager may refuse and set `_NET_WM_STATE_DEMANDS_ATTENTION`
204
+ instead (the blinking taskbar entry), and no client can tell.
205
+
206
+ ## Registration: the half that is not runtime code
207
+
208
+ react-x11 does **not** write into `~/.local/share` for you (that would not
209
+ work for a system-installed or Flatpak app anyway). Ship these from your
210
+ installer, your package, or a setup command your users run.
211
+
212
+ `~/.local/share/applications/com.example.myapp.desktop` — the basename is tied
213
+ to the bus name by the spec:
214
+
215
+ ```ini
216
+ [Desktop Entry]
217
+ Type=Application
218
+ Name=My App
219
+ Exec=my-app %u
220
+ Icon=com.example.myapp
221
+ DBusActivatable=true
222
+ StartupWMClass=com.example.myapp
223
+ MimeType=x-scheme-handler/com.example.myapp;
224
+ ```
225
+
226
+ `StartupWMClass` must match the `appId` prop on your `<window>`, or the
227
+ window manager cannot match the window that appears to the launch that asked
228
+ for it.
229
+
230
+ Then tell the MIME database it changed:
231
+
232
+ ```bash
233
+ update-desktop-database ~/.local/share/applications
234
+ ```
235
+
236
+ And, so that the bus can _start_ the app when it is not running,
237
+ `~/.local/share/dbus-1/services/com.example.myapp.service`:
238
+
239
+ ```ini
240
+ [D-BUS Service]
241
+ Name=com.example.myapp
242
+ Exec=/usr/bin/my-app
243
+ ```
244
+
245
+ ### Checking it by hand
246
+
247
+ ```bash
248
+ gio open com.example.myapp://test # path A: D-Bus activation
249
+ ```
250
+
251
+ ```bash
252
+ env -u XDG_CURRENT_DESKTOP xdg-open com.example.myapp://test # path B: Exec %u
253
+ ```
254
+
255
+ Run each twice: once with the app closed (it should start and handle the link)
256
+ and once with it running (it should come to the front, and no second window
257
+ should appear). `REACT_X11_DEBUG_URI=1` narrates what this module decided —
258
+ with every URI's query and fragment stripped, so the output is safe to paste
259
+ into an issue.
260
+
261
+ ## Security
262
+
263
+ The URI is attacker-controlled input from an unauthenticated local caller.
264
+
265
+ - **Scheme squatting is unfixable and expected.** Nothing in the MIME database
266
+ enforces uniqueness; the last handler to register wins. RFC 8252 §6 requires
267
+ PKCE for public native clients — **use it**, and do not treat receiving a
268
+ code as proof of anything. The reverse-domain naming rule (why `appId` is
269
+ validated) is the other half of the mitigation.
270
+ - **`Open` is callable by any peer on the session bus.** It is not an
271
+ authenticated entry point: the bus is a per-user socket, not an
272
+ authorization check. Validate what you receive, and never turn a URI's path
273
+ into a filesystem path.
274
+ - **`platform_data` is spoofable.** A hostile local process can fabricate a
275
+ `desktop-startup-id` and make your app raise itself. Same uid, so it is focus
276
+ theft rather than data loss — but treat `ctx.timestamp` as a hint, not a
277
+ credential.
278
+ - **On the `xdg-open` path the URI lands in `argv`**, so a code is briefly
279
+ visible in `/proc/<pid>/cmdline` to processes of the same user. The D-Bus
280
+ path does not have this property; loopback avoids it too.
281
+ - **Never log the URI.** react-x11 does not: everything on
282
+ `REACT_X11_DEBUG_URI` goes through a redaction that drops the query, the
283
+ fragment and any userinfo. Hold your own logging to the same rule — the code
284
+ is _in_ the URI.
285
+
286
+ ## On macOS
287
+
288
+ The same hooks, a different transport. On the cocoa backend the OS talks to
289
+ the application through **Apple Events**, which `@windowkit/appkit` (≥ 0.5)
290
+ forwards into the same delivery as a D-Bus `Open`:
291
+
292
+ | | |
293
+ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
294
+ | `kAEGetURL` — a URL for a scheme the bundle claims | `useAppOpen(uris, ctx)`, filtered by the registration's `schemes` like an `Open` |
295
+ | `kAEOpenDocuments` — a document from the Finder | `useAppOpen`, as `file://` URLs (`file:` is always allowed) |
296
+ | `kAEReopenApplication` — a Dock click, `open -a` | `useAppActivate(ctx)`, with `ctx.platformData['has-visible-windows']` |
297
+ | `kAEQuitApplication` — Dock › Quit, ⌘Q, a logout | the **primary window's close request**: `onCloseRequest` where there is one, else the default, which closes the app |
298
+
299
+ Three things differ from the bus, and none of them changes app code:
300
+
301
+ - **Single instance is the platform default.** Launch Services activates the
302
+ running app rather than starting a second one, so there is no `secondary`
303
+ role to exit from; `registerApplication()` resolves `null` on a Mac with no
304
+ D-Bus (the normal Mac) and its `schemes` still filter what arrives.
305
+ - **`ctx.timestamp` is `null`.** Apple Events carry no startup id, and the
306
+ raise does not need one: `activateWindow()` there is `NSApp.activate`, which
307
+ no focus-stealing rule weighs.
308
+ - **Registration is `Info.plist`, not a `.desktop` file** — `CFBundleURLTypes`
309
+ for the scheme and `CFBundleDocumentTypes` for documents, in an app bundle
310
+ (see [packaging.md](packaging.md)). A bare `node` process receives nothing
311
+ from Launch Services, because there is no bundle for it to look up.
312
+
313
+ A quit becomes the same close request the red light sends to the app's primary
314
+ window: an `onCloseRequest` that opens a "save your work?" dialog has vetoed it
315
+ until it decides, and one that unmounts has accepted it. Once the app has
316
+ closed on a quit, the process ends
317
+ (`createRoot({ cocoa: { exitOnQuit: false } })` turns that off for an embedder
318
+ that owns the process). One limit: a **logout** is cancelled by macOS the
319
+ moment it asks, and the app then quits on its own — the logout has to be
320
+ started again.
321
+
322
+ ## Over ssh
323
+
324
+ Both mechanisms only work when the browser runs on the **same host as the
325
+ app**. A URL pasted into your laptop's browser reaches neither a loopback port
326
+ nor a scheme handler on the server you are `ssh -X`'d into.
327
+
328
+ ## Deliberately not here
329
+
330
+ - **Desktop actions** (jump-list entries): `ActivateAction` replies correctly
331
+ and `onAction` receives it; nothing more.
332
+ - **Writing `.desktop` files** — your installer's job (see above).
333
+ - **A full OAuth client** — no PKCE implementation or token store.
334
+ - **`http`/`https` handling** — being the default browser is not this.
335
+ - **The launcher's Wayland `xdg-activation` token** is exposed on
336
+ `ctx.activationToken` and otherwise ignored; on the
337
+ [Wayland backend](wayland-backend.md) `activateWindow()` requests a token of
338
+ its own.
339
+
340
+ Design notes: [design/uri-schemes.md](../design/uri-schemes.md).
@@ -0,0 +1,332 @@
1
+ # The Wayland backend
2
+
3
+ **Status: a working backend, opt-in.** react-x11's own reconciler renders a
4
+ window on a real Wayland compositor (mutter/GNOME, sway, labwc) with
5
+ decorations — the compositor's where it draws them, client-side where it
6
+ does not — text, shadows, paths, images, live partial repaints, pointer,
7
+ touch, pen and keyboard input decoded against the compositor's keymap, an
8
+ input method, a clipboard, drag and drop, popups, layer-shell docks and
9
+ panels, and a screen capture with an eyedropper on top — drawn on the GPU
10
+ and handed over as dma-buf, on **Node or Bun**, with no X server and no
11
+ Xwayland anywhere in the path.
12
+
13
+ It implements the research RFC in [design/wayland.md](../design/wayland.md);
14
+ where the two disagree, this page is what ran. How it is built, what was
15
+ measured and what is left are in the
16
+ [design notes](../design/wayland-backend.md).
17
+
18
+ ```bash
19
+ REACT_X11_BACKEND=wayland node your-app.mjs # x11-dri >= 0.9 (native socket)
20
+ REACT_X11_BACKEND=wayland bun your-app.mjs # or Bun's bun:ffi transport
21
+ node examples/wayland-gl.mjs # GPU plasma, paced by the compositor
22
+ node examples/wayland-ui.mjs # every drawing primitive, no React
23
+ node --import tsx --test 'test/wayland/*.test.js' # the suite: no display needed
24
+ ```
25
+
26
+ The two packages it needs beyond react-x11's own are optional dependencies,
27
+ installed with it: [`@windowkit/wayland`](https://github.com/windowkit/wayland),
28
+ the protocol layer — a fork of `wayland-client` that passes file descriptors
29
+ — and [`x11-dri`](https://github.com/sidorares/node-x11-dri) 0.9 or later,
30
+ the GPU context and, on Node, the socket: Wayland passes file descriptors
31
+ (the keymap, shm pools, clipboard pipes) and Node's own sockets cannot. Under
32
+ Bun the socket is node-x11's `bun:ffi` transport instead.
33
+
34
+ The backend is never chosen by `'auto'`: X11 stays the default on Linux
35
+ because the remote case ([remote.md](remote.md)) is the flagship reason this
36
+ project exists and Wayland has no network transparency.
37
+
38
+ ## How to tell it is not Xwayland
39
+
40
+ A GNOME session answers `$DISPLAY` too, through Xwayland, so "the window
41
+ appeared" proves nothing. Three checks that do:
42
+
43
+ - **Unset `DISPLAY`.** The Wayland backend never opens it; the X11 backend
44
+ cannot start without it.
45
+ ```bash
46
+ env -u DISPLAY REACT_X11_BACKEND=wayland node app.mjs # works only natively
47
+ ```
48
+ - **Ask the app.** `root.app.backend === 'wayland'`, and `root.app.X` is a
49
+ stand-in — no `display.screen`, no `X.require`. The examples print the
50
+ transport they connected through (`connected via x11-dri` or
51
+ `fdpass-bun`) and never import an X module at all.
52
+ - **Ask the desktop.** `xlsclients` and `xwininfo -root -tree` list Xwayland
53
+ clients; a native window is absent from both. GNOME's Looking Glass
54
+ (`Alt+F2`, `lg`, Windows tab) shows it as a `MetaWindowWayland`, and its
55
+ frame is this backend's own titlebar rather than mutter's Adwaita frame,
56
+ which only Xwayland windows get.
57
+
58
+ ## What runs
59
+
60
+ | piece | what an app gets |
61
+ | ------------------ | --------------------------------------------------------------------------------------- |
62
+ | windows and popups | xdg toplevels and popups, fractional scale ([below](#windows-what-maps-what-bends)) |
63
+ | 2d context | rects, rounded rects, paths, exact clips, gradients, patterns, images, text, glyph runs |
64
+ | `<glarea>` | GLES drawing into a rectangle of the window, its children composited over it |
65
+ | offscreen surfaces | the paint cache and scroll blits; a held `Surface` context draws at any time |
66
+ | input | pointer, keyboard decoded against the compositor's keymap, key repeat |
67
+ | touch and tablet | fingers and pens drive the pointer, with pen fields; raw touches |
68
+ | input methods | the compositor's IME (`zwp_text_input_v3`) into the composition events |
69
+ | decorations | the compositor's frame where it offers one, client-side otherwise |
70
+ | layer surfaces | docks, panels, wallpapers, overlays — wlr-layer-shell |
71
+ | screen capture | wlr-screencopy / ext-image-copy-capture; the eyedropper |
72
+ | clipboard | clipboard and primary selection |
73
+ | drag and drop | both directions, in-app drops keep `e.items` by reference; no drag icon yet |
74
+ | screens, keyboard | `useScreens()` and `useKeyboardState()` ([system.md](system.md#on-wayland)) |
75
+ | tests | `test/wayland/`: an in-process compositor, no display needed |
76
+
77
+ The keymap is the compositor's own, so AltGr and levels 3 and 4 work
78
+ because the keymap says which modifier they are, not because one is
79
+ assumed. Compositors do not repeat keys; the backend repeats them at the
80
+ seat's advertised rate.
81
+
82
+ **Touch and pen.** A finger or a tablet tool drives the same mouse events
83
+ as a mouse, and only those events carry `pointerType` (`'touch'`, `'pen'`,
84
+ `'eraser'`, `'mouse'`), `pressure`, `tiltX`/`tiltY`, `rotation`,
85
+ `distance` and `tangentialPressure`, in the DOM's names and ranges
86
+ ([events.md](events.md#event-object)); a mouse event is unchanged. Only
87
+ the first finger of a gesture emulates the pointer. Every finger also
88
+ arrives raw on the window as `touchstart`/`touchmove`/`touchend`/
89
+ `touchcancel` (the X11 backend's XI2 names, with `touchId`), each with the
90
+ active `touches`, batched per touch frame. Barrel buttons map as the
91
+ desktop does — BTN_STYLUS is button 3, BTN_STYLUS2 button 2 — and a tool
92
+ leaving proximity releases whatever it still held.
93
+
94
+ **Input methods.** IBus or whatever the compositor runs reaches
95
+ `<textinput>` and `onCompositionStart`/`Update`/`End` exactly as a dead key
96
+ does. Keys the IME consumes never arrive as key events. A `sensitive`
97
+ field offers the IME no surrounding text and says `sensitive_data`;
98
+ `inputMode` on a `<textinput>` becomes the content purpose (`email`, `url`,
99
+ `tel`, …).
100
+
101
+ **`<glarea>`** is a rectangle of the window's own GPU target, not a child
102
+ surface: the area gets the shared GLES context with `viewport`, `scissor`
103
+ and `bindFramebuffer(null)` re-based on its rect, the tree's 2D is drawn
104
+ under it and the panes its children paint on are blended over it.
105
+
106
+ **Perspective images.** `ctx.drawImageProjected(image, m)` draws an image
107
+ through a 3×3 projection (a plane turned out of the page, as CSS
108
+ `rotateY()` in a `perspective` turns one) as one exact quad. It answers
109
+ `false` and draws nothing where a corner is behind the viewer, for the
110
+ caller to draw the part in front some other way. Only this backend has it
111
+ (XRender on glamor folds the image, CoreGraphics and Direct2D draw through
112
+ an affine matrix alone): test with `'drawImageProjected' in ctx` and tile
113
+ where it is absent.
114
+
115
+ **Glyph runs.** `ctx.drawGlyphs(op, src, positioned)`, for callers that
116
+ place their own glyphs (a terminal grid, a tabular column), is spelled as on
117
+ X11 and Cocoa: the op `ctx.Render.PictOp.Over`, the source
118
+ `ctx.createSolidPicture(r, g, b, a)` — or, as Cocoa also takes, a CSS
119
+ colour string, a premultiplied `[r, g, b, a]`, or `null` for the fill style
120
+ in force.
121
+
122
+ ### Running the examples
123
+
124
+ Every example runs on this backend the way it runs on X11, with the display
125
+ taken away so nothing can fall back:
126
+
127
+ ```bash
128
+ env -u DISPLAY REACT_X11_BACKEND=wayland bun examples/widgets.jsx # or node --import tsx
129
+ ```
130
+
131
+ Two are X11 by nature and say so: `examples/wm.jsx` is a window manager and
132
+ `examples/xeyes.jsx` reads the root window's pointer — neither has a
133
+ Wayland counterpart. `examples/viewer3d.jsx` draws its model here through
134
+ its direct half, GLES on the window's own GPU target — the same half X11
135
+ uses under `NTK_GL_POLICY=direct`.
136
+
137
+ Environment variables, for desktops with no screenshot API and for
138
+ debugging:
139
+
140
+ - `REACT_X11_WAYLAND_TRACE=1` writes a line per presented frame.
141
+ - `REACT_X11_WAYLAND_SNAPSHOT=/tmp/frame.png` writes the backing target of
142
+ the first window at its 30th present (`REACT_X11_WAYLAND_SNAPSHOT_AT`
143
+ changes which) — the pixels the compositor was handed, read back from the
144
+ GPU.
145
+ - `REACT_X11_WAYLAND_FRAME_STALL_MS` is how long a frame callback may go
146
+ unanswered before the window counts as not shown (2000; `0` turns that
147
+ off) — see
148
+ [A surface the compositor stops showing stops drawing](#a-surface-the-compositor-stops-showing-stops-drawing).
149
+ - `REACT_X11_GL_DEVICE` names the DRM node to render on.
150
+
151
+ The GPU context is made on `glPolicy.devicePath`, else on
152
+ `REACT_X11_GL_DEVICE`, else on the first render node, and a device named
153
+ either way is the only one tried. A machine with no render node at all — a
154
+ GitHub-hosted runner, a Hyper-V or VirtualBox guest — usually still has a
155
+ card node Mesa renders on in software, so the backend then tries each
156
+ `/dev/dri/card*` in turn; headless weston with its GL renderer accepts what
157
+ it makes on a hosted runner.
158
+
159
+ ## Windows: what maps, what bends
160
+
161
+ The compositor owns placement, stacking and focus, so some of what a
162
+ `<window>` asks for on X11 means less here:
163
+
164
+ - **Position.** A toplevel cannot place itself: `x`/`y` on a `<window>` are
165
+ ignored, not an error, and the compositor's placement is final. A
166
+ toplevel's own position reads `0, 0`; no client knows where it is on
167
+ screen.
168
+ - **Popups** are placed relative to their parent window, and the compositor
169
+ slides and flips them to stay on screen — the policy `anchorRect` gets
170
+ from react-x11 on X11. A press outside a grabbing popup, or Escape, is
171
+ the compositor's to act on; it hides the popup and the tree hears
172
+ `onDismiss` as it would on X11.
173
+ - **Focus and raising** are the compositor's to give. `focus()` asks
174
+ through xdg-activation and is ignored without a recent user interaction;
175
+ attention requests go the same way; `raise()` and `lower()` do nothing.
176
+ - **States.** Maximised, fullscreen and minimised map; there is no
177
+ un-minimise request. Always-on-top, sticky and workspaces have no
178
+ equivalent.
179
+ - **Size.** A window sizes itself, except while the compositor imposes a
180
+ size — maximised, tiled, or mid-resize — when the size it asks for is
181
+ not applied.
182
+ - **Type and class.** `windowType` matters only where layer-shell exists
183
+ ([below](#layer-surfaces-docks-panels-wallpapers)); the window's class
184
+ (`app_id`) is fixed at creation.
185
+ - **A window the compositor is not showing does not draw** — next section.
186
+
187
+ ## A surface the compositor stops showing stops drawing
188
+
189
+ After the first present a window draws only when the compositor sends it a
190
+ frame callback, and a compositor is entitled never to send one to a
191
+ surface it is not showing — covered, on another workspace, minimised, the
192
+ screen locked. There is no timer-driven fallback, on purpose: painting
193
+ into a surface nobody sees is work for nothing.
194
+
195
+ The silence is **not an error**: nothing rejects, and React goes on
196
+ re-rendering into a window that does not draw, which from outside looks
197
+ like a hang. So the stall is reported twice over:
198
+
199
+ - **On stderr, once per window, in development**, when the window still had
200
+ something to draw. One line, naming the compositor as the reason and saying
201
+ the app is not hung.
202
+ - **In the tree**, as [`useWindowState().presenting`](system.md#presenting-and-the-wayland-frame-clock),
203
+ which also folds into `visible` — so an app can stop animating and
204
+ simulating rather than feeding a loop nobody is turning, which is what
205
+ every map, chart and game on this backend wants anyway.
206
+
207
+ Nothing has to be done to recover: the pending frame callback fires the
208
+ moment the surface is shown again, drawing resumes and `presenting` flips
209
+ back. The deadline is two seconds; `REACT_X11_WAYLAND_FRAME_STALL_MS`
210
+ changes it, and `0` turns the watch off entirely, timer, warning and all.
211
+
212
+ ## Decorations: whose frame
213
+
214
+ The backend draws its own titlebar where the compositor will not (mutter),
215
+ and asks for the compositor's frame where `zxdg_decoration_manager_v1` is
216
+ offered — wlroots (sway, labwc), KDE. When the compositor agrees, the
217
+ client-side frame goes away and the content grows into the whole surface,
218
+ which the tree sees as a resize; a window not yet shown comes up the size
219
+ it was declared, not a titlebar taller.
220
+
221
+ `createRoot({ decorations })` says what to ask for: `undefined`/`true`/
222
+ `'server'` prefer the compositor's frame, `'client'` never asks for it, and
223
+ `false` — as does `<window decorations={false}>` — wants no frame at all.
224
+ A frameless window still creates the decoration object where the protocol
225
+ exists, to _decline_ server-side: sway frames every toplevel that has not
226
+ said `client_side`.
227
+
228
+ ## Layer surfaces: docks, panels, wallpapers
229
+
230
+ Where the compositor advertises `zwlr_layer_shell_v1`, a `<window>` whose
231
+ `windowType` is one of these is not an `xdg_toplevel` but a layer surface,
232
+ the same `wl_surface`, frame loop and input underneath:
233
+
234
+ | `windowType` | layer | anchored to | exclusive zone |
235
+ | ---------------- | ------------ | ------------------------------------------------- | -------------- |
236
+ | `'dock'` | `top` | the edge its shape and position imply (see below) | its thickness |
237
+ | `'desktop'` | `background` | all four edges | -1 |
238
+ | `'notification'` | `top` | top and right, 12px in | 0 |
239
+ | `'splash'` | `overlay` | nothing — the compositor centres it | 0 |
240
+
241
+ A dock's edge is read the way an X window manager reads the same window:
242
+ wide and at `y={0}` is a top panel, wide otherwise a bottom one; tall and at
243
+ `x={0}` a left dock, tall otherwise a right one. Its size is what the tree
244
+ measured (or `width`/`height`), so a 560×56 dock sits centred on its edge
245
+ with 56px reserved; anchoring it to both ends of the edge stretches it:
246
+
247
+ ```jsx
248
+ <window windowType="dock" height={56}>…</window>
249
+ <window windowType="dock" height={40} layerShell={{ anchor: ['bottom', 'left', 'right'] }}>…</window>
250
+ <window layerShell={{ layer: 'overlay', anchor: 'bottom', margin: { bottom: 40 } }}>an OSD</window>
251
+ ```
252
+
253
+ `layerShell` names anything the type's default gets wrong — `layer`,
254
+ `anchor` (an edge, an array of edges, `'all'`, `'none'`), `exclusiveZone`
255
+ (a number, `'auto'` for the thickness, `-1`), `margin`,
256
+ `keyboardInteractivity` (`'none'`, `'on-demand'`, `'exclusive'`),
257
+ `namespace`, `output` — and `layerShell={false}` keeps a dock-typed window
258
+ an ordinary toplevel. Popups from a layer surface work (a dock's menus). A
259
+ stretched axis is the compositor's: `resize()` on it is not granted, and
260
+ the configured size wins. Without layer-shell — GNOME — the same JSX is a
261
+ toplevel.
262
+
263
+ ## Screen capture, and the eyedropper
264
+
265
+ `app.screenCapture` is there when the compositor offers either
266
+ `ext_image_copy_capture_manager_v1` (wlroots 0.19+, KDE 6.2+) or
267
+ `zwlr_screencopy_manager_v1` (sway 1.10 has this one), and null otherwise
268
+ (GNOME). `capture({ output, cursor })` resolves to one frame of the output
269
+ as straight RGBA, `{ width, height, data }`; `pixelAt(x, y)` to one device
270
+ pixel of it, as 0–255 channels. Each call is a fresh capture of the whole
271
+ output — for many pixels, `capture()` once; for anything per frame, not at
272
+ all.
273
+
274
+ `pickColor()` is the eyedropper, the way every wlroots colour picker works:
275
+ it freezes the output into a capture, shows it as a full-screen
276
+ layer-shell overlay that takes the keyboard, and reads the pixel under the
277
+ click — so it needs layer-shell as well as a capture protocol. Escape
278
+ cancels; Return, space and KP_Enter pick at the pointer. It answers
279
+ `{ r, g, b }` in 0–1. `useEyedropper()` uses it below the Screenshot portal
280
+ (the desktop's own, user-consented picker — GNOME's only route, and what
281
+ sway with xdg-desktop-portal-wlr uses too), so `useEyedropper().supported`
282
+ is true on a bare sway. Where the compositor has neither the portal's
283
+ picker nor capture plus layer-shell, `supported` is false: leave the
284
+ eyedropper button undrawn.
285
+
286
+ Two limits. With several outputs, the one captured by default is the one
287
+ the compositor advertised last; `capture({ output })` and
288
+ `layerShell.output` take another `wl_output` proxy. And a portal dialog (file chooser, colour picker) is modal
289
+ to its window only where the compositor has **xdg-foreign**; without it, or
290
+ from a popup, the dialog floats.
291
+
292
+ ## Under a second compositor
293
+
294
+ The backend runs on sway and labwc as well as mutter, nested
295
+ (`WLR_BACKENDS=wayland`) or headless (`WLR_BACKENDS=headless`). Headless
296
+ sway plus `grim` is a way to screenshot an app with no desktop at all —
297
+ `grim` needs wlr-screencopy, which mutter refuses:
298
+
299
+ ```bash
300
+ WLR_BACKENDS=headless sway -c sway.conf & # output * resolution 1280x800
301
+ WAYLAND_DISPLAY=wayland-1 REACT_X11_BACKEND=wayland node --import tsx examples/simple.jsx &
302
+ WAYLAND_DISPLAY=wayland-1 grim shot.png
303
+ ```
304
+
305
+ ## Not supported yet
306
+
307
+ - **Multisampling.** Paths filled with a gradient, solid paths larger than
308
+ 512×512 after clipping, and clips to anything but one axis-aligned
309
+ rectangle have aliased edges; rounded rectangles, smaller solid paths and
310
+ text are antialiased.
311
+ - **`<Frame>` panes.** A `<Frame>` fails at once: its `fallback` renders and
312
+ no pane is started ([frame.md](frame.md)).
313
+ - **The drag icon.** Drag and drop works both ways, but the drag shows no
314
+ icon: `<popup dragPreview>` is not drawn.
315
+ - **Input methods, the rest of it.** A preedit that hides its cursor puts
316
+ the caret at the end, and the only content hints sent are `multiline`
317
+ and `sensitive_data`.
318
+ - **Tablet pads** — the buttons, rings and strips on the tablet itself are
319
+ ignored.
320
+ - **`useScreens().source` reads `'test'`**, as it does on macOS and Windows.
321
+ - **`fillText`** uses only the first family of a `font` shorthand, and
322
+ strokes text as a fill.
323
+ - **`ext-image-copy-capture`** has not been run against a compositor yet
324
+ (sway 1.10 offers only wlr-screencopy).
325
+
326
+ ## Things that are gone, not missing
327
+
328
+ Window managers (`examples/wm.jsx`), global grabs, reading other windows,
329
+ screen coordinates, `GetImage`, XEmbed/`<foreign>`, and `ssh -X` are
330
+ compositor-private by design. Panels and docks are `layer-shell`, and a
331
+ screen read is `screencopy` — both wlroots and KDE, neither GNOME, where the
332
+ same JSX is a toplevel and the same pick is the portal's.