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.
- package/README.md +94 -61
- package/docs/README.md +256 -0
- package/docs/accessibility.md +575 -0
- package/docs/appearance.md +317 -0
- package/docs/click-to-component.md +92 -0
- package/docs/clipboard.md +262 -0
- package/docs/components.md +1231 -0
- package/docs/dbus.md +205 -0
- package/docs/debugging.md +371 -0
- package/docs/desktop-calendar.md +247 -0
- package/docs/desktop.md +523 -0
- package/docs/devtools.md +133 -0
- package/docs/drag-and-drop.md +585 -0
- package/docs/ecosystem/animation.md +360 -0
- package/docs/ecosystem/data-fetching.md +186 -0
- package/docs/ecosystem/dev-tooling.md +393 -0
- package/docs/ecosystem/forms.md +277 -0
- package/docs/ecosystem/headless.md +194 -0
- package/docs/ecosystem/i18n.md +86 -0
- package/docs/ecosystem/icons.md +377 -0
- package/docs/ecosystem/layout.md +206 -0
- package/docs/ecosystem/routing.md +135 -0
- package/docs/ecosystem/state.md +344 -0
- package/docs/ecosystem/testing.md +474 -0
- package/docs/ecosystem/theming.md +494 -0
- package/docs/ecosystem.md +439 -0
- package/docs/elements.md +2079 -0
- package/docs/embedding.md +239 -0
- package/docs/events.md +742 -0
- package/docs/extending.md +1700 -0
- package/docs/eyedropper.md +164 -0
- package/docs/filedialog.md +222 -0
- package/docs/frame.md +436 -0
- package/docs/gl.md +173 -0
- package/docs/globalmenu.md +195 -0
- package/docs/macos.md +592 -0
- package/docs/notifications.md +150 -0
- package/docs/packaging.md +468 -0
- package/docs/permissions.md +143 -0
- package/docs/react-features.md +405 -0
- package/docs/remote.md +179 -0
- package/docs/scale.md +225 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1710 -0
- package/docs/system.md +519 -0
- package/docs/testing.md +494 -0
- package/docs/typescript.md +75 -0
- package/docs/uri-schemes.md +340 -0
- package/docs/wayland-backend.md +332 -0
- package/docs/windows-integrations.md +177 -0
- package/docs/windows.md +380 -0
- package/package.json +11 -3
- package/src/Reconciler.js +2 -2
- package/src/a11y.js +1 -1
- package/src/backend/context2d.js +3 -3
- package/src/bootstrap.js +1 -1
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/cocoa/app.js +6 -6
- package/src/cocoa/bezels.js +1 -1
- package/src/cocoa/fonts.js +1 -1
- package/src/cocoa/globalmenu.js +1 -1
- package/src/cocoa/keymap.js +1 -1
- package/src/cocoa/panewindow.js +1 -1
- package/src/cocoa/player.js +1 -1
- package/src/cocoa/presenter.js +2 -2
- package/src/cocoa/promotion.js +1 -1
- package/src/cocoa/relaunch.js +1 -1
- package/src/cocoa/threaded.js +1 -1
- package/src/cocoa/video.js +1 -1
- package/src/cocoa/window.js +4 -4
- package/src/components/Menu.js +7 -2
- package/src/components/Switch.js +1 -1
- package/src/components/native.js +1 -1
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/dnd.js +2 -2
- package/src/filedialog.js +3 -3
- package/src/glnodes.js +1 -1
- package/src/grid.js +1 -1
- package/src/keyboardstate.js +28 -0
- package/src/layouts.js +1 -1
- package/src/node.d.ts +10 -10
- package/src/nodes/animation.js +1 -1
- package/src/nodes/invalidate.js +1 -1
- package/src/nodes/layouthost.js +1 -1
- package/src/nodes/video.js +19 -8
- package/src/nodes/window/droptarget.js +1 -1
- package/src/pacing.js +1 -1
- package/src/portal.js +31 -0
- package/src/refresh/index.js +2 -1
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/types/video.d.ts +5 -2
- package/src/videobackend.js +28 -0
- package/src/videoframes.js +29 -16
- package/src/wayland/app.js +86 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/video.js +376 -0
- package/src/wayland/xkb.js +137 -0
- package/src/win32/a11y.js +1 -1
- package/src/win32/app.js +2 -2
- package/src/win32/fonts.js +1 -1
- package/src/win32/glarea.js +4 -4
- package/src/win32/overlay.js +1 -1
- package/src/win32/panewindow.js +3 -3
- package/src/win32/shell.js +1 -1
- package/src/win32/window.js +2 -2
- package/src/windowid.js +1 -1
- 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
|