react-x11 2.52.0 → 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 (112) hide show
  1. package/README.md +10 -10
  2. package/docs/README.md +160 -314
  3. package/docs/accessibility.md +176 -229
  4. package/docs/appearance.md +77 -176
  5. package/docs/click-to-component.md +18 -51
  6. package/docs/clipboard.md +40 -59
  7. package/docs/components.md +518 -1036
  8. package/docs/dbus.md +61 -133
  9. package/docs/debugging.md +152 -245
  10. package/docs/desktop-calendar.md +51 -88
  11. package/docs/desktop.md +222 -347
  12. package/docs/devtools.md +50 -167
  13. package/docs/drag-and-drop.md +126 -204
  14. package/docs/ecosystem/animation.md +91 -125
  15. package/docs/ecosystem/data-fetching.md +38 -53
  16. package/docs/ecosystem/dev-tooling.md +159 -202
  17. package/docs/ecosystem/forms.md +61 -97
  18. package/docs/ecosystem/headless.md +64 -89
  19. package/docs/ecosystem/i18n.md +18 -21
  20. package/docs/ecosystem/icons.md +93 -126
  21. package/docs/ecosystem/layout.md +42 -52
  22. package/docs/ecosystem/routing.md +18 -29
  23. package/docs/ecosystem/state.md +71 -88
  24. package/docs/ecosystem/testing.md +131 -157
  25. package/docs/ecosystem/theming.md +119 -165
  26. package/docs/ecosystem.md +145 -179
  27. package/docs/elements.md +1091 -1774
  28. package/docs/embedding.md +83 -132
  29. package/docs/events.md +412 -695
  30. package/docs/extending.md +784 -1326
  31. package/docs/eyedropper.md +63 -122
  32. package/docs/filedialog.md +71 -107
  33. package/docs/frame.md +195 -339
  34. package/docs/gl.md +65 -62
  35. package/docs/globalmenu.md +63 -146
  36. package/docs/macos.md +531 -3234
  37. package/docs/notifications.md +33 -47
  38. package/docs/packaging.md +166 -309
  39. package/docs/permissions.md +9 -14
  40. package/docs/react-features.md +81 -106
  41. package/docs/remote.md +32 -76
  42. package/docs/scale.md +72 -186
  43. package/docs/styling.md +329 -547
  44. package/docs/system.md +200 -280
  45. package/docs/testing.md +63 -83
  46. package/docs/typescript.md +6 -48
  47. package/docs/uri-schemes.md +67 -107
  48. package/docs/wayland-backend.md +200 -459
  49. package/docs/windows-integrations.md +75 -131
  50. package/docs/windows.md +343 -1367
  51. package/package.json +3 -2
  52. package/src/Reconciler.js +2 -2
  53. package/src/a11y.js +1 -1
  54. package/src/backend/context2d.js +3 -3
  55. package/src/bootstrap.js +1 -1
  56. package/src/cocoa/app.js +6 -6
  57. package/src/cocoa/bezels.js +1 -1
  58. package/src/cocoa/fonts.js +1 -1
  59. package/src/cocoa/globalmenu.js +1 -1
  60. package/src/cocoa/keymap.js +1 -1
  61. package/src/cocoa/panewindow.js +1 -1
  62. package/src/cocoa/player.js +1 -1
  63. package/src/cocoa/presenter.js +2 -2
  64. package/src/cocoa/promotion.js +1 -1
  65. package/src/cocoa/relaunch.js +1 -1
  66. package/src/cocoa/threaded.js +1 -1
  67. package/src/cocoa/video.js +1 -1
  68. package/src/cocoa/window.js +4 -4
  69. package/src/components/Menu.js +7 -2
  70. package/src/components/Switch.js +1 -1
  71. package/src/components/native.js +1 -1
  72. package/src/dnd.js +2 -2
  73. package/src/glnodes.js +1 -1
  74. package/src/grid.js +1 -1
  75. package/src/layouts.js +1 -1
  76. package/src/nodes/animation.js +1 -1
  77. package/src/nodes/invalidate.js +1 -1
  78. package/src/nodes/layouthost.js +1 -1
  79. package/src/nodes/video.js +19 -8
  80. package/src/nodes/window/droptarget.js +1 -1
  81. package/src/pacing.js +1 -1
  82. package/src/refresh/index.js +2 -1
  83. package/src/types/video.d.ts +5 -2
  84. package/src/videobackend.js +28 -0
  85. package/src/videoframes.js +29 -16
  86. package/src/wayland/app.js +27 -0
  87. package/src/wayland/video.js +376 -0
  88. package/src/win32/a11y.js +1 -1
  89. package/src/win32/app.js +2 -2
  90. package/src/win32/fonts.js +1 -1
  91. package/src/win32/glarea.js +4 -4
  92. package/src/win32/overlay.js +1 -1
  93. package/src/win32/panewindow.js +3 -3
  94. package/src/win32/shell.js +1 -1
  95. package/src/win32/window.js +2 -2
  96. package/src/windowid.js +1 -1
  97. package/src/xvideo.js +189 -0
  98. package/docs/architecture/accessibility.md +0 -470
  99. package/docs/architecture/animation.md +0 -707
  100. package/docs/architecture/container-queries.md +0 -525
  101. package/docs/architecture/custom-layout.md +0 -334
  102. package/docs/architecture/drag-and-drop.md +0 -941
  103. package/docs/architecture/element-layer-contents.md +0 -622
  104. package/docs/architecture/frame-pacing.md +0 -255
  105. package/docs/architecture/grid-layout.md +0 -358
  106. package/docs/architecture/protocol-efficiency.md +0 -357
  107. package/docs/architecture/video.md +0 -1149
  108. package/docs/architecture/windowed-regions.md +0 -854
  109. package/docs/glx.md +0 -137
  110. package/docs/wayland.md +0 -545
  111. package/docs/windows-embedding.md +0 -271
  112. package/docs/windows-gl.md +0 -221
