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,150 @@
1
+ # Notifications
2
+
3
+ A banner outside the app's own windows — "export finished", "update ready" —
4
+ on whatever the machine has.
5
+
6
+ ```jsx
7
+ import { useNotifier } from 'react-x11';
8
+
9
+ function Exporter() {
10
+ const notifier = useNotifier();
11
+
12
+ const run = async () => {
13
+ const file = await exportReport();
14
+ if (!notifier.available) return;
15
+ await notifier.notify({
16
+ summary: 'Export finished',
17
+ body: file.name,
18
+ actions: [{ key: 'open', label: 'Open' }],
19
+ onAction: (key) => key === 'open' && reveal(file),
20
+ });
21
+ };
22
+ return <Button label="Export" onPress={run} />;
23
+ }
24
+ ```
25
+
26
+ An in-app toast is **not** this; this page is about the desktop's own banners.
27
+
28
+ ## The ladder
29
+
30
+ Like the [file dialog](filedialog.md)'s, four rungs, chosen for you:
31
+
32
+ | | | |
33
+ | --- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | 1 | **the backend's notification centre** | The [cocoa backend](macos.md): `UNUserNotificationCenter`, the system's own banners, list and action buttons. Delivers only for a **code-signed app bundle** with a bundle id — see below. On [win32](windows-integrations.md): a tray balloon, which the shell shows as a toast and keeps in the action centre. |
35
+ | 2 | **`org.freedesktop.Notifications`** | The desktop's daemon over D-Bus: updating in place, the daemon's capabilities, and the two signals that say what the user did. What a Linux desktop should get. |
36
+ | 3 | **`osascript`** | `display notification`, on a Mac with neither of the above — the X11 backend under XQuartz, or an unbundled cocoa app. Posted under Script Editor's identity; no actions, no update, no report back. |
37
+ | 4 | **`notify-send`** | libnotify's CLI, for a Linux box where the bus transport is missing (Node 20) but a daemon runs. The same limits, though a new enough one prints an id, which gives `update()` back. |
38
+
39
+ `notificationBackend()` reports which one this machine lands on without
40
+ posting anything. Where none answers, `notify()` rejects with a **typed**
41
+ `NoNotificationServiceError` — the signal to fall back to your own UI, not a
42
+ crash — and `useNotifier().available` is that branch as render state.
43
+
44
+ ## `notify()` and the handle
45
+
46
+ ```ts
47
+ const banner = await notify({
48
+ summary, // required — the one line every daemon shows
49
+ body?, icon?, urgency?: 'low' | 'normal' | 'critical', timeout?,
50
+ actions?: [{ key, label }], onAction?(key), onClose?(reason),
51
+ subtitle?, silent?, category?, resident?, appName?, appId?, userInfo?,
52
+ backend?, app?,
53
+ });
54
+
55
+ banner.id; // the daemon's id / the centre's identifier / null
56
+ banner.backend; // which rung
57
+ await banner.update({ body }); // in place, where the rung can
58
+ await banner.close(); // take it down, where the rung can
59
+ ```
60
+
61
+ **The vocabulary is freedesktop's on every rung.** `urgency` is
62
+ `low | normal | critical`, an action is `{ key, label }`, a click on the
63
+ banner itself is the action keyed `'default'`, and a banner that went away
64
+ reports `'expired' | 'dismissed' | 'closed' | 'unknown'`. The cocoa rung
65
+ translates into them.
66
+
67
+ **Attribution is filled in for you.** On Linux the `desktop-entry` hint is
68
+ the `appId` [`registerApplication()`](uri-schemes.md) established, which is
69
+ what makes the banner show up under your app in the shell's own list of
70
+ them; `appId` overrides it. On macOS the bundle is the attribution, and there
71
+ is nothing to pass.
72
+
73
+ ## What each rung cannot do
74
+
75
+ | rung | update in place | close | actions | what the user did |
76
+ | ------------- | ------------------- | ------- | ----------------- | -------------------- |
77
+ | cocoa | yes | yes | yes | action, dismissal |
78
+ | win32 | no | no | no | nothing |
79
+ | dbus | yes (`replaces_id`) | yes | if the daemon can | action, close reason |
80
+ | `osascript` | posts anew | nothing | no | nothing |
81
+ | `notify-send` | with an id, yes | nothing | no | nothing |
82
+
83
+ On `win32` the banner is a real toast in the action centre, but
84
+ fire-and-forget: buttons, replies and update-in-place would need an
85
+ AppUserModelID for the process, which is a packaging question
86
+ ([windows.md](windows.md) §"Packaging and identity").
87
+
88
+ The shell-out rungs show a banner and know nothing afterwards. `handle.backend` says which rung answered, so an app that cares
89
+ can say "open the app to see it" there rather than promise a button it
90
+ cannot deliver.
91
+
92
+ **Actions on the bus are gated by the daemon.** To a daemon that cannot show
93
+ buttons (no `actions` in its `GetCapabilities`, read once per bus session)
94
+ they are dropped with a development warning rather than sent blind.
95
+
96
+ **A refusal is not fallen through.** On the cocoa backend the first post asks
97
+ the system's authorization prompt, once per bundle. If the user declined,
98
+ `notify()` rejects with `NotificationsDeniedError` rather than posting through
99
+ `osascript`.
100
+
101
+ **A prompt nobody saw is not a refusal.** A bundle macOS has not registered
102
+ (typically an ad-hoc signature in a temp directory) never gets the prompt and
103
+ stays `notDetermined`; the ladder then carries on to `osascript`. Only
104
+ `backend: 'cocoa'` turns that into an error, and it says `notDetermined`
105
+ rather than blaming the user.
106
+
107
+ So an app that reports progress should check `handle.backend` before it
108
+ decides how often to post. `examples/notify.jsx` (`npm run examples:notify`)
109
+ updates one banner in place where the rung can, and on `osascript` or
110
+ `notify-send` posts once at the end.
111
+
112
+ ## The bundle, on macOS
113
+
114
+ `UNUserNotificationCenter` attributes every banner to an app bundle — a
115
+ `CFBundleIdentifier` Launch Services can see — so a bare `node` process gets
116
+ `available: false` from the centre and `notify()` takes the `osascript` rung,
117
+ whose banners are Script Editor's. To get the real thing, run as a bundle: the
118
+ executable in `Name.app/Contents/MacOS/`, an `Info.plist` that names it and
119
+ carries `CFBundleIdentifier`, and a signature
120
+ (`codesign --force --sign - Name.app` is enough locally). See
121
+ [packaging.md](packaging.md).
122
+
123
+ ## `useNotifier()`
124
+
125
+ ```ts
126
+ const { notify, available, backend } = useNotifier(defaults?);
127
+ ```
128
+
129
+ `notify` is the bare function bound to the tree's connection; `available`
130
+ settles once the ladder has been probed (one bus round trip, or one read of
131
+ the centre) and is false where `notify()` would reject; `backend` is the rung.
132
+ Options given to the hook are defaults for every call; options given to a
133
+ call win.
134
+
135
+ ## Running it where there is no daemon
136
+
137
+ | where | rung |
138
+ | ----------------------------------------------- | -------------------------------------------------- |
139
+ | macOS, the cocoa backend, as an app bundle | the centre |
140
+ | macOS, the cocoa backend, bare `node` | `osascript` |
141
+ | macOS + XQuartz | `osascript` (a D-Bus daemon on a Mac is rare) |
142
+ | GNOME / KDE / any desktop with a daemon | `org.freedesktop.Notifications` |
143
+ | Node 20 on Linux, where npm skips `dbus-native` | `notify-send` |
144
+ | ssh, `startx`, a container, CI | `NoNotificationServiceError` — draw your own toast |
145
+
146
+ The bus rung holds one session-bus reference while any banner is live and
147
+ releases it when the last one closes, so an app that closed its banners on
148
+ the way out is free to exit.
149
+
150
+ Design notes: [design/notifications.md](../design/notifications.md).
@@ -0,0 +1,468 @@
1
+ # Packaging and distribution
2
+
3
+ Five ways to ship a react-x11 app, cheapest first. Every recipe here was
4
+ run, except the signing steps that need a real identity, which say so.
5
+
6
+ | tier | works | cost |
7
+ | ------------------------------- | -------------------------- | ------------------------------- |
8
+ | 1. plain `npm install` | yes | a `node_modules` on the target |
9
+ | 2. a single `.mjs` | yes, with one esbuild flag | one file, ~7 MB |
10
+ | 3. Node single executable (SEA) | yes, as CommonJS | one file, ~142 MB |
11
+ | 4. AppImage / `.deb` | yes, wrapping tier 1 or 2 | packaging metadata |
12
+ | 5. a macOS `.app` | yes, wrapping tier 2 or 3 | a plist, and a signing identity |
13
+
14
+ ## Tier 1 — `npm install`
15
+
16
+ The default, and fine for a server-side app, a CI tool, or anything already
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
24
+ matches the platform, so an install that cannot build one still succeeds.
25
+
26
+ ## Tier 2 — one `.mjs` file
27
+
28
+ ```sh
29
+ esbuild app.jsx --bundle --platform=node --format=esm --outfile=app.mjs \
30
+ --banner:js="import{createRequire as __cjsRequire}from'node:module';const require=__cjsRequire(import.meta.url);"
31
+
32
+ node app.mjs
33
+ ```
34
+
35
+ Three things about that command are load-bearing.
36
+
37
+ **`--format=esm` is the easy path, not the only one.** It tolerates top-level
38
+ await anywhere in the graph; `cjs` does not, and refuses to build with
39
+ `Top-level await is currently not supported with the "cjs" output format`.
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`.
44
+
45
+ **The `--banner:js` is not optional.** node-x11 is CommonJS and uses dynamic
46
+ `require`, which esbuild cannot resolve statically. Without the banner the
47
+ bundle builds cleanly and then dies at startup:
48
+
49
+ ```
50
+ Error: Dynamic require of "events" is not supported
51
+ ```
52
+
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]).
55
+
56
+ **The banner's import must be aliased.** The obvious form, `import
57
+ {createRequire} from 'node:module'`, collides with react-x11's own import of
58
+ the same name and the bundle fails to parse with `Identifier 'createRequire'
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)).
74
+
75
+ ## Tier 3 — Node single executable
76
+
77
+ ```sh
78
+ esbuild app.jsx --bundle --platform=node --format=cjs --outfile=app.cjs
79
+ node --build-sea=sea.json # { "main": "app.cjs", "output": "myapp" }
80
+ ./myapp
81
+ ```
82
+
83
+ ```json
84
+ { "main": "app.cjs", "output": "myapp", "disableExperimentalSEAWarning": true }
85
+ ```
86
+
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:
92
+
93
+ - **No top-level `await`.** Put startup in an `async function main()`;
94
+ `const root = await createRoot()` at the top level of your entry is enough
95
+ to fail the build. esbuild names the file and line.
96
+ - **`import.meta.url` is `undefined`.** Anything resolving paths through it
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`.
104
+ - **Runtime module loading is out.** Inside a SEA, the embedded main's
105
+ `require()` _and_ `import()` resolve **built-in modules only** — a `data:`
106
+ or `file:` URL import fails with `ERR_UNKNOWN_BUILTIN_MODULE`. A bundle has
107
+ nothing left to resolve, so this only bites if you meant to load something
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.
111
+ - **Assets are not files.** `sea.getAsset()` reads what the config's
112
+ `assets` map embedded; fonts are the usual case, and `StaticFontSource`
113
+ takes bytes directly ([ntk's fonts guide][ntk-fonts]).
114
+
115
+ On macOS the binary must be re-signed before it runs
116
+ (`codesign --remove-signature myapp && codesign --sign - myapp`); on Linux
117
+ nothing extra.
118
+
119
+ **The cocoa backend works in a SEA, with the addon beside the binary.** The
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)).
132
+
133
+ [ntk-fonts]: https://github.com/sidorares/ntk/blob/master/docs/fonts.md
134
+
135
+ ## Tier 4 — AppImage, `.deb`, `.rpm`
136
+
137
+ These wrap tier 1 or tier 2; the packaging is the desktop-integration
138
+ metadata rather than anything about JavaScript.
139
+
140
+ - A **`.desktop` file** — without one, the app has no launcher entry, no
141
+ icon in the dock, and no association with its own windows. Its `StartupWMClass`
142
+ must match the `appId` prop on your `<window>`
143
+ ([elements.md](elements.md)), or the desktop groups your window under the
144
+ wrong icon.
145
+ - **Icons** at 48, 128 and 256 px under
146
+ `usr/share/icons/hicolor/<size>/apps/`. Set `_NET_WM_ICON` too, which the
147
+ window manager reads for the titlebar and alt-tab; the WM example already
148
+ reads both sources ([AGENTS.md](../AGENTS.md), "Writing a window
149
+ manager").
150
+ - **Node itself**, if you are not depending on a system one. That is most of
151
+ the size.
152
+
153
+ Nothing here is react-x11-specific — any Node desktop app packages the same
154
+ way.
155
+
156
+ ## Tier 5 — a macOS `.app`
157
+
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)).
162
+
163
+ ```sh
164
+ APP=Downloads.app
165
+ mkdir -p $APP/Contents/MacOS
166
+ cp "$(command -v node)" $APP/Contents/MacOS/Downloads # a copy, not a script
167
+ cat > $APP/Contents/Info.plist <<'PLIST'
168
+ <?xml version="1.0" encoding="UTF-8"?>
169
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
170
+ <plist version="1.0">
171
+ <dict>
172
+ <key>CFBundleName</key><string>Downloads</string>
173
+ <key>CFBundleExecutable</key><string>Downloads</string>
174
+ <key>CFBundleIdentifier</key><string>com.example.downloads</string>
175
+ <key>CFBundlePackageType</key><string>APPL</string>
176
+ <key>CFBundleVersion</key><string>1</string>
177
+ <key>CFBundleShortVersionString</key><string>1.0</string>
178
+ <key>NSHighResolutionCapable</key><true/>
179
+ </dict>
180
+ </plist>
181
+ PLIST
182
+
183
+ codesign --force --sign - $APP # ad-hoc; see the signature note below
184
+ $APP/Contents/MacOS/Downloads app.mjs # or app.cjs from tier 3
185
+ ```
186
+
187
+ **The executable must be the real binary, in `Contents/MacOS/`.** A shell
188
+ script that execs node from elsewhere does not work: `NSBundle.mainBundle` is
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
192
+ ([below](#on-macos-the-app-moves-onto-a-worker)).
193
+
194
+ What the bundle changes:
195
+
196
+ | | inside the `.app` | bare `node` |
197
+ | ----------------------------------------- | ----------------------- | ------------------ |
198
+ | `notificationSettings().bundleIdentifier` | `com.example.downloads` | `null` |
199
+ | the notification centre | `available: true` | `available: false` |
200
+ | `notificationBackend()` | `'cocoa'` | `'osascript'` |
201
+
202
+ Everything that keys off a bundle id follows the first column: the app's own
203
+ name in the menu bar rather than "node", a Dock tile that is yours to badge
204
+ ([desktop.md](desktop.md)), `activationPolicy` meaning something, and URL
205
+ schemes and document types you can declare in the same plist and receive as
206
+ Apple Events ([uri-schemes.md](uri-schemes.md)).
207
+
208
+ ### The signature is the part that bites
209
+
210
+ An **ad-hoc signature (`--sign -`) is enough to be a bundle, and not enough to
211
+ post a notification.** With one, the centre reports `available: true` and
212
+ every authorization request fails with `UNErrorDomain` code 1
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.
217
+
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):
220
+
221
+ ```sh
222
+ security find-identity -v -p codesigning # pick one
223
+ codesign --force --sign "Apple Development: you@example.com (TEAMID)" $APP
224
+ ```
225
+
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.
231
+
232
+ ### Developer ID, or the App Store
233
+
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.
238
+
239
+ | | Developer ID — a download | Mac App Store |
240
+ | ---------------- | -------------------------------------------------- | ---------------------------------------------------- |
241
+ | App Sandbox | optional | required |
242
+ | hardened runtime | required, by notarization | not required |
243
+ | runtime | node or bun | node — bun does not start under the sandbox |
244
+ | shape | this tier around tier 2 or 3, or bun's `--compile` | tier 3: a SEA, an executable that takes no arguments |
245
+ | identity | Developer ID Application | Apple Distribution, plus an installer identity |
246
+ | then | `notarytool`, `stapler` | `productbuild`, Transporter |
247
+
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.
258
+
259
+ **The SEA is the store shape.** Launch Services starts a bundle's
260
+ executable with no arguments, so a copied `node` has nothing to run; the
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:
265
+
266
+ ```sh
267
+ esbuild sea-main.js --bundle --platform=node --format=cjs --jsx=automatic --outfile=app.cjs \
268
+ --define:process.env.REACT_X11_NO_AUTORUN='"1"'
269
+ node --build-sea sea.json # { "main": "app.cjs", "output": "Guestbook", "disableExperimentalSEAWarning": true }
270
+ ```
271
+
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
+ ```
300
+
301
+ The bundle is then tier 5's — the plist, the SEA at
302
+ `Contents/MacOS/Guestbook`, `calayers.node` in `Contents/Resources` — and
303
+ the signature is two commands, in this order:
304
+
305
+ ```sh
306
+ codesign --force --sign - Guestbook.app/Contents/Resources/calayers.node
307
+ codesign --force --sign - --entitlements sandbox.plist Guestbook.app
308
+ open Guestbook.app
309
+ ```
310
+
311
+ where `sandbox.plist` holds the one key.
312
+
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.
319
+
320
+ **Developer ID — not run.** For a download outside the store, Gatekeeper
321
+ wants a Developer ID signature with the hardened runtime, then
322
+ notarization:
323
+
324
+ ```sh
325
+ ID="Developer ID Application: NAME (TEAMID)"
326
+ codesign --force --timestamp --sign "$ID" Guestbook.app/Contents/Resources/calayers.node
327
+ codesign --force --timestamp --options runtime --entitlements developer-id.plist \
328
+ --sign "$ID" Guestbook.app
329
+ ditto -c -k --keepParent Guestbook.app Guestbook.zip
330
+ xcrun notarytool submit Guestbook.zip --keychain-profile notary --wait
331
+ xcrun stapler staple Guestbook.app
332
+ ```
333
+
334
+ `developer-id.plist` is the upstream list for the runtime you ship. Bun's
335
+ "Code signing on macOS" ([the executables guide][bun-codesign]) and Node's
336
+ [`tools/osx-entitlements.plist`][node-entitlements] name the same five:
337
+ `com.apple.security.cs.allow-jit`, `allow-unsigned-executable-memory`,
338
+ `disable-executable-page-protection`, `allow-dyld-environment-variables`
339
+ and `disable-library-validation`. Node's file has a sixth,
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.
349
+
350
+ **Mac App Store — not run.** Sign the same bundle, inside-out, with the
351
+ store identity, and package it as an installer:
352
+
353
+ ```sh
354
+ ID="Apple Distribution: NAME (TEAMID)"
355
+ codesign --force --timestamp --sign "$ID" Guestbook.app/Contents/Resources/calayers.node
356
+ codesign --force --timestamp --entitlements store.plist --sign "$ID" Guestbook.app
357
+ productbuild --sign "3rd Party Mac Developer Installer: NAME (TEAMID)" \
358
+ --component Guestbook.app /Applications Guestbook.pkg
359
+ xcrun altool --upload-app -f Guestbook.pkg -t macos --apiKey KEY --apiIssuer ISSUER # or Transporter
360
+ ```
361
+
362
+ `store.plist` is `com.apple.security.app-sandbox` plus what the app
363
+ actually needs — `network.client`, `files.user-selected.read-write` — and
364
+ none of the hardened-runtime keys: the store does not require the hardened
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).
371
+
372
+ [bun-15661]: https://github.com/oven-sh/bun/issues/15661
373
+ [dts-128166]: https://developer.apple.com/forums/thread/128166
374
+ [dts-701514]: https://developer.apple.com/forums/thread/701514
375
+ [bun-codesign]: https://bun.com/docs/bundler/executables#code-signing-on-macos
376
+ [node-entitlements]: https://github.com/nodejs/node/blob/main/tools/osx-entitlements.plist
377
+
378
+ ## On macOS: the app moves onto a worker
379
+
380
+ On macOS the first import of react-x11 moves the app onto a
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
384
+ ([macos.md](macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)). The
385
+ worker's entry is your entry itself, run from the top: when react-x11 is
386
+ imported the entry has not run a line yet, because its imports finish
387
+ before its body does. The main thread then waits, and launches AppKit only
388
+ when the app's first cocoa `createRoot` asks for it.
389
+
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).
396
+
397
+ To skip the cost, or the move:
398
+
399
+ - **Start with the launcher**: `node --import react-x11/cocoa-main app.mjs`,
400
+ or `bun --preload react-x11/cocoa-main app.mjs`. It moves the app before
401
+ the entry is loaded at all, which is faster than the pump and costs only
402
+ the worker. It resolves the installed package, so it is for an app run
403
+ from a checkout or an `npm install` — anywhere you write the command
404
+ line.
405
+ - **Stay on the pump** with `REACT_X11_THREADED=0`: the app keeps the main
406
+ thread, and a menu, a drag or a live resize stops its JS again.
407
+ - **Import react-x11 first** in the entry. What the entry imports before
408
+ it loads on the main thread as well as on the worker: nothing to notice
409
+ for React, but a module that does work as it loads — opens a port,
410
+ writes a file, logs — does it twice, and one that makes a call only a
411
+ main thread may, `process.chdir()`, throws the second time
412
+ ([macos.md](macos.md#what-changes-for-an-app)).
413
+
414
+ The move does not happen in a single executable (tier 3, and
415
+ `bun build --compile`), whose entry is not a file a worker can load, so it
416
+ runs on the pump; in a `<Frame>` pane, whose IPC channel to its host is
417
+ the main thread's; under a test runner; with `REACT_X11_THREADED=0` or
418
+ `REACT_X11_BACKEND=x11`; when react-x11 is imported after the app has
419
+ started running — by an `import()` from a timer, or one the entry awaits —
420
+ where the worker would run again what the entry has already done; and on
421
+ Node 20 and 22, under an ES module loader. By Node version, measured on
422
+ 20.20, 22.16, 24.12 and 26.0:
423
+
424
+ | the app | Node 20, 22 | Node 24 and later |
425
+ | ------------------------------------------------------ | ----------- | ----------------- |
426
+ | `node app.mjs`, an ES module entry, or a tier 2 bundle | moves | moves |
427
+ | `node app.cjs`, a CommonJS entry | moves | moves |
428
+ | `tsx app.jsx`, or any `--import` or `--loader` | stays | moves |
429
+ | under `--import react-x11/cocoa-main` | moves | moves |
430
+ | importing react-x11 with an `import()` | stays | stays |
431
+
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
440
+ `REACT_X11_THREADED=0`. Bun moves an ES module entry and a CommonJS one,
441
+ `bun app.mjs` and `bun app.cjs`, and keeps the main thread for an entry
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)).
444
+
445
+ ## Checklist
446
+
447
+ - On macOS, choose where the app's JS runs: the default move onto a worker
448
+ costs 25–73 ms of startup, `--import react-x11/cocoa-main` skips it,
449
+ `REACT_X11_THREADED=0` keeps the pump, and a SEA always does, as does an
450
+ app under a loader on Node 20 or 22
451
+ ([above](#on-macos-the-app-moves-onto-a-worker)).
452
+ - Pick the format deliberately: `--format=esm` (tier 2, needs the banner) or
453
+ `--format=cjs` (tier 3, needs no top-level await in your own code).
454
+ - With `esm`, alias the banner's `createRequire`.
455
+ - Do not ship `.Xauthority`, and do not bake `DISPLAY` into an image —
456
+ [security.md](security.md).
457
+ - Set `appId` and match it in `StartupWMClass`.
458
+ - On macOS, ship a `.app` (tier 5) rather than a bare executable, and sign it
459
+ with a real identity if you want the notification centre.
460
+ - For the Mac App Store, build tier 3 — a node SEA — and sandbox it: bun does
461
+ not start under App Sandbox. Developer ID takes either runtime.
462
+ - Sign nested code first and the app last, entitlements on the app only, and
463
+ never `--deep`.
464
+ - Test on a display you did not develop on. Fonts are the usual surprise:
465
+ family resolution goes through `fc-match`, so `sans-serif` is a different
466
+ face on the target ([remote.md](remote.md#the-other-x-servers)).
467
+
468
+ [x11-246]: https://github.com/sidorares/node-x11/issues/246