react-x11 2.51.1 → 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.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +9 -2
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -0,0 +1,611 @@
1
+ # Packaging and distribution
2
+
3
+ Five ways to ship a react-x11 app, cheapest first. Every recipe here was
4
+ run — except the two that need a signing identity, which say so — and
5
+ where something does not work, the reason is measured rather than
6
+ guessed.
7
+
8
+ | tier | works | cost |
9
+ | ------------------------------- | -------------------------- | ------------------------------- |
10
+ | 1. plain `npm install` | yes | a `node_modules` on the target |
11
+ | 2. a single `.mjs` | yes, with one esbuild flag | one file, ~7 MB |
12
+ | 3. Node single executable (SEA) | yes, as CommonJS | one file, ~142 MB |
13
+ | 4. AppImage / `.deb` | yes, wrapping tier 1 or 2 | packaging metadata |
14
+ | 5. a macOS `.app` | yes, wrapping tier 2 or 3 | a plist, and a signing identity |
15
+
16
+ ## Tier 1 — `npm install`
17
+
18
+ The default, and fine for a server-side app, a CI tool, or anything already
19
+ deployed with a package manager. Nothing to say beyond: the package is ESM
20
+ (`"type": "module"`), it needs Node ≥ 20.19 (`engines` in its
21
+ `package.json`), and it has **no required native dependencies** — ntk,
22
+ node-x11 and yoga-layout are pure JavaScript and WASM, so the X11 backend
23
+ needs no node-gyp and no prebuild matrix, and `npm install` on a fresh box
24
+ just works. The native bridges the other backends drive — `@windowkit/appkit`
25
+ for Cocoa, `@windowkit/win32` for win32, `x11-dri` for Wayland's GPU path —
26
+ are `optionalDependencies` that install from a bundled prebuild where one
27
+ matches the platform, so an install that cannot build one still succeeds.
28
+
29
+ ## Tier 2 — one `.mjs` file
30
+
31
+ ```sh
32
+ esbuild app.jsx --bundle --platform=node --format=esm --outfile=app.mjs \
33
+ --banner:js="import{createRequire as __cjsRequire}from'node:module';const require=__cjsRequire(import.meta.url);"
34
+
35
+ node app.mjs
36
+ ```
37
+
38
+ Three things about that command are load-bearing.
39
+
40
+ **`--format=esm` is the easy path, not the only one.** It tolerates top-level
41
+ await anywhere in the graph; `cjs` does not, and refuses to build with
42
+ `Top-level await is currently not supported with the "cjs" output format`.
43
+ Nothing in this stack forces the issue — ntk has had no top-level await
44
+ since 5, and the layout engine, which was the other source of one, is loaded
45
+ rather than imported here (below). See
46
+ [tier 3](#tier-3--node-single-executable), which needs `cjs`; the choice is
47
+ only about what your own code does at module scope.
48
+
49
+ **The `--banner:js` is not optional.** node-x11 is CommonJS and uses dynamic
50
+ `require`, which esbuild cannot resolve statically. Without the banner the
51
+ bundle builds cleanly and then dies at startup:
52
+
53
+ ```
54
+ Error: Dynamic require of "events" is not supported
55
+ ```
56
+
57
+ The banner defines the `require` that shim calls. This is an upstream gap —
58
+ node-x11 wants an ESM entry point, or `node:`-prefixed requires — and until
59
+ it closes, the banner is the fix.
60
+
61
+ **The banner's import must be aliased.** The obvious form, `import
62
+ {createRequire} from 'node:module'`, collides with react-x11's own import of
63
+ the same name and the bundle fails to parse with `Identifier 'createRequire'
64
+ has already been declared`. Hence `as __cjsRequire` above. That one costs an
65
+ afternoon if you meet it without warning.
66
+
67
+ Two things that are _not_ problems, and are worth knowing because they
68
+ usually are:
69
+
70
+ - **yoga-layout is bundler-safe.** Its WASM is base64-inlined into the
71
+ JavaScript, so nothing is emitted beside the bundle and nothing needs a
72
+ loader rule or a copy step. Verified: no `.wasm` file appears, and the
73
+ bundle renders.
74
+ - **`keysym` is gone.** It used to read a JSON file off disk at module load
75
+ and broke every bundle; ntk 4.3.0 no longer depends on it. If you are
76
+ reading an older account of this, that half is fixed.
77
+
78
+ Verified end to end: bundling an app that imports `react-x11`, `react` and
79
+ node-x11's in-process X server produces a 7 MB `app.mjs` that mounts a tree,
80
+ paints, and reads its own pixels back.
81
+
82
+ If you use react-x11's [DevTools](devtools.md) or
83
+ [click-to-component](click-to-component.md) integrations, note that they are
84
+ dynamically imported behind environment variables. A bundler will pull in
85
+ `ws` and `node:child_process` for them; if you do not want that, stub them
86
+ out with a resolver plugin the way
87
+ `website/scripts/build-demo-bundles.mjs` does.
88
+
89
+ On macOS the bundle moves onto a worker as it starts, the way the source
90
+ does, and still needs nothing beside it: the worker's entry is the bundle
91
+ itself. Verified with a one-window app under Node and Bun; what it costs,
92
+ and how to skip it, is [below](#on-macos-the-app-moves-onto-a-worker).
93
+
94
+ ## Tier 3 — Node single executable
95
+
96
+ ```sh
97
+ esbuild app.jsx --bundle --platform=node --format=cjs --outfile=app.cjs
98
+ node --build-sea=sea.json # { "main": "app.cjs", "output": "myapp" }
99
+ ./myapp
100
+ ```
101
+
102
+ ```json
103
+ { "main": "app.cjs", "output": "myapp", "disableExperimentalSEAWarning": true }
104
+ ```
105
+
106
+ This did not work until recently, and the reason is worth keeping: Node's SEA
107
+ evaluates the embedded main as **CommonJS** — there is no `package.json`
108
+ inside the blob for it to consult, and the `.mjs` name has no effect on an
109
+ embedded script — while the stack forced ESM, because esbuild will not emit
110
+ CommonJS for a graph containing top-level await. The await came from the
111
+ layout engine: `yoga-layout`'s default entry is
112
+ `const Yoga = wrapAssembly(await loadYoga())`, and every app inherited it.
113
+ `src/yoga.js` loads the engine through `yoga-layout/load` instead — enums
114
+ synchronously, WebAssembly during `createRoot()` — and react-x11 moved its
115
+ own three `await import()`s for DevTools, click-to-component and tracing out
116
+ of module scope. Nothing in the graph has a top-level await now, and
117
+ `test/yoga.test.js` fails the build if an import puts one back.
118
+
119
+ Verified: a bundle of react-x11, react, ntk and node-x11's in-process X
120
+ server mounts a 40-row tree and paints it — 157 requests in 7 socket writes —
121
+ from a single 142 MB file, most of which is node itself.
122
+
123
+ What the CommonJS format costs you, in your own code:
124
+
125
+ - **No top-level `await`.** Put startup in an `async function main()`;
126
+ `const root = await createRoot()` at the top level of your entry is enough
127
+ to fail the build. esbuild names the file and line, so this is a
128
+ five-second fix rather than a mystery.
129
+ - **`import.meta.url` is `undefined`.** Anything resolving paths through it
130
+ needs `process.execPath` or a literal. react-x11 guards its own uses (the
131
+ version string DevTools shows falls back rather than throwing, and the
132
+ cocoa backend's addon loader is built from `process.execPath` when there
133
+ is no module URL — see the cocoa note below); check your app for the same
134
+ pattern. Two features cannot be guarded, because what they resolve through
135
+ it has to exist on disk: `<Frame>` forks a child entry, and
136
+ click-to-component opens your source. Neither works in a SEA, and each
137
+ fails when first used rather than at startup: a pane hands its `fallback`
138
+ a `spawn`-phase `ERR_INVALID_URL` (and is an empty box without one), and
139
+ `createRoot()` with `REACT_X11_CLICK_TO_COMPONENT` set rejects with
140
+ `ERR_INVALID_ARG_TYPE`. Both measured on the SEA below.
141
+ - **Runtime module loading is out.** Inside a SEA, the embedded main's
142
+ `require()` _and_ `import()` resolve **built-in modules only** — a `data:`
143
+ or `file:` URL import fails with `ERR_UNKNOWN_BUILTIN_MODULE`. A bundle has
144
+ nothing left to resolve, so this only bites if you meant to load something
145
+ later. It also closes the obvious workaround for the old ESM problem: you
146
+ cannot carry an ESM bundle as a SEA asset and import it. The one door left
147
+ open is `createRequire(process.execPath)` from `node:module` — a full
148
+ CommonJS loader rooted at the executable, and how the cocoa addon gets in.
149
+ - **Assets are not files.** `sea.getAsset()` reads what the config's
150
+ `assets` map embedded; fonts are the usual case, and `StaticFontSource`
151
+ takes bytes directly ([ntk's fonts guide][ntk-fonts]).
152
+
153
+ On macOS the binary must be re-signed before it runs
154
+ (`codesign --remove-signature myapp && codesign --sign - myapp`); on Linux
155
+ nothing extra. Measured on Node 26.
156
+
157
+ **The cocoa backend works in a SEA, with the addon beside the binary.** The
158
+ native bridge is a `.node` file: the blob cannot carry it and the embedded
159
+ main's `require()` cannot load it, so react-x11 loads it through
160
+ `createRequire(process.execPath)` — the loader Node's SEA documentation
161
+ prescribes — which resolves from the executable's own directory. Either
162
+ point `REACT_X11_CALAYERS_PATH` at the addon (the `@windowkit/appkit`
163
+ package directory or its `calayers.node` prebuild, the way
164
+ `examples/form/main.js` names it inside a compiled bundle), or ship
165
+ `node_modules/@windowkit/appkit` next to the executable and let the bare
166
+ name resolve. With neither, the error is the backend's usual one, naming
167
+ both attempts. Verified with a `createRoot({ backend: 'cocoa' })` app built
168
+ by the recipe above: it mounts a window on the Core Animation backend all
169
+ three ways, and the sandboxed store build in
170
+ [tier 5](#developer-id-or-the-app-store) — this recipe inside a bundle —
171
+ ran without the define it used to need. Earlier releases made that loader
172
+ at import time, from
173
+ `import.meta.url`, and every cocoa SEA died with `ERR_INVALID_ARG_VALUE`
174
+ before the backend could say what it was loading; on a release you cannot
175
+ move off, `--define:import.meta.url='"file:///dev/null"'` at bundle time is
176
+ the workaround, and `test/cocoa-native.test.js` keeps the fix.
177
+
178
+ A SEA runs the cocoa backend in pump mode, on the main thread: the move
179
+ onto a worker (below) starts the worker on the entry file, and a SEA's
180
+ entry is inside the binary, where a worker cannot load it from. So a menu,
181
+ a drag or a live resize stops its JS, as every cocoa app's did before
182
+ threaded mode.
183
+
184
+ [ntk-fonts]: https://github.com/sidorares/ntk/blob/master/docs/fonts.md
185
+
186
+ ## Tier 4 — AppImage, `.deb`, `.rpm`
187
+
188
+ These wrap tier 1 or tier 2; the packaging is the desktop-integration
189
+ metadata rather than anything about JavaScript.
190
+
191
+ - A **`.desktop` file** — without one, the app has no launcher entry, no
192
+ icon in the dock, and no association with its own windows. Its `StartupWMClass`
193
+ must match the `appId` prop on your `<window>`
194
+ ([elements.md](elements.md)), or the desktop groups your window under the
195
+ wrong icon.
196
+ - **Icons** at 48, 128 and 256 px under
197
+ `usr/share/icons/hicolor/<size>/apps/`. Set `_NET_WM_ICON` too, which the
198
+ window manager reads for the titlebar and alt-tab; the WM example already
199
+ reads both sources ([AGENTS.md](../AGENTS.md), "Writing a window
200
+ manager").
201
+ - **Node itself**, if you are not depending on a system one. That is most of
202
+ the size.
203
+
204
+ Nothing here is react-x11-specific — any Node desktop app packages the same
205
+ way — which is why this section is short rather than absent.
206
+
207
+ ## Tier 5 — a macOS `.app`
208
+
209
+ The cocoa backend's counterpart to tier 4, and the same shape: a wrapper
210
+ around tier 2 or tier 3 whose content is metadata. It matters more than it
211
+ looks, because several integrations are not features you enable but things
212
+ macOS grants **a bundle** and refuses a loose executable — the notification
213
+ centre first among them ([notifications.md](notifications.md)).
214
+
215
+ ```sh
216
+ APP=Downloads.app
217
+ mkdir -p $APP/Contents/MacOS
218
+ cp "$(command -v node)" $APP/Contents/MacOS/Downloads # a copy, not a script
219
+ cat > $APP/Contents/Info.plist <<'PLIST'
220
+ <?xml version="1.0" encoding="UTF-8"?>
221
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
222
+ <plist version="1.0">
223
+ <dict>
224
+ <key>CFBundleName</key><string>Downloads</string>
225
+ <key>CFBundleExecutable</key><string>Downloads</string>
226
+ <key>CFBundleIdentifier</key><string>com.example.downloads</string>
227
+ <key>CFBundlePackageType</key><string>APPL</string>
228
+ <key>CFBundleVersion</key><string>1</string>
229
+ <key>CFBundleShortVersionString</key><string>1.0</string>
230
+ <key>NSHighResolutionCapable</key><true/>
231
+ </dict>
232
+ </plist>
233
+ PLIST
234
+
235
+ codesign --force --sign - $APP # ad-hoc; see the signature note below
236
+ $APP/Contents/MacOS/Downloads app.mjs # or app.cjs from tier 3
237
+ ```
238
+
239
+ **The executable must be the real binary, in `Contents/MacOS/`.** A shell
240
+ script that execs node from elsewhere does not work: `NSBundle.mainBundle` is
241
+ resolved from the running executable's own path, so the bundle the system
242
+ sees would be node's location, not yours. Copy the binary in (or ship a tier
243
+ 3 SEA, which is one file and wants to live there anyway). Which tier the
244
+ bundle wraps decides where the app's JS runs: a tier 2 bundle beside the
245
+ binary moves onto a worker, a SEA stays on the pump
246
+ ([below](#on-macos-the-app-moves-onto-a-worker)).
247
+
248
+ What the bundle changes, measured with the same script run both ways:
249
+
250
+ | | inside the `.app` | bare `node` |
251
+ | ----------------------------------------- | ----------------------- | ------------------ |
252
+ | `notificationSettings().bundleIdentifier` | `com.example.downloads` | `null` |
253
+ | the notification centre | `available: true` | `available: false` |
254
+ | `notificationBackend()` | `'cocoa'` | `'osascript'` |
255
+
256
+ Everything that keys off a bundle id follows the first column: the app's own
257
+ name in the menu bar rather than "node", a Dock tile that is yours to badge
258
+ ([desktop.md](desktop.md)), `activationPolicy` meaning something, and URL
259
+ schemes and document types you can declare in the same plist and receive as
260
+ Apple Events ([uri-schemes.md](uri-schemes.md)).
261
+
262
+ ### The signature is the part that bites
263
+
264
+ An **ad-hoc signature (`--sign -`) is enough to be a bundle, and not enough to
265
+ post a notification.** With one, the centre reports `available: true` and
266
+ every authorization request fails with `UNErrorDomain` code 1
267
+ (`notificationsNotAllowed`) — the prompt never appears, the status stays
268
+ `notDetermined`, and `usernotificationsd` logs nothing at all, so the system
269
+ never registered the app as a notification client. Measured here as not the
270
+ cause: registering with `lsregister -f`, launching through `open -a` instead
271
+ of exec'ing the binary, `activationPolicy: 'regular'` with a real window on
272
+ screen, and re-signing with the hardened runtime and node's own entitlements.
273
+ None of them move it.
274
+
275
+ The remaining variable is the identity itself — a real Apple Development or
276
+ Developer ID certificate, which a free Apple ID gets you through Xcode:
277
+
278
+ ```sh
279
+ security find-identity -v -p codesigning # pick one
280
+ codesign --force --sign "Apple Development: you@example.com (TEAMID)" $APP
281
+ ```
282
+
283
+ That is the documented requirement and the untested one, because the machine
284
+ this page was measured on has no signing identity. If you have a certificate
285
+ and it still refuses, that is worth an issue rather than a workaround.
286
+
287
+ Until then the ladder does the right thing on its own: `notify()` falls
288
+ through to `osascript`, which delivers real banners attributed to Script
289
+ Editor, and `useNotifier().backend` says `osascript` so an app can tell the
290
+ difference ([notifications.md](notifications.md)). Nothing else about the
291
+ bundle depends on the certificate.
292
+
293
+ ### Developer ID, or the App Store
294
+
295
+ Past the ad-hoc signature there are two ways out of the building, and they
296
+ want different runtimes. Measured 2026-09-08 on macOS 15.2 with bun 1.4.0
297
+ and node 26. The machine has no signing identity, so the line through this
298
+ section is the ad-hoc one: everything on the near side of it was run, and
299
+ everything past it is Apple's documentation, marked as such.
300
+
301
+ | | Developer ID — a download | Mac App Store |
302
+ | ---------------- | -------------------------------------------------- | ---------------------------------------------------- |
303
+ | App Sandbox | optional | required |
304
+ | hardened runtime | required, by notarization | not required |
305
+ | runtime | node or bun | node — bun does not start under the sandbox |
306
+ | shape | this tier around tier 2 or 3, or bun's `--compile` | tier 3: a SEA, an executable that takes no arguments |
307
+ | identity | Developer ID Application | Apple Distribution, plus an installer identity |
308
+ | then | `notarytool`, `stapler` | `productbuild`, Transporter |
309
+
310
+ **The sandbox is where bun stops.** Apple's App Sandbox page: "To
311
+ distribute a macOS app through the Mac App Store, you must enable the App
312
+ Sandbox capability" — one entitlement, `com.apple.security.app-sandbox`.
313
+ The form example's bundle, as `make-app.sh` builds it with
314
+ `bun build --compile` and re-signed with only that entitlement, crashes at
315
+ launch every time:
316
+
317
+ - **the bare binary**, outside any bundle: `SIGTRAP` in
318
+ `_libsecinit_appsandbox`, the libSystem initializer that applies the
319
+ sandbox — before a line of JavaScript runs;
320
+ - **inside the bundle**: `SIGABRT` in HIServices' `_RegisterApplication`,
321
+ under `-[NSApplication init]`, after the kernel logs
322
+ `Sandbox: Guestbook deny(1) mach-lookup com.apple.coreservices.launchservicesd`.
323
+
324
+ Measured not to be the cause: how it is launched (exec'd from a shell, in
325
+ the foreground under a watchdog, or by `open`), the plist (the example's
326
+ and a minimal one), and where the bundle sits. Upstream:
327
+ [oven-sh/bun#15661][bun-15661], "Cannot run Bun within macOS sandbox", open
328
+ since December 2024 — its "status code 5" is this SIGTRAP.
329
+
330
+ The same example, sandboxed with the same entitlement and the same ad-hoc
331
+ signature, runs as node: as an esbuild `.mjs` (tier 2) under a copied
332
+ `node` binary, and as a `node --build-sea` executable (tier 3). Both stayed
333
+ up with the window on screen and logged no `Sandbox: … deny` line at all;
334
+ the SEA, launched by `open`, registers with Launch Services as itself and
335
+ gets its container:
336
+
337
+ ```
338
+ $ open SeaGuestbook.app
339
+ "LSDisplayName"="SeaGuestbook"
340
+ "CFBundleIdentifier"="com.example.seaguestbook"
341
+ container: ~/Library/Containers/com.example.seaguestbook
342
+ ```
343
+
344
+ The control: a five-line Swift AppKit app (`NSApplication.shared`, print
345
+ the container id), ad-hoc-signed with the same entitlement, runs and
346
+ reports its container. So an ad-hoc signature plus the entitlement is a
347
+ valid local sandbox test, and no certificate is needed for this part.
348
+
349
+ **The SEA is the store shape.** Launch Services starts a bundle's
350
+ executable with no arguments, so a copied `node` has nothing to run; the
351
+ SEA carries its main. (The sandbox also moves the working directory into
352
+ the container — `ProbeNode probe.mjs` with a relative path failed with
353
+ `MODULE_NOT_FOUND` at `~/Library/Containers/…/Data/probe.mjs`, an absolute
354
+ one ran — and a SEA has no path to get wrong.) Its build is tier 3's, with
355
+ one esbuild define that the form example needs today:
356
+
357
+ ```sh
358
+ esbuild sea-main.js --bundle --platform=node --format=cjs --jsx=automatic --outfile=app.cjs \
359
+ --define:process.env.REACT_X11_NO_AUTORUN='"1"'
360
+ node --build-sea sea.json # { "main": "app.cjs", "output": "Guestbook", "disableExperimentalSEAWarning": true }
361
+ ```
362
+
363
+ - **`REACT_X11_NO_AUTORUN`.** `index.jsx` mounts itself under that guard
364
+ with a top-level `await createRoot()`, which `cjs` cannot express;
365
+ without the define esbuild stops at
366
+ `Top-level await is currently not supported with the "cjs" output format`.
367
+ Defined, the guard is a constant and the branch is dropped at build
368
+ time; the entry mounts `App` itself, in an async function:
369
+
370
+ ```js
371
+ // sea-main.js — main.js's job, as CommonJS
372
+ const path = require('node:path');
373
+ const contents = path.resolve(path.dirname(process.execPath), '..');
374
+ process.env.REACT_X11_CALAYERS_PATH ??= path.join(
375
+ contents,
376
+ 'Resources',
377
+ 'calayers.node',
378
+ );
379
+ process.env.REACT_X11_BACKEND ??= 'cocoa';
380
+ (async () => {
381
+ const [{ createRoot }, { default: App }, React] = await Promise.all([
382
+ import('react-x11'),
383
+ import('./index.jsx'),
384
+ import('react'),
385
+ ]);
386
+ const root = await createRoot();
387
+ root.render(React.createElement(App));
388
+ })().catch((e) => {
389
+ console.error(e);
390
+ process.exit(1);
391
+ });
392
+ ```
393
+
394
+ - **`import.meta.url` needs no define any more.** This recipe used to
395
+ carry a second one, `--define:import.meta.url='"file:///dev/null"'`,
396
+ because the cocoa backend's addon loader was made from `import.meta.url`
397
+ at import and a SEA has none. The loader is now made from
398
+ `process.execPath` when the URL is missing
399
+ ([tier 3](#tier-3--node-single-executable)), and the addon is found
400
+ through `REACT_X11_CALAYERS_PATH` as before. On a react-x11 whose
401
+ `src/cocoa/native.js` still calls `createRequire(import.meta.url)` at
402
+ module scope, put the define back.
403
+
404
+ The bundle is then tier 5's — the plist, the SEA at
405
+ `Contents/MacOS/Guestbook`, `calayers.node` in `Contents/Resources` — and
406
+ the signature is two commands, in this order:
407
+
408
+ ```sh
409
+ codesign --force --sign - Guestbook.app/Contents/Resources/calayers.node
410
+ codesign --force --sign - --entitlements sandbox.plist Guestbook.app
411
+ open Guestbook.app
412
+ ```
413
+
414
+ where `sandbox.plist` holds the one key. That is what ran.
415
+
416
+ **Sign inside-out, and never `--deep`.** Apple DTS, in "Signing a Mac
417
+ Product For Distribution" and "Creating distribution-signed code for Mac"
418
+ ([forums 128166][dts-128166], [701514][dts-701514]): "Do not use the
419
+ `--deep` argument … it will cause problems when signing a complex
420
+ program". Sign each code item separately, dependencies first — the addon
421
+ before the app, because the app's signature seals the addon's — and put
422
+ entitlements on the main executable only: "Do not apply entitlements to
423
+ library code. It doesn't do anything useful and can prevent your code from
424
+ running." `--deep` applies one set of options to every item, which is the
425
+ wrong thing the moment the app has entitlements and the addon must not.
426
+
427
+ **Developer ID — not run.** For a download outside the store, Gatekeeper
428
+ wants a Developer ID signature with the hardened runtime, then
429
+ notarization:
430
+
431
+ ```sh
432
+ ID="Developer ID Application: NAME (TEAMID)"
433
+ codesign --force --timestamp --sign "$ID" Guestbook.app/Contents/Resources/calayers.node
434
+ codesign --force --timestamp --options runtime --entitlements developer-id.plist \
435
+ --sign "$ID" Guestbook.app
436
+ ditto -c -k --keepParent Guestbook.app Guestbook.zip
437
+ xcrun notarytool submit Guestbook.zip --keychain-profile notary --wait
438
+ xcrun stapler staple Guestbook.app
439
+ ```
440
+
441
+ `developer-id.plist` is the upstream list for the runtime you ship. Bun's
442
+ "Code signing on macOS" ([the executables guide][bun-codesign]) and Node's
443
+ [`tools/osx-entitlements.plist`][node-entitlements] name the same five:
444
+ `com.apple.security.cs.allow-jit`, `allow-unsigned-executable-memory`,
445
+ `disable-executable-page-protection`, `allow-dyld-environment-variables`
446
+ and `disable-library-validation`. Node's file has a sixth,
447
+ `com.apple.security.get-task-allow` — a debugging entitlement, and the
448
+ `node` you copy from a release carries it (`codesign -d --entitlements -`
449
+ on node 26 lists all six). Never ship it; a re-sign replaces node's
450
+ entitlements wholesale, so it only ever appears if your plist names it.
451
+
452
+ What the hardened runtime needs was measured with an ad-hoc signature and
453
+ `--options runtime`, which is the real thing: a copied `node` signed with
454
+ no entitlements aborts at startup —
455
+ `Fatal process out of memory: Failed to reserve virtual memory for CodeRange`,
456
+ V8 unable to map executable memory — and runs with `allow-jit` alone; a
457
+ `bun build --compile` binary ran with none, with `allow-jit` alone, and
458
+ with all five (whether its JIT was on was not measured). So `allow-jit` is
459
+ the load-bearing one for node, and the other four are what upstream
460
+ ships, not what this page found necessary. The Developer ID identity,
461
+ `notarytool` and `stapler` were not run: the machine has none.
462
+
463
+ **Mac App Store — not run.** Sign the same bundle, inside-out, with the
464
+ store identity, and package it as an installer:
465
+
466
+ ```sh
467
+ ID="Apple Distribution: NAME (TEAMID)"
468
+ codesign --force --timestamp --sign "$ID" Guestbook.app/Contents/Resources/calayers.node
469
+ codesign --force --timestamp --entitlements store.plist --sign "$ID" Guestbook.app
470
+ productbuild --sign "3rd Party Mac Developer Installer: NAME (TEAMID)" \
471
+ --component Guestbook.app /Applications Guestbook.pkg
472
+ xcrun altool --upload-app -f Guestbook.pkg -t macos --apiKey KEY --apiIssuer ISSUER # or Transporter
473
+ ```
474
+
475
+ `store.plist` is `com.apple.security.app-sandbox` plus what the app
476
+ actually needs — `network.client`, `files.user-selected.read-write` — and
477
+ none of the hardened-runtime keys: the store does not require the hardened
478
+ runtime, and Apple's "Porting just-in-time compilers to Apple silicon" is
479
+ explicit that `allow-jit` "is required only when an app adopts the
480
+ Hardened Runtime capability". `Contents/embedded.provisionprofile` is
481
+ needed only for restricted entitlements (iCloud, push, app groups) or
482
+ TestFlight. The ad-hoc half of this — the sandboxed SEA bundle, launched
483
+ by `open`, registered, contained, zero denials — is what was measured; the
484
+ identity, `productbuild --sign` and the upload are Apple's documentation.
485
+
486
+ [bun-15661]: https://github.com/oven-sh/bun/issues/15661
487
+ [dts-128166]: https://developer.apple.com/forums/thread/128166
488
+ [dts-701514]: https://developer.apple.com/forums/thread/701514
489
+ [bun-codesign]: https://bun.com/docs/bundler/executables#code-signing-on-macos
490
+ [node-entitlements]: https://github.com/nodejs/node/blob/main/tools/osx-entitlements.plist
491
+
492
+ ## On macOS: the app moves onto a worker
493
+
494
+ On macOS the first import of react-x11 moves the app onto a
495
+ `worker_threads` Worker. AppKit keeps the process main thread — it is the
496
+ only thread AppKit lets own a window — and an app whose JS ran there would
497
+ stop through every menu, drag, live resize and modal panel
498
+ ([macos.md](macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)). The
499
+ worker's entry is your entry itself, run from the top: when react-x11 is
500
+ imported the entry has not run a line yet, because its imports finish
501
+ before its body does. The main thread then waits, and launches AppKit only
502
+ when the app's first cocoa `createRoot` asks for it.
503
+
504
+ What it costs, measured on an M1 Pro with Node 26 and Bun 1.4 — spawn to
505
+ the first painted window of a one-window app, the median of seven runs:
506
+
507
+ | | pump (`REACT_X11_THREADED=0`) | the move (the default) | `--import react-x11/cocoa-main` |
508
+ | --------------------- | ----------------------------: | ---------------------: | ------------------------------: |
509
+ | Node, the source | 471 ms | 544 ms | 462 ms |
510
+ | Node, a tier 2 bundle | 427 ms | 463 ms | — |
511
+ | Bun, the source | 408 ms | 446 ms | 385 ms |
512
+ | Bun, a tier 2 bundle | 401 ms | 426 ms | — |
513
+ | memory, Node / Bun | 170 / 150 MB | 219 / 182 MB | 186 / 159 MB |
514
+
515
+ The move's 25–73 ms and 30–50 MB are the main thread loading the entry's
516
+ imports once, for nothing, before the worker loads them again; a bundle is
517
+ at the low end because one file loads faster than a tree of them. An app
518
+ that imports react-x11 and never makes a cocoa root — an X11 app on
519
+ XQuartz, a script — never launches AppKit, and pays 95–131 ms more to exit
520
+ (Node 251 → 382 ms, Bun 196 → 291 ms).
521
+
522
+ To skip the cost, or the move:
523
+
524
+ - **Start with the launcher**: `node --import react-x11/cocoa-main app.mjs`,
525
+ or `bun --preload react-x11/cocoa-main app.mjs`. It moves the app before
526
+ the entry is loaded at all, which is faster than the pump and costs only
527
+ the worker. It resolves the installed package, so it is for an app run
528
+ from a checkout or an `npm install` — anywhere you write the command
529
+ line.
530
+ - **Stay on the pump** with `REACT_X11_THREADED=0`: the app keeps the main
531
+ thread, and a menu, a drag or a live resize stops its JS again.
532
+ - **Import react-x11 first** in the entry. What the entry imports before
533
+ it loads on the main thread as well as on the worker: nothing to notice
534
+ for React, but a module that does work as it loads — opens a port,
535
+ writes a file, logs — does it twice, and one that makes a call only a
536
+ main thread may, `process.chdir()`, throws the second time
537
+ ([macos.md](macos.md#what-changes-for-an-app)).
538
+
539
+ The move does not happen in a single executable (tier 3, and
540
+ `bun build --compile`), whose entry is not a file a worker can load, so it
541
+ runs on the pump; in a `<Frame>` pane, whose IPC channel to its host is
542
+ the main thread's; under a test runner; with `REACT_X11_THREADED=0` or
543
+ `REACT_X11_BACKEND=x11`; when react-x11 is imported after the app has
544
+ started running — by an `import()` from a timer, or one the entry awaits —
545
+ where the worker would run again what the entry has already done; and on
546
+ Node 20 and 22, under an ES module loader. By Node version, measured on
547
+ 20.20, 22.16, 24.12 and 26.0:
548
+
549
+ | the app | Node 20, 22 | Node 24 and later |
550
+ | ------------------------------------------------------ | ----------- | ----------------- |
551
+ | `node app.mjs`, an ES module entry, or a tier 2 bundle | moves | moves |
552
+ | `node app.cjs`, a CommonJS entry | moves | moves |
553
+ | `tsx app.jsx`, or any `--import` or `--loader` | stays | moves |
554
+ | under `--import react-x11/cocoa-main` | moves | moves |
555
+ | importing react-x11 with an `import()` | stays | stays |
556
+
557
+ An app under a loader stays on Node 20 and 22 because tsx, the loader an
558
+ app is likeliest to run under, keeps its hooks to the main thread there,
559
+ and a worker without them stops on the first `.jsx` or `.ts` file it loads
560
+ (measured, tsx 4.23). No loader can be asked before the move, so any
561
+ `--import` or `--loader`, on the command line or in `NODE_OPTIONS`,
562
+ counts. The launcher moves such an app all the same, for a loader that
563
+ does follow it onto a worker; tsx 4.23 does from Node 22.22.3, by its own
564
+ version check. On Node 24 and later tsx follows from tsx 4.21.1 and Node
565
+ 24.11.1, and an older one there stops the app as it starts, on
566
+ `ERR_UNKNOWN_FILE_EXTENSION` (measured, tsx 4.20.6): update tsx, or set
567
+ `REACT_X11_THREADED=0`. Bun moves an ES module entry and a CommonJS one,
568
+ `bun app.mjs` and `bun app.cjs`, and keeps the main thread for an entry
569
+ that imports react-x11 with an `import()`, measured on Bun 1.4; it tells a
570
+ late import its own way ([macos.md](macos.md#the-renderers-half)).
571
+
572
+ ## Checklist
573
+
574
+ - On macOS, choose where the app's JS runs: the default move onto a worker
575
+ costs 25–73 ms of startup, `--import react-x11/cocoa-main` skips it,
576
+ `REACT_X11_THREADED=0` keeps the pump, and a SEA always does, as does an
577
+ app under a loader on Node 20 or 22
578
+ ([above](#on-macos-the-app-moves-onto-a-worker)).
579
+ - Pick the format deliberately: `--format=esm` (tier 2, needs the banner) or
580
+ `--format=cjs` (tier 3, needs no top-level await in your own code).
581
+ - With `esm`, alias the banner's `createRequire`.
582
+ - Do not ship `.Xauthority`, and do not bake `DISPLAY` into an image —
583
+ [security.md](security.md).
584
+ - Set `appId` and match it in `StartupWMClass`.
585
+ - On macOS, ship a `.app` (tier 5) rather than a bare executable, and sign it
586
+ with a real identity if you want the notification centre.
587
+ - For the Mac App Store, build tier 3 — a node SEA — and sandbox it: bun does
588
+ not start under App Sandbox. Developer ID takes either runtime.
589
+ - Sign nested code first and the app last, entitlements on the app only, and
590
+ never `--deep`.
591
+ - Test on a display you did not develop on. Fonts are the usual surprise:
592
+ family resolution goes through `fc-match`, so `sans-serif` is a different
593
+ face on the target ([remote.md](remote.md#the-other-x-servers), issue #86).
594
+
595
+ ## Upstream
596
+
597
+ Two things would delete the rest of the friction here:
598
+
599
+ - **node-x11: ESM sources** ([node-x11#246][x11-246]). That removes tier 2's
600
+ banner, which is now the only real trap on the page. Note the tempting
601
+ cheaper version does not work: `node:`-prefixed requires change nothing,
602
+ because esbuild wraps a CommonJS module either way and its `require` shim
603
+ throws in ESM output regardless of the specifier — measured, not assumed.
604
+ - **bun: App Sandbox** ([oven-sh/bun#15661][bun-15661]). Until a
605
+ `bun build --compile` binary can start sandboxed, a Mac App Store build is
606
+ node's SEA and bun is a Developer ID runtime only.
607
+
608
+ ESM support for a SEA's embedded main would be welcome in Node, but it is no
609
+ longer load-bearing: tier 3 works as CommonJS.
610
+
611
+ [x11-246]: https://github.com/sidorares/node-x11/issues/246