package/docs/packaging.md CHANGED
@@ -1,9 +1,7 @@
1
1
  # Packaging and distribution
2
2
 
3
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.
4
+ run, except the signing steps that need a real identity, which say so.
7
5
 
8
6
  | tier | works | cost |
9
7
  | ------------------------------- | -------------------------- | ------------------------------- |
@@ -16,14 +14,13 @@ guessed.
16
14
  ## Tier 1 — `npm install`
17
15
 
18
16
  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
17
+ deployed with a package manager. The package is ESM (`"type": "module"`),
18
+ needs Node ≥ 20.19 (`engines` in its `package.json`), and has **no required
19
+ native dependencies** — ntk, node-x11 and yoga-layout are pure JavaScript
20
+ and WASM, so the X11 backend needs no node-gyp and no prebuild matrix. The
21
+ native bridges the other backends drive — `@windowkit/appkit` for Cocoa,
22
+ `@windowkit/win32` for win32, `x11-dri` for Wayland's GPU path — are
23
+ `optionalDependencies` that install from a bundled prebuild where one
27
24
  matches the platform, so an install that cannot build one still succeeds.
28
25
 
29
26
  ## Tier 2 — one `.mjs` file
@@ -40,11 +37,10 @@ Three things about that command are load-bearing.
40
37
  **`--format=esm` is the easy path, not the only one.** It tolerates top-level
41
38
  await anywhere in the graph; `cjs` does not, and refuses to build with
42
39
  `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.
40
+ react-x11's own graph has none (the layout engine is loaded during
41
+ `createRoot()`, not imported), so the choice is only about what your own
42
+ code does at module scope. [Tier 3](#tier-3--node-single-executable) needs
43
+ `cjs`.
48
44
 
49
45
  **The `--banner:js` is not optional.** node-x11 is CommonJS and uses dynamic
50
46
  `require`, which esbuild cannot resolve statically. Without the banner the
@@ -54,42 +50,27 @@ bundle builds cleanly and then dies at startup:
54
50
  Error: Dynamic require of "events" is not supported
55
51
  ```
56
52
 
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.
53
+ The banner defines the `require` that esbuild's shim calls; it is needed
54
+ until node-x11 ships ESM sources ([node-x11#246][x11-246]).
60
55
 
61
56
  **The banner's import must be aliased.** The obvious form, `import
62
57
  {createRequire} from 'node:module'`, collides with react-x11's own import of
63
58
  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).
59
+ has already been declared`. Hence `as __cjsRequire` above.
60
+
61
+ **yoga-layout is bundler-safe**: its WASM is base64-inlined into the
62
+ JavaScript, so nothing is emitted beside the bundle and nothing needs a
63
+ loader rule or a copy step.
64
+
65
+ react-x11's [DevTools](devtools.md) and
66
+ [click-to-component](click-to-component.md) integrations are dynamically
67
+ imported behind environment variables, so a bundler pulls in `ws` and
68
+ `node:child_process` for them; to leave them out, stub them with a resolver
69
+ plugin the way `website/scripts/build-demo-bundles.mjs` does.
70
+
71
+ On macOS the bundle moves onto a worker as it starts, as the source does,
72
+ and still needs nothing beside it: the worker's entry is the bundle itself
73
+ ([below](#on-macos-the-app-moves-onto-a-worker)).
93
74
 
94
75
  ## Tier 3 — Node single executable
95
76
 
@@ -103,83 +84,51 @@ node --build-sea=sea.json # { "main": "app.cjs", "output": "myapp" }
103
84
  { "main": "app.cjs", "output": "myapp", "disableExperimentalSEAWarning": true }
104
85
  ```
105
86
 
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:
87
+ Node's SEA evaluates the embedded main as **CommonJS** — there is no
88
+ `package.json` inside the blob, and a `.mjs` name has no effect on an
89
+ embedded script — so the bundle is `--format=cjs`. Nothing in react-x11's
90
+ graph has a top-level await, so that builds. What the CommonJS format costs
91
+ you, in your own code:
124
92
 
125
93
  - **No top-level `await`.** Put startup in an `async function main()`;
126
94
  `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.
95
+ to fail the build. esbuild names the file and line.
129
96
  - **`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.
97
+ needs `process.execPath` or a literal; react-x11 guards its own uses. Two
98
+ features cannot be guarded, because what they resolve has to exist on
99
+ disk, and neither works in a SEA. Each fails when first used rather than
100
+ at startup: `<Frame>` forks a child entry, and a pane hands its `fallback`
101
+ a `spawn`-phase `ERR_INVALID_URL` (and is an empty box without one);
102
+ click-to-component opens your source, and `createRoot()` with
103
+ `REACT_X11_CLICK_TO_COMPONENT` set rejects with `ERR_INVALID_ARG_TYPE`.
141
104
  - **Runtime module loading is out.** Inside a SEA, the embedded main's
142
105
  `require()` _and_ `import()` resolve **built-in modules only** — a `data:`
143
106
  or `file:` URL import fails with `ERR_UNKNOWN_BUILTIN_MODULE`. A bundle has
144
107
  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.
108
+ later (an ESM bundle carried as a SEA asset cannot be imported either).
109
+ The one door left open is `createRequire(process.execPath)` from
110
+ `node:module` — a full CommonJS loader rooted at the executable.
149
111
  - **Assets are not files.** `sea.getAsset()` reads what the config's
150
112
  `assets` map embedded; fonts are the usual case, and `StaticFontSource`
151
113
  takes bytes directly ([ntk's fonts guide][ntk-fonts]).
152
114
 
153
115
  On macOS the binary must be re-signed before it runs
154
116
  (`codesign --remove-signature myapp && codesign --sign - myapp`); on Linux
155
- nothing extra. Measured on Node 26.
117
+ nothing extra.
156
118
 
157
119
  **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.
120
+ native bridge is a `.node` file the blob cannot carry, so react-x11 loads it
121
+ through `createRequire(process.execPath)`, which resolves from the
122
+ executable's own directory. Either point `REACT_X11_CALAYERS_PATH` at the
123
+ addon (the `@windowkit/appkit` package directory or its `calayers.node`
124
+ prebuild, the way `examples/form/main.js` names it inside a compiled
125
+ bundle), or ship `node_modules/@windowkit/appkit` next to the executable and
126
+ let the bare name resolve. With neither, the error names both attempts.
127
+
128
+ A SEA runs the cocoa backend in pump mode, on the main thread — its entry
129
+ is inside the binary, where a worker cannot load it from — so a menu, a
130
+ drag or a live resize stops its JS
131
+ ([below](#on-macos-the-app-moves-onto-a-worker)).
183
132
 
184
133
  [ntk-fonts]: https://github.com/sidorares/ntk/blob/master/docs/fonts.md
185
134
 
@@ -202,15 +151,14 @@ metadata rather than anything about JavaScript.
202
151
  the size.
203
152
 
204
153
  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.
154
+ way.
206
155
 
207
156
  ## Tier 5 — a macOS `.app`
208
157
 
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)).
158
+ The cocoa backend's counterpart to tier 4: a wrapper around tier 2 or tier
159
+ 3 whose content is metadata. Several integrations are things macOS grants
160
+ **a bundle** and refuses a loose executable — the notification centre first
161
+ among them ([notifications.md](notifications.md)).
214
162
 
215
163
  ```sh
216
164
  APP=Downloads.app
@@ -238,14 +186,12 @@ $APP/Contents/MacOS/Downloads app.mjs # or app.cjs from tier 3
238
186
 
239
187
  **The executable must be the real binary, in `Contents/MacOS/`.** A shell
240
188
  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
189
+ resolved from the running executable's own path. Copy the binary in (or ship
190
+ a tier 3 SEA). Which tier the bundle wraps decides where the app's JS runs: a
191
+ tier 2 bundle beside the binary moves onto a worker, a SEA stays on the pump
246
192
  ([below](#on-macos-the-app-moves-onto-a-worker)).
247
193
 
248
- What the bundle changes, measured with the same script run both ways:
194
+ What the bundle changes:
249
195
 
250
196
  | | inside the `.app` | bare `node` |
251
197
  | ----------------------------------------- | ----------------------- | ------------------ |
@@ -264,39 +210,31 @@ Apple Events ([uri-schemes.md](uri-schemes.md)).
264
210
  An **ad-hoc signature (`--sign -`) is enough to be a bundle, and not enough to
265
211
  post a notification.** With one, the centre reports `available: true` and
266
212
  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.
213
+ (`notificationsNotAllowed`): the prompt never appears and the status stays
214
+ `notDetermined`. Registering with `lsregister -f`, launching through
215
+ `open -a`, `activationPolicy: 'regular'` and the hardened runtime do not
216
+ change that.
274
217
 
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:
218
+ The documented requirement is a real Apple Development or Developer ID
219
+ certificate, which a free Apple ID gets you through Xcode (not tested here):
277
220
 
278
221
  ```sh
279
222
  security find-identity -v -p codesigning # pick one
280
223
  codesign --force --sign "Apple Development: you@example.com (TEAMID)" $APP
281
224
  ```
282
225
 
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.
226
+ Without one, `notify()` falls through to `osascript`, which delivers real
227
+ banners attributed to Script Editor, and `useNotifier().backend` says
228
+ `osascript` so an app can tell the difference
229
+ ([notifications.md](notifications.md)). Nothing else about the bundle
230
+ depends on the certificate.
292
231
 
293
232
  ### Developer ID, or the App Store
294
233
 
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.
234
+ Past the ad-hoc signature there are two ways to distribute, and they want
235
+ different runtimes. The sandboxed, ad-hoc-signed half below was run (macOS
236
+ 15.2, bun 1.4.0, node 26); the steps that need a real identity follow
237
+ Apple's documentation and were not run.
300
238
 
301
239
  | | Developer ID — a download | Mac App Store |
302
240
  | ---------------- | -------------------------------------------------- | ---------------------------------------------------- |
@@ -307,52 +245,23 @@ everything past it is Apple's documentation, marked as such.
307
245
  | identity | Developer ID Application | Apple Distribution, plus an installer identity |
308
246
  | then | `notarytool`, `stapler` | `productbuild`, Transporter |
309
247
 
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.
248
+ **Bun does not start under the App Sandbox**, which the Mac App Store
249
+ requires (one entitlement, `com.apple.security.app-sandbox`). A
250
+ `bun build --compile` binary signed with it crashes at launch: `SIGTRAP` in
251
+ `_libsecinit_appsandbox` before any JavaScript runs, or, inside a bundle,
252
+ `SIGABRT` in `_RegisterApplication` after a
253
+ `Sandbox: … deny(1) mach-lookup com.apple.coreservices.launchservicesd`
254
+ ([oven-sh/bun#15661][bun-15661]). The same app as node — a tier 2 `.mjs`
255
+ under a copied `node`, or a tier 3 SEA — runs sandboxed. An ad-hoc
256
+ signature plus the entitlement is a valid local sandbox test; no
257
+ certificate is needed for it.
348
258
 
349
259
  **The SEA is the store shape.** Launch Services starts a bundle's
350
260
  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:
261
+ SEA carries its main. The sandbox also moves the working directory into the
262
+ container (`~/Library/Containers/<bundle id>/Data`), so a relative script
263
+ path fails with `MODULE_NOT_FOUND`; a SEA has no path to get wrong. Its
264
+ build is tier 3's, with one esbuild define that the form example needs:
356
265
 
357
266
  ```sh
358
267
  esbuild sea-main.js --bundle --platform=node --format=cjs --jsx=automatic --outfile=app.cjs \
@@ -360,46 +269,34 @@ esbuild sea-main.js --bundle --platform=node --format=cjs --jsx=automatic --outf
360
269
  node --build-sea sea.json # { "main": "app.cjs", "output": "Guestbook", "disableExperimentalSEAWarning": true }
361
270
  ```
362
271
 
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.
272
+ `index.jsx` mounts itself under a `REACT_X11_NO_AUTORUN` guard with a
273
+ top-level `await createRoot()`, which `cjs` cannot express; defined, the
274
+ guard is a constant, the branch is dropped at build time, and the entry
275
+ mounts `App` itself, in an async function:
276
+
277
+ ```js
278
+ // sea-main.js — main.js's job, as CommonJS
279
+ const path = require('node:path');
280
+ const contents = path.resolve(path.dirname(process.execPath), '..');
281
+ process.env.REACT_X11_CALAYERS_PATH ??= path.join(
282
+ contents,
283
+ 'Resources',
284
+ 'calayers.node',
285
+ );
286
+ process.env.REACT_X11_BACKEND ??= 'cocoa';
287
+ (async () => {
288
+ const [{ createRoot }, { default: App }, React] = await Promise.all([
289
+ import('react-x11'),
290
+ import('./index.jsx'),
291
+ import('react'),
292
+ ]);
293
+ const root = await createRoot();
294
+ root.render(React.createElement(App));
295
+ })().catch((e) => {
296
+ console.error(e);
297
+ process.exit(1);
298
+ });
299
+ ```
403
300
 
404
301
  The bundle is then tier 5's — the plist, the SEA at
405
302
  `Contents/MacOS/Guestbook`, `calayers.node` in `Contents/Resources` — and
@@ -411,18 +308,14 @@ codesign --force --sign - --entitlements sandbox.plist Guestbook.app
411
308
  open Guestbook.app
412
309
  ```
413
310
 
414
- where `sandbox.plist` holds the one key. That is what ran.
311
+ where `sandbox.plist` holds the one key.
415
312
 
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.
313
+ **Sign inside-out, and never `--deep`** (Apple DTS, [forums
314
+ 128166][dts-128166], [701514][dts-701514]). Sign each code item separately,
315
+ dependencies first — the addon before the app, because the app's signature
316
+ seals the addon's — and put entitlements on the main executable only.
317
+ `--deep` applies one set of options to every item, which is wrong the moment
318
+ the app has entitlements and the addon must not.
426
319
 
427
320
  **Developer ID — not run.** For a download outside the store, Gatekeeper
428
321
  wants a Developer ID signature with the hardened runtime, then
@@ -444,21 +337,15 @@ xcrun stapler staple Guestbook.app
444
337
  `com.apple.security.cs.allow-jit`, `allow-unsigned-executable-memory`,
445
338
  `disable-executable-page-protection`, `allow-dyld-environment-variables`
446
339
  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.
340
+ `com.apple.security.get-task-allow` — a debugging entitlement, which the
341
+ `node` you copy from a release carries. Never ship it; a re-sign replaces
342
+ node's entitlements wholesale, so it only appears if your plist names it.
343
+
344
+ Under the hardened runtime a copied `node` signed with no entitlements
345
+ aborts at startup with
346
+ `Fatal process out of memory: Failed to reserve virtual memory for CodeRange`;
347
+ `allow-jit` alone is enough for it to run. A `bun build --compile` binary
348
+ ran with none. The other four are what upstream ships.
462
349
 
463
350
  **Mac App Store — not run.** Sign the same bundle, inside-out, with the
464
351
  store identity, and package it as an installer:
@@ -475,13 +362,12 @@ xcrun altool --upload-app -f Guestbook.pkg -t macos --apiKey KEY --apiIssuer ISS
475
362
  `store.plist` is `com.apple.security.app-sandbox` plus what the app
476
363
  actually needs — `network.client`, `files.user-selected.read-write` — and
477
364
  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.
365
+ runtime, and `allow-jit` is needed only under it.
366
+ `Contents/embedded.provisionprofile` is needed only for restricted
367
+ entitlements (iCloud, push, app groups) or TestFlight.
368
+
369
+ Design notes — the sandbox crash investigation, what was measured and how:
370
+ [design/packaging.md](../design/packaging.md#developer-id-or-the-app-store).
485
371
 
486
372
  [bun-15661]: https://github.com/oven-sh/bun/issues/15661
487
373
  [dts-128166]: https://developer.apple.com/forums/thread/128166
@@ -492,32 +378,21 @@ identity, `productbuild --sign` and the upload are Apple's documentation.
492
378
  ## On macOS: the app moves onto a worker
493
379
 
494
380
  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
381
+ `worker_threads` Worker. AppKit keeps the process main thread — the only
382
+ thread it lets own a window — and an app whose JS ran there would stop
383
+ through every menu, drag, live resize and modal panel
498
384
  ([macos.md](macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)). The
499
385
  worker's entry is your entry itself, run from the top: when react-x11 is
500
386
  imported the entry has not run a line yet, because its imports finish
501
387
  before its body does. The main thread then waits, and launches AppKit only
502
388
  when the app's first cocoa `createRoot` asks for it.
503
389
 
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).
390
+ The move costs 25–73 ms of startup and 30–50 MB: the main thread loads the
391
+ entry's imports once before the worker loads them again (a tier 2 bundle is
392
+ at the low end). An app that imports react-x11 and never makes a cocoa root
393
+ — an X11 app on XQuartz, a script — never launches AppKit, and takes
394
+ 95–131 ms longer to exit. Measurements:
395
+ [design/packaging.md](../design/packaging.md#on-macos-the-app-moves-onto-a-worker).
521
396
 
522
397
  To skip the cost, or the move:
523
398
 
@@ -554,20 +429,18 @@ Node 20 and 22, under an ES module loader. By Node version, measured on
554
429
  | under `--import react-x11/cocoa-main` | moves | moves |
555
430
  | importing react-x11 with an `import()` | stays | stays |
556
431
 
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
432
+ An app under a loader stays on the main thread on Node 20 and 22: tsx, the
433
+ likeliest loader, keeps its hooks to the main thread there, and a worker
434
+ without them stops on the first `.jsx` or `.ts` file it loads. Any
435
+ `--import` or `--loader`, on the command line or in `NODE_OPTIONS`, counts.
436
+ The launcher moves such an app all the same, for a loader that does follow
437
+ it onto a worker (tsx 4.23 does from Node 22.22.3). On Node 24 and later
438
+ tsx follows from tsx 4.21.1 and Node 24.11.1; an older tsx there stops the
439
+ app as it starts with `ERR_UNKNOWN_FILE_EXTENSION` — update tsx, or set
567
440
  `REACT_X11_THREADED=0`. Bun moves an ES module entry and a CommonJS one,
568
441
  `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)).
442
+ that imports react-x11 with an `import()`; it tells a late import its own
443
+ way ([macos.md](macos.md#what-changes-for-an-app)).
571
444
 
572
445
  ## Checklist
573
446
 
@@ -590,22 +463,6 @@ late import its own way ([macos.md](macos.md#the-renderers-half)).
590
463
  never `--deep`.
591
464
  - Test on a display you did not develop on. Fonts are the usual surprise:
592
465
  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.
466
+ face on the target ([remote.md](remote.md#the-other-x-servers)).
610
467
 
611
468
  [x11-246]: https://github.com/sidorares/node-x11/issues/246