react-x11 2.47.5 → 2.49.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 +9 -8
- package/package.json +6 -3
- package/src/Reconciler.js +4 -1
- package/src/cli/dev.js +503 -0
- package/src/cli/index.js +81 -0
- package/src/cocoa/app.js +33 -3
- package/src/cocoa/dnd.js +29 -4
- package/src/cocoa/panehost.js +24 -5
- package/src/cocoa/window.js +3 -1
- package/src/errors.js +8 -0
- package/src/index.d.ts +6 -0
- package/src/refresh/loader.js +26 -6
- package/src/refresh/register.js +8 -5
package/README.md
CHANGED
|
@@ -446,14 +446,15 @@ To have it manage your real session instead — including replacing
|
|
|
446
446
|
npm run examples:tasks:hot # then edit examples/tasks.jsx while it runs
|
|
447
447
|
```
|
|
448
448
|
|
|
449
|
-
Runs the tasks example under
|
|
450
|
-
`
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
component
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
449
|
+
Runs the tasks example under `react-x11 dev`, with React **Fast Refresh**
|
|
450
|
+
(Node ≥ 22.15): saving `examples/tasks.jsx` updates the edited components
|
|
451
|
+
in place. The connection, the mounted window, and component state — the
|
|
452
|
+
task list, even half-typed text in the input — survive the reload (a
|
|
453
|
+
component whose hook signature changed remounts alone). Any app adopts it
|
|
454
|
+
the same way, with no accept handlers and no entry file to write:
|
|
455
|
+
`npx react-x11 dev app.jsx` mounts the file's default export, in a window
|
|
456
|
+
of its own while the tree renders none, and offers to install the toolchain
|
|
457
|
+
on its first run; see
|
|
457
458
|
[docs/ecosystem/dev-tooling.md](docs/ecosystem/dev-tooling.md#react-refresh).
|
|
458
459
|
|
|
459
460
|
## React DevTools
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.49.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"react-x11": "./src/cli/index.js"
|
|
8
|
+
},
|
|
6
9
|
"files": [
|
|
7
10
|
"src"
|
|
8
11
|
],
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
"examples:xeyes": "tsx examples/xeyes.jsx",
|
|
27
30
|
"examples:dashboard": "tsx examples/dashboard.jsx",
|
|
28
31
|
"examples:tasks": "tsx examples/tasks.jsx",
|
|
29
|
-
"examples:tasks:hot": "node
|
|
32
|
+
"examples:tasks:hot": "node src/cli/index.js dev examples/tasks.jsx",
|
|
30
33
|
"examples:menu": "tsx examples/menu.jsx",
|
|
31
34
|
"examples:notify": "tsx examples/notify.jsx",
|
|
32
35
|
"examples:monitor": "tsx examples/monitor.jsx",
|
|
@@ -109,7 +112,7 @@
|
|
|
109
112
|
"yoga-layout": "^3.2.1"
|
|
110
113
|
},
|
|
111
114
|
"optionalDependencies": {
|
|
112
|
-
"@windowkit/appkit": "^0.
|
|
115
|
+
"@windowkit/appkit": "^0.29.0",
|
|
113
116
|
"@windowkit/wayland": "^3.1.1",
|
|
114
117
|
"@windowkit/win32": "^0.0.9",
|
|
115
118
|
"dbus-native": "^0.16.0",
|
package/src/Reconciler.js
CHANGED
|
@@ -44,6 +44,7 @@ import { hasDropProps } from './dnd.js';
|
|
|
44
44
|
import { AppProvider } from './appcontext.js';
|
|
45
45
|
import {
|
|
46
46
|
defaultRootHandlers,
|
|
47
|
+
NOT_IN_WINDOW,
|
|
47
48
|
setErrorHandler,
|
|
48
49
|
STRICT_TOKENS,
|
|
49
50
|
} from './errors.js';
|
|
@@ -138,11 +139,13 @@ const ROOT_TYPES = new Set(['window', 'popup', THEME_SCOPE]);
|
|
|
138
139
|
* `<box>` the app never wrote (#584).
|
|
139
140
|
*/
|
|
140
141
|
function notAtRoot(type) {
|
|
141
|
-
|
|
142
|
+
const err = new Error(
|
|
142
143
|
`react-x11: <${type}> must be inside a <window> — it is at the root of ` +
|
|
143
144
|
'the tree, which holds only <window> and <popup> elements. Render it ' +
|
|
144
145
|
`inside one: <window><${type} …/></window>.`,
|
|
145
146
|
);
|
|
147
|
+
err.code = NOT_IN_WINDOW;
|
|
148
|
+
return err;
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
const HostConfig = {
|
package/src/cli/dev.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// `react-x11 dev <file>` — hot reload with nothing to set up.
|
|
2
|
+
//
|
|
3
|
+
// What `node --import react-x11/refresh/register main.jsx` leaves to the app,
|
|
4
|
+
// this does itself, and each piece is here because the --import route gets it
|
|
5
|
+
// wrong without one:
|
|
6
|
+
//
|
|
7
|
+
// - **It is the entry.** The file named on a command line is the one module
|
|
8
|
+
// hot-module-replacement never watches — it starts watching a module when
|
|
9
|
+
// another module imports it — so under --import an app in one file never
|
|
10
|
+
// reloads, and nothing says so. Here this module imports the file, which
|
|
11
|
+
// makes it an ordinary hot module: one file, and saving it reloads it.
|
|
12
|
+
// - **It mounts the file's default export**, so the file has no createRoot
|
|
13
|
+
// and no named-import rule to learn before its first save. A file that
|
|
14
|
+
// mounts a root of its own while it is imported runs as it is, the way the
|
|
15
|
+
// --import route runs it.
|
|
16
|
+
// - **It supplies a `<window>` when the tree has none.** Whether a tree has
|
|
17
|
+
// one cannot be read off the export — a component returns another
|
|
18
|
+
// component, or a window only once a state settles — but the renderer
|
|
19
|
+
// knows the moment it matters: a drawn element at the root throws
|
|
20
|
+
// NOT_IN_WINDOW before anything commits. So the component is mounted as it
|
|
21
|
+
// is, and on that error mounted again inside a window of ours, with nothing
|
|
22
|
+
// lost: the failed render committed nothing. Decided again on every reload,
|
|
23
|
+
// both ways, so the first answer does not outlive the code that gave it.
|
|
24
|
+
// - **It asks before it installs the toolchain.** Babel and friends are
|
|
25
|
+
// optional peer dependencies, 7.6 MB that an app's users should not
|
|
26
|
+
// download for a tool only its developers run, so `dev` finds them
|
|
27
|
+
// missing, names them with the versions the loader is written against, and
|
|
28
|
+
// installs them with the project's own package manager when the developer
|
|
29
|
+
// says yes. Without a terminal to ask on it prints the command instead.
|
|
30
|
+
// - **It runs the project's react-x11.** A second copy of the renderer is a
|
|
31
|
+
// second React root nobody refreshes, so a global or npx-cached
|
|
32
|
+
// `react-x11` hands over to the copy the file itself imports.
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
35
|
+
import * as nodeModule from 'node:module';
|
|
36
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
37
|
+
import { createInterface } from 'node:readline/promises';
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
|
+
import { THEME_SCOPE } from '../nodes/kinds.js';
|
|
40
|
+
|
|
41
|
+
const OWN_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
42
|
+
const OWN_PACKAGE = JSON.parse(
|
|
43
|
+
readFileSync(join(OWN_ROOT, 'package.json'), 'utf8'),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the dev window is when the tree brings none. No size: a `<window>`
|
|
48
|
+
* without one is sized from its content, which is the honest frame for a
|
|
49
|
+
* component shown alone, and the floor only keeps a lone button's window big
|
|
50
|
+
* enough to find and grab.
|
|
51
|
+
*/
|
|
52
|
+
const DEV_WINDOW = { minWidth: 320, minHeight: 200 };
|
|
53
|
+
|
|
54
|
+
/** A mistake the developer can fix from the message: printed bare. */
|
|
55
|
+
export class DevError extends Error {
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'DevError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run `file` with hot reload. Resolves once the app is mounted; the process
|
|
64
|
+
* then lives as long as the app's connection does.
|
|
65
|
+
*
|
|
66
|
+
* `options.args` is what the app sees after its file in `process.argv`, as
|
|
67
|
+
* under `node file.jsx …args`. `createRoot` and `log` are the seams the
|
|
68
|
+
* tests replace: the root is made against their mock app, and what this
|
|
69
|
+
* prints is what they read.
|
|
70
|
+
*/
|
|
71
|
+
export async function runDev(file, options = {}) {
|
|
72
|
+
const {
|
|
73
|
+
args = [],
|
|
74
|
+
createRoot = null,
|
|
75
|
+
log = (line) => console.error(`react-x11 dev: ${line}`),
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
const path = resolve(file);
|
|
79
|
+
const rel = (p) => relative(process.cwd(), p) || p;
|
|
80
|
+
if (!existsSync(path)) {
|
|
81
|
+
throw new DevError(`${file}: no such file.`);
|
|
82
|
+
}
|
|
83
|
+
const ext = extname(path);
|
|
84
|
+
if (!['.jsx', '.js', '.mjs'].includes(ext)) {
|
|
85
|
+
throw new DevError(
|
|
86
|
+
`${rel(path)}: the hot loader compiles .jsx files, and runs .js and ` +
|
|
87
|
+
`.mjs as Node does — ${ext || 'a file with no extension'} is ` +
|
|
88
|
+
'neither. TypeScript is not supported yet; rename the file to .jsx.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (process.env.NODE_ENV === 'production') {
|
|
92
|
+
throw new DevError(
|
|
93
|
+
"NODE_ENV is 'production', and react-refresh ships a no-op build " +
|
|
94
|
+
'for production, so nothing would reload. Unset NODE_ENV (or set ' +
|
|
95
|
+
"it to 'development') to run react-x11 dev.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (typeof nodeModule.registerHooks !== 'function') {
|
|
99
|
+
throw new DevError(
|
|
100
|
+
'hot reload needs Node.js 22.15 or newer, for module.registerHooks — ' +
|
|
101
|
+
`this is Node ${process.versions.node}. The app itself runs on ` +
|
|
102
|
+
'Node 20.19: `node` (or `npx tsx`) with your own entry file.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const theirs = reactX11For(path);
|
|
107
|
+
if (theirs === null) {
|
|
108
|
+
throw new DevError(
|
|
109
|
+
`${rel(path)} cannot import react-x11: it is not installed in the ` +
|
|
110
|
+
'project the file is in. Install it there:\n\n' +
|
|
111
|
+
' npm install react-x11 react\n',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (realpathSync(theirs) !== realpathSync(OWN_ROOT)) {
|
|
115
|
+
await handOver(theirs);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const projectDir = projectDirOf(dirname(path));
|
|
120
|
+
if ((await ensureToolchain({ projectDir, log })) === 'installed') {
|
|
121
|
+
restart();
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Before anything below loads: a module's source maps are read when it
|
|
126
|
+
// loads, and only once this is on.
|
|
127
|
+
process.setSourceMapsEnabled(true);
|
|
128
|
+
const { registerRefresh } = await import('../refresh/loader.js');
|
|
129
|
+
await registerRefresh();
|
|
130
|
+
// The runtime patches React DevTools' global hook and then hands the
|
|
131
|
+
// renderer to it, which has to happen before the first root commits.
|
|
132
|
+
const { default: Refresh } = await import('../refresh/index.js');
|
|
133
|
+
const { default: RefreshRuntime } = await import('react-refresh/runtime');
|
|
134
|
+
const { default: React } = await import('react');
|
|
135
|
+
const ReactX11 = await import('../index.js');
|
|
136
|
+
const { NOT_IN_WINDOW } = await import('../errors.js');
|
|
137
|
+
const { liveApps } = await import('../trace-registry.js');
|
|
138
|
+
|
|
139
|
+
// What `node file.jsx …args` would hand the app.
|
|
140
|
+
process.argv = [process.argv[0], path, ...args];
|
|
141
|
+
|
|
142
|
+
const appsBefore = liveApps().length;
|
|
143
|
+
const mod = await import(pathToFileURL(path).href);
|
|
144
|
+
const name = rel(path);
|
|
145
|
+
const compiled = ext === '.jsx';
|
|
146
|
+
|
|
147
|
+
// A module reloads in place only when everything it exports is a
|
|
148
|
+
// component (src/refresh/index.js, `moduleReady`); otherwise the edit goes
|
|
149
|
+
// up to its importer, which is this module, and stops there.
|
|
150
|
+
const notComponents = Object.keys(mod).filter(
|
|
151
|
+
(key) => !RefreshRuntime.isLikelyComponentType(mod[key]),
|
|
152
|
+
);
|
|
153
|
+
const boundary = Object.keys(mod).length > 0 && notComponents.length === 0;
|
|
154
|
+
|
|
155
|
+
if (liveApps().length > appsBefore) {
|
|
156
|
+
log(
|
|
157
|
+
`${name} mounted a root of its own, so it runs as it is: saving the ` +
|
|
158
|
+
`.jsx modules it imports reloads them, and ${name} itself needs a ` +
|
|
159
|
+
'restart.',
|
|
160
|
+
);
|
|
161
|
+
if (compiled && boundary) {
|
|
162
|
+
log(
|
|
163
|
+
`${name} also exports only components, so saving it re-runs it — ` +
|
|
164
|
+
'and its createRoot call, which opens another window. Leave the ' +
|
|
165
|
+
'mounting to react-x11 dev (delete the createRoot call), or guard ' +
|
|
166
|
+
'it: if (!import.meta.hot) { … }.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const App = mod.default;
|
|
173
|
+
const componentLike =
|
|
174
|
+
typeof App === 'function' ||
|
|
175
|
+
(typeof App === 'object' && App !== null && '$$typeof' in App);
|
|
176
|
+
if (!componentLike) {
|
|
177
|
+
throw new DevError(
|
|
178
|
+
`${name} has no component as its default export, and mounted no root ` +
|
|
179
|
+
'of its own, so there is nothing to show. Export the component:\n\n' +
|
|
180
|
+
' export default function App() { … }\n',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!compiled) {
|
|
184
|
+
log(
|
|
185
|
+
`${name} is not compiled by the hot loader (it compiles .jsx files), ` +
|
|
186
|
+
'so saving it needs a restart; the .jsx modules it imports reload.',
|
|
187
|
+
);
|
|
188
|
+
} else if (!boundary) {
|
|
189
|
+
const reasons = [];
|
|
190
|
+
if (notComponents.includes('default')) {
|
|
191
|
+
reasons.push(
|
|
192
|
+
'its default export is not a component to React Refresh, which ' +
|
|
193
|
+
'goes by a capitalized name — export default function App() { … }',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const others = notComponents.filter((key) => key !== 'default');
|
|
197
|
+
if (others.length > 0) {
|
|
198
|
+
reasons.push(
|
|
199
|
+
`${others.join(', ')} ${others.length === 1 ? 'is' : 'are'} not — ` +
|
|
200
|
+
'move what is not a component to a module of its own',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
log(
|
|
204
|
+
`saving ${name} will not reload it in place: a module does that only ` +
|
|
205
|
+
`when everything it exports is a component, and ${reasons.join('; ')}. ` +
|
|
206
|
+
'Until then, saving it needs a restart.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const title = `${basename(path)} — react-x11 dev`;
|
|
211
|
+
let wrapped = false;
|
|
212
|
+
let mixedNoted = false;
|
|
213
|
+
let root = null;
|
|
214
|
+
const tree = () => {
|
|
215
|
+
const app = React.createElement(App);
|
|
216
|
+
return wrapped
|
|
217
|
+
? React.createElement('window', { ...DEV_WINDOW, title }, app)
|
|
218
|
+
: app;
|
|
219
|
+
};
|
|
220
|
+
const render = () => root.render(tree());
|
|
221
|
+
const mounted = () => (root.app._rootChildren ?? []).length > 0;
|
|
222
|
+
|
|
223
|
+
root = await (createRoot ?? ReactX11.createRoot)({
|
|
224
|
+
onUncaughtError(error, info) {
|
|
225
|
+
if (error?.code === NOT_IN_WINDOW && !wrapped) {
|
|
226
|
+
wrapped = true;
|
|
227
|
+
log(
|
|
228
|
+
`${name} renders no <window>, so it is shown in one of react-x11 ` +
|
|
229
|
+
"dev's; the first <window> it renders replaces it.",
|
|
230
|
+
);
|
|
231
|
+
setImmediate(render);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (info?.handler) {
|
|
235
|
+
// from an event handler: React was not on the stack and nothing
|
|
236
|
+
// unmounted (src/errors.js, reportHandlerError)
|
|
237
|
+
console.error(
|
|
238
|
+
`react-x11 dev: ${info.handler} on ${info.element} threw; ` +
|
|
239
|
+
'dispatch continues.',
|
|
240
|
+
error,
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
console.error(
|
|
245
|
+
'react-x11 dev: uncaught error' + (info?.componentStack ?? ''),
|
|
246
|
+
error,
|
|
247
|
+
);
|
|
248
|
+
setImmediate(() => {
|
|
249
|
+
if (!mounted()) {
|
|
250
|
+
log(`fix it and save: ${name} is mounted again on the next reload.`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// After a reload has re-rendered the edited components: take the dev
|
|
257
|
+
// window away from a tree that now brings its own. The other direction
|
|
258
|
+
// needs nothing here — a tree that lost its window throws NOT_IN_WINDOW,
|
|
259
|
+
// and the handler above wraps it — and neither does a tree an error took
|
|
260
|
+
// down: react-refresh keeps the roots that failed, and renders them again
|
|
261
|
+
// on the next reload itself.
|
|
262
|
+
const settle = () => {
|
|
263
|
+
if (!wrapped || !mounted()) return;
|
|
264
|
+
const { windows, drawn } = rootContent(root.app._rootChildren[0]);
|
|
265
|
+
if (windows > 0 && drawn === 0) {
|
|
266
|
+
wrapped = false;
|
|
267
|
+
mixedNoted = false;
|
|
268
|
+
log(
|
|
269
|
+
`${name} renders its own <window> now, so it is mounted without ` +
|
|
270
|
+
"react-x11 dev's; that resets its state.",
|
|
271
|
+
);
|
|
272
|
+
render();
|
|
273
|
+
} else if (windows > 0 && !mixedNoted) {
|
|
274
|
+
mixedNoted = true;
|
|
275
|
+
log(
|
|
276
|
+
`${name} renders a <window> beside content that is in none, so ` +
|
|
277
|
+
"both stay in react-x11 dev's window. Put that content in a " +
|
|
278
|
+
'<window> of its own.',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
Refresh.onReload(({ urls, refreshed }) => {
|
|
283
|
+
if (refreshed) {
|
|
284
|
+
log(`reloaded ${urls.map((url) => rel(fileURLToPath(url))).join(', ')}`);
|
|
285
|
+
}
|
|
286
|
+
setImmediate(settle);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
render();
|
|
290
|
+
log(`${name} is running; saving it reloads in place. Ctrl+C stops it.`);
|
|
291
|
+
return root;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What the tree put inside the dev window, by the rule the root itself
|
|
296
|
+
* keeps: windows (popups among them), and anything drawn. A
|
|
297
|
+
* `<ThemeProvider>` is neither — inside a window it is a box that holds what
|
|
298
|
+
* it wraps — so its children are counted in its place.
|
|
299
|
+
*/
|
|
300
|
+
function rootContent(node) {
|
|
301
|
+
let windows = 0;
|
|
302
|
+
let drawn = 0;
|
|
303
|
+
for (const child of node?.children ?? []) {
|
|
304
|
+
if (child.isWindow) windows++;
|
|
305
|
+
else if (child._reactFiber?.type === THEME_SCOPE) {
|
|
306
|
+
const inner = rootContent(child);
|
|
307
|
+
windows += inner.windows;
|
|
308
|
+
drawn += inner.drawn;
|
|
309
|
+
} else drawn++;
|
|
310
|
+
}
|
|
311
|
+
return { windows, drawn };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The react-x11 package `path` imports, or null when it imports none. */
|
|
315
|
+
export function reactX11For(path) {
|
|
316
|
+
let entry;
|
|
317
|
+
try {
|
|
318
|
+
entry = nodeModule.createRequire(path).resolve('react-x11');
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
for (let dir = dirname(entry); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
323
|
+
const manifest = join(dir, 'package.json');
|
|
324
|
+
if (!existsSync(manifest)) continue;
|
|
325
|
+
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'react-x11') {
|
|
326
|
+
return dir;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Hand the command over to the project's own copy of react-x11, which reads
|
|
334
|
+
* the same `process.argv`.
|
|
335
|
+
*/
|
|
336
|
+
async function handOver(theirRoot) {
|
|
337
|
+
const pkg = JSON.parse(readFileSync(join(theirRoot, 'package.json'), 'utf8'));
|
|
338
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['react-x11'];
|
|
339
|
+
if (!bin) {
|
|
340
|
+
throw new DevError(
|
|
341
|
+
`this project's react-x11 is ${pkg.version} (${theirRoot}), which has ` +
|
|
342
|
+
`no react-x11 dev — it arrived in ${OWN_PACKAGE.version}. Update ` +
|
|
343
|
+
'it:\n\n npm install react-x11@latest\n',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
await import(pathToFileURL(join(theirRoot, bin)).href);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The nearest directory at or above `dir` with a package.json. */
|
|
350
|
+
function projectDirOf(dir) {
|
|
351
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
352
|
+
if (existsSync(join(at, 'package.json'))) return at;
|
|
353
|
+
if (dirname(at) === at) return dir;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const LOCKFILES = [
|
|
358
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
359
|
+
['yarn.lock', 'yarn'],
|
|
360
|
+
['bun.lock', 'bun'],
|
|
361
|
+
['bun.lockb', 'bun'],
|
|
362
|
+
['package-lock.json', 'npm'],
|
|
363
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The package manager a project is installed with: its lockfile, nearest
|
|
368
|
+
* first, then the one that launched this command (`npx`, `pnpm dlx`, `bunx`
|
|
369
|
+
* set `npm_config_user_agent`), then npm.
|
|
370
|
+
*/
|
|
371
|
+
export function packageManagerFor(
|
|
372
|
+
dir,
|
|
373
|
+
userAgent = process.env.npm_config_user_agent,
|
|
374
|
+
) {
|
|
375
|
+
for (let at = dir; ; at = dirname(at)) {
|
|
376
|
+
for (const [file, manager] of LOCKFILES) {
|
|
377
|
+
if (existsSync(join(at, file))) return manager;
|
|
378
|
+
}
|
|
379
|
+
if (dirname(at) === at) break;
|
|
380
|
+
}
|
|
381
|
+
return /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The command that adds `packages` as dev dependencies, as argv. */
|
|
385
|
+
export function installCommand(manager, packages) {
|
|
386
|
+
switch (manager) {
|
|
387
|
+
case 'pnpm':
|
|
388
|
+
return ['pnpm', 'add', '--save-dev', ...packages];
|
|
389
|
+
case 'yarn':
|
|
390
|
+
return ['yarn', 'add', '--dev', ...packages];
|
|
391
|
+
case 'bun':
|
|
392
|
+
return ['bun', 'add', '--dev', ...packages];
|
|
393
|
+
default:
|
|
394
|
+
return ['npm', 'install', '--save-dev', ...packages];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** `argv` as one line a POSIX shell (or cmd.exe) reads back the same. */
|
|
399
|
+
function shellLine(argv) {
|
|
400
|
+
const quote = (arg) =>
|
|
401
|
+
/^[\w@./:=-]+$/.test(arg)
|
|
402
|
+
? arg
|
|
403
|
+
: process.platform === 'win32'
|
|
404
|
+
? `"${arg}"`
|
|
405
|
+
: `'${arg}'`;
|
|
406
|
+
return argv.map(quote).join(' ');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Make sure the hot-reload toolchain resolves from this copy of react-x11,
|
|
411
|
+
* which is where the loader imports it from. `'ready'` when it does,
|
|
412
|
+
* `'installed'` when it was just installed (the caller restarts: a package
|
|
413
|
+
* manager may have moved react-x11 itself to satisfy the new peers), and a
|
|
414
|
+
* DevError carrying the command when it is missing and was not installed.
|
|
415
|
+
*
|
|
416
|
+
* Every way out of this is a seam for the tests: `resolveSpecifier`,
|
|
417
|
+
* `interactive`, `ask` and `run`.
|
|
418
|
+
*/
|
|
419
|
+
export async function ensureToolchain({
|
|
420
|
+
projectDir,
|
|
421
|
+
log,
|
|
422
|
+
resolveSpecifier = (specifier) => import.meta.resolve(specifier),
|
|
423
|
+
interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
424
|
+
ask = askOnTerminal,
|
|
425
|
+
run = (argv, cwd) =>
|
|
426
|
+
spawnSync(argv[0], argv.slice(1), {
|
|
427
|
+
cwd,
|
|
428
|
+
stdio: 'inherit',
|
|
429
|
+
// npm and friends are .cmd scripts there, which only a shell runs
|
|
430
|
+
shell: process.platform === 'win32',
|
|
431
|
+
}).status,
|
|
432
|
+
}) {
|
|
433
|
+
const { TOOLCHAIN } = await import('../refresh/loader.js');
|
|
434
|
+
const missing = TOOLCHAIN.filter(({ specifier }) => {
|
|
435
|
+
try {
|
|
436
|
+
resolveSpecifier(specifier);
|
|
437
|
+
return false;
|
|
438
|
+
} catch {
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
if (missing.length === 0) return 'ready';
|
|
443
|
+
|
|
444
|
+
// The versions the loader is written against, from react-x11's own
|
|
445
|
+
// peerDependencies: an unpinned install takes whatever major is newest.
|
|
446
|
+
const packages = missing.map(
|
|
447
|
+
({ name }) => `${name}@${OWN_PACKAGE.peerDependencies[name]}`,
|
|
448
|
+
);
|
|
449
|
+
const argv = installCommand(packageManagerFor(projectDir), packages);
|
|
450
|
+
const line = shellLine(argv);
|
|
451
|
+
const what =
|
|
452
|
+
`hot reload needs ${missing.length === 1 ? 'a package' : 'packages'} ` +
|
|
453
|
+
`this project does not have: ${missing.map((m) => m.name).join(', ')}. ` +
|
|
454
|
+
'They are optional peer dependencies of react-x11, so an app that never ' +
|
|
455
|
+
'hot-reloads does not ship them to its users.';
|
|
456
|
+
if (!interactive) {
|
|
457
|
+
throw new DevError(
|
|
458
|
+
`${what} Install them as dev dependencies:\n\n ${line}\n`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
log(what);
|
|
462
|
+
const answer = (await ask(`Run \`${line}\` in ${projectDir}? [Y/n] `))
|
|
463
|
+
.trim()
|
|
464
|
+
.toLowerCase();
|
|
465
|
+
if (answer !== '' && answer !== 'y' && answer !== 'yes') {
|
|
466
|
+
throw new DevError(
|
|
467
|
+
`not installed. To install them yourself:\n\n ${line}\n`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const status = run(argv, projectDir);
|
|
471
|
+
if (status !== 0) {
|
|
472
|
+
throw new DevError(
|
|
473
|
+
`\`${line}\` failed${status == null ? '' : ` (exit ${status})`}; its ` +
|
|
474
|
+
'output is above.',
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return 'installed';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function askOnTerminal(question) {
|
|
481
|
+
const terminal = createInterface({
|
|
482
|
+
input: process.stdin,
|
|
483
|
+
output: process.stderr,
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
return await terminal.question(question);
|
|
487
|
+
} finally {
|
|
488
|
+
terminal.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Start this command again, once the toolchain is installed: the new process
|
|
494
|
+
* resolves the packages, and react-x11 itself, from scratch.
|
|
495
|
+
*/
|
|
496
|
+
function restart() {
|
|
497
|
+
const { status, signal } = spawnSync(
|
|
498
|
+
process.execPath,
|
|
499
|
+
[...process.execArgv, ...process.argv.slice(1)],
|
|
500
|
+
{ stdio: 'inherit' },
|
|
501
|
+
);
|
|
502
|
+
process.exitCode = status ?? (signal ? 1 : 0);
|
|
503
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// react-x11's command line, the `bin` of the package. One command so far:
|
|
3
|
+
//
|
|
4
|
+
// react-x11 dev <file> [args…] hot reload with nothing to set up (./dev.js)
|
|
5
|
+
//
|
|
6
|
+
// Arguments before the file belong to the command, and everything after it
|
|
7
|
+
// to the app, which sees them in process.argv as `node <file> …args` would
|
|
8
|
+
// have handed them over.
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { DevError, runDev } from './dev.js';
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: react-x11 dev <file> [args…]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
dev <file> Run <file> with hot reload: saving it, or a .jsx module it
|
|
16
|
+
imports, updates the running app in place. Its default
|
|
17
|
+
export is mounted, inside a window of its own when it
|
|
18
|
+
renders none; a file that mounts its own root runs as it is.
|
|
19
|
+
Arguments after the file are the app's.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
-h, --help Show this help.
|
|
23
|
+
-v, --version Print react-x11's version.
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
function version() {
|
|
27
|
+
const manifest = new URL('../../package.json', import.meta.url);
|
|
28
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).version;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An exit code to finish with, or undefined while the app runs. */
|
|
32
|
+
async function main([command, ...rest]) {
|
|
33
|
+
if (command === '-h' || command === '--help' || command === 'help') {
|
|
34
|
+
process.stdout.write(USAGE);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (command === '-v' || command === '--version') {
|
|
38
|
+
process.stdout.write(`${version()}\n`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (command === undefined) {
|
|
42
|
+
process.stderr.write(USAGE);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
if (command !== 'dev') {
|
|
46
|
+
process.stderr.write(`react-x11: no command "${command}".\n\n${USAGE}`);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const at = rest.findIndex((arg) => !arg.startsWith('-'));
|
|
50
|
+
const own = at === -1 ? rest : rest.slice(0, at);
|
|
51
|
+
if (own.includes('-h') || own.includes('--help')) {
|
|
52
|
+
process.stdout.write(USAGE);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (own.length > 0) {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`react-x11 dev: no option ${own[0]} — options after the file are ` +
|
|
58
|
+
`the app's.\n\n${USAGE}`,
|
|
59
|
+
);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
if (at === -1) {
|
|
63
|
+
process.stderr.write(`react-x11 dev: which file?\n\n${USAGE}`);
|
|
64
|
+
return 2;
|
|
65
|
+
}
|
|
66
|
+
await runDev(rest[at], { args: rest.slice(at + 1) });
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const code = await main(process.argv.slice(2));
|
|
72
|
+
if (code !== undefined) process.exitCode = code;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// A DevError is a sentence about the setup, and its stack is ours; any
|
|
75
|
+
// other throw came out of the app's own module, and its stack is the
|
|
76
|
+
// useful part.
|
|
77
|
+
console.error(
|
|
78
|
+
err instanceof DevError ? `react-x11 dev: ${err.message}` : err,
|
|
79
|
+
);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
package/src/cocoa/app.js
CHANGED
|
@@ -71,6 +71,9 @@ const PUMP_INTERVAL_MS = 8;
|
|
|
71
71
|
// to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
|
|
72
72
|
// sets it; 0 is no handshake at all.
|
|
73
73
|
const RESIZE_WAIT_MS = 50;
|
|
74
|
+
// What `createRoot({ cocoa: { paneStretch } })` and
|
|
75
|
+
// REACT_X11_COCOA_PANE_STRETCH take (src/cocoa/panehost.js, `_stretch`).
|
|
76
|
+
const PANE_STRETCH = ['behind', 'never', 'always'];
|
|
74
77
|
// How often the screen layout is re-read while something is watching it, in
|
|
75
78
|
// ms. There is no event to wait for — the bridge keeps its `NSScreen` copy
|
|
76
79
|
// current on macOS's notification and emits nothing (#617) — so
|
|
@@ -149,6 +152,24 @@ export class CocoaApp {
|
|
|
149
152
|
);
|
|
150
153
|
}
|
|
151
154
|
this._resizeWait = resizeWait;
|
|
155
|
+
// How a `<Frame>` pane's last frame is shown while the frame of the
|
|
156
|
+
// size its layer was just given is on its way (src/cocoa/panehost.js,
|
|
157
|
+
// `_stretch`): 'behind' stretches it across once the pane falls behind
|
|
158
|
+
// `resizeWait`, 'never' keeps it at its size however late the pane is,
|
|
159
|
+
// and 'always' stretches it both ways. Apart from `resizeWait`, which
|
|
160
|
+
// is also how long AppKit holds the window's edge for a frame, so a
|
|
161
|
+
// pane that is never to be stretched would otherwise need a budget
|
|
162
|
+
// that held the drag back as long.
|
|
163
|
+
const paneStretch =
|
|
164
|
+
options.cocoa?.paneStretch ??
|
|
165
|
+
(process.env.REACT_X11_COCOA_PANE_STRETCH || 'behind');
|
|
166
|
+
if (!PANE_STRETCH.includes(paneStretch)) {
|
|
167
|
+
throw new TypeError(
|
|
168
|
+
'react-x11: cocoa.paneStretch (REACT_X11_COCOA_PANE_STRETCH) is ' +
|
|
169
|
+
`one of ${PANE_STRETCH.join(', ')} — got ${paneStretch}.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
this._paneStretch = paneStretch;
|
|
152
173
|
|
|
153
174
|
// The activation policy has to be fixed before the app finishes
|
|
154
175
|
// launching — a Regular launch registers a Dock tile, so an agent app
|
|
@@ -157,7 +178,17 @@ export class CocoaApp {
|
|
|
157
178
|
// policy goes first, through `initApp`'s option (bridge >= 0.5), and
|
|
158
179
|
// only when the root asked for one: a bridge without the option, or a
|
|
159
180
|
// fake, is left alone.
|
|
160
|
-
|
|
181
|
+
//
|
|
182
|
+
// A `<Frame>` pane asks for one without being told: 'prohibited'. Its
|
|
183
|
+
// pixels are the host's to show, it makes no NSWindow (`createWindow`)
|
|
184
|
+
// and it runs no NSApplication, so nothing of it may be an app the user
|
|
185
|
+
// sees — and launched Regular, the bridge's default, every pane was a
|
|
186
|
+
// `node` tile of its own in the Dock and in ⌘-Tab, one per tab of a
|
|
187
|
+
// browser. Prohibited rather than accessory because a pane must not be
|
|
188
|
+
// activated either: the host window has the focus and forwards it.
|
|
189
|
+
const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
|
|
190
|
+
const policy =
|
|
191
|
+
options.cocoa?.activationPolicy ?? (pane ? 'prohibited' : undefined);
|
|
161
192
|
// Under the launcher (src/cocoa/main.js) the app launched before this
|
|
162
193
|
// code ran, so the policy is a switch after the fact, and a Regular
|
|
163
194
|
// launch has already registered its Dock tile. The bridge publishes
|
|
@@ -236,7 +267,6 @@ export class CocoaApp {
|
|
|
236
267
|
// here then (src/cocoa/paneplayer.js); one that does not leaves a pane
|
|
237
268
|
// refusing `src`, its poster shown and `onError` told.
|
|
238
269
|
this._players = new Map();
|
|
239
|
-
const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
|
|
240
270
|
if (canPlay(native) && !pane) {
|
|
241
271
|
this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
|
|
242
272
|
}
|
|
@@ -367,7 +397,7 @@ export class CocoaApp {
|
|
|
367
397
|
|
|
368
398
|
// Frame-pane mode: this process renders a pane whose pixels a host
|
|
369
399
|
// composites (REACT_X11_FRAME is what the Frame host sets on the fork).
|
|
370
|
-
this._paneMode =
|
|
400
|
+
this._paneMode = pane;
|
|
371
401
|
this._paneSend = null;
|
|
372
402
|
// the pane's own window, the one the host lays out and forwards to
|
|
373
403
|
this._paneWindow = null;
|
package/src/cocoa/dnd.js
CHANGED
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
// the pasteboard, so an in-app drop still gets `e.items` by reference and
|
|
35
35
|
// `e.source === 'internal'`, the contract the X11 transport keeps. Other
|
|
36
36
|
// applications read the pasteboard, where a thunk is a promise the bridge
|
|
37
|
-
// asks `provide` to keep.
|
|
37
|
+
// asks `provide` to keep — on the main thread. On a worker the bridge takes
|
|
38
|
+
// no `provide`, and a thunk resolves as the drag begins (`dragSpec`).
|
|
38
39
|
//
|
|
39
40
|
// ## Types
|
|
40
41
|
//
|
|
@@ -223,6 +224,16 @@ export const wire = (value) =>
|
|
|
223
224
|
* to keep. The files thunk alone resolves now, because the items cannot
|
|
224
225
|
* be counted without it.
|
|
225
226
|
*
|
|
227
|
+
* Unless the app is on a worker (`upFront`, threaded mode): AppKit asks a
|
|
228
|
+
* provider from inside its own callback on the main thread, which a worker
|
|
229
|
+
* cannot answer in time, so the bridge refuses `provide` there with a
|
|
230
|
+
* TypeError. That was every drag on a Mac, since an app runs on a worker
|
|
231
|
+
* by default. There every thunk resolves now, as the win32 backend has
|
|
232
|
+
* them, and this is the external promotion `_resolve` caches for: an
|
|
233
|
+
* in-app drop is handed the same value by reference. A value that resolves
|
|
234
|
+
* to nothing, or that JSON cannot carry (a cycle, a node), is left off
|
|
235
|
+
* the pasteboard rather than failing the drag; an in-app drop still has it.
|
|
236
|
+
*
|
|
226
237
|
* No image, and so no slide-back. The picture of the drag is the app's own
|
|
227
238
|
* `<popup dragPreview>`; what the system session carries is a blank 1×1.
|
|
228
239
|
* Left at AppKit's default, a refused or cancelled drop plays
|
|
@@ -236,7 +247,7 @@ export const wire = (value) =>
|
|
|
236
247
|
* to animate from `onDragEnd`. Not a seam: there is no system image for
|
|
237
248
|
* a caller to want slid back.
|
|
238
249
|
*/
|
|
239
|
-
export function dragSpec(session, native, scale) {
|
|
250
|
+
export function dragSpec(session, native, scale, { upFront = false } = {}) {
|
|
240
251
|
const types = session.types;
|
|
241
252
|
const reverse = new Map(); // uti -> react-x11 type
|
|
242
253
|
const items = [];
|
|
@@ -256,7 +267,17 @@ export function dragSpec(session, native, scale) {
|
|
|
256
267
|
if (!uti || uti in first) continue;
|
|
257
268
|
reverse.set(uti, type);
|
|
258
269
|
const raw = session._data[type];
|
|
259
|
-
|
|
270
|
+
if (!upFront) {
|
|
271
|
+
first[uti] = typeof raw === 'function' ? null : wire(raw);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const value = session._resolve(type);
|
|
275
|
+
if (value == null) continue;
|
|
276
|
+
try {
|
|
277
|
+
first[uti] = wire(value);
|
|
278
|
+
} catch {
|
|
279
|
+
// not something the pasteboard can carry; the in-app drop has it
|
|
280
|
+
}
|
|
260
281
|
}
|
|
261
282
|
if (Object.keys(first).length) {
|
|
262
283
|
if (items.length) Object.assign(items[0], first);
|
|
@@ -269,7 +290,11 @@ export function dragSpec(session, native, scale) {
|
|
|
269
290
|
items,
|
|
270
291
|
operations: session.actions,
|
|
271
292
|
slideBack: false,
|
|
272
|
-
|
|
293
|
+
...(upFront
|
|
294
|
+
? null
|
|
295
|
+
: {
|
|
296
|
+
provide: (uti) => wire(session._resolve(reverse.get(uti) ?? uti)),
|
|
297
|
+
}),
|
|
273
298
|
};
|
|
274
299
|
}
|
|
275
300
|
|
package/src/cocoa/panehost.js
CHANGED
|
@@ -91,7 +91,8 @@ class PaneLayer {
|
|
|
91
91
|
* and a smeared strip. The budget is the window's live-resize
|
|
92
92
|
* handshake's (`createRoot({ cocoa: { resizeWait } })`), how long a frame
|
|
93
93
|
* of a new size may take before the old one is shown in its place: 0
|
|
94
|
-
* stretches always, both ways, as before.
|
|
94
|
+
* stretches always, both ways, as before. And the app may say otherwise
|
|
95
|
+
* whatever the budget (`_stretch`).
|
|
95
96
|
*
|
|
96
97
|
* Down the layer the frame is at its size whatever this says. A width
|
|
97
98
|
* moves where a page's lines break, and what comes is a page laid out
|
|
@@ -116,8 +117,9 @@ class PaneLayer {
|
|
|
116
117
|
* decides the rest (`_answered`).
|
|
117
118
|
*/
|
|
118
119
|
_anchoredNow() {
|
|
120
|
+
const stretch = this._stretch();
|
|
121
|
+
if (stretch !== 'behind') return stretch === 'never';
|
|
119
122
|
const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
|
|
120
|
-
if (!(budget > 0)) return false;
|
|
121
123
|
// a size asked longer ago than the budget, and no frame of any since
|
|
122
124
|
const oldest = this._asked[0];
|
|
123
125
|
if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
|
|
@@ -126,6 +128,23 @@ class PaneLayer {
|
|
|
126
128
|
return !this._behind;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* When the frame the layer holds is stretched to bounds that are not its
|
|
133
|
+
* size: `'behind'`, across, once the pane falls behind the budget
|
|
134
|
+
* (`_anchoredNow`); `'never'`, kept at its size and its edge carried over
|
|
135
|
+
* however late the pane is; `'always'`, both ways, Core Animation's own.
|
|
136
|
+
* The app's `cocoa.paneStretch` (REACT_X11_COCOA_PANE_STRETCH), and
|
|
137
|
+
* `'behind'` where it names none, or `'always'` at a budget of 0, which
|
|
138
|
+
* waits for no frame. An app that says `'never'` was not served by the
|
|
139
|
+
* budget: as long as AppKit holds the window's edge for a frame, so a
|
|
140
|
+
* budget a slow page never misses held every step of the drag back.
|
|
141
|
+
*/
|
|
142
|
+
_stretch() {
|
|
143
|
+
const stretch = this.app._paneStretch ?? 'behind';
|
|
144
|
+
if (stretch !== 'behind') return stretch;
|
|
145
|
+
return (this.app._resizeWait ?? RESIZE_WAIT_MS) > 0 ? 'behind' : 'always';
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
/**
|
|
130
149
|
* The props that show the frame the layer holds in bounds that are not
|
|
131
150
|
* its size, where they changed. At its size and from the top left, as a
|
|
@@ -138,7 +157,8 @@ class PaneLayer {
|
|
|
138
157
|
* the frame is stretched, and down it is still at its size
|
|
139
158
|
* (`_anchoredNow`): the whole of each row stretched to the new width,
|
|
140
159
|
* cropped or with its last row carried down. Both rects are the whole
|
|
141
|
-
* when the frame is the layer's size, and
|
|
160
|
+
* when the frame is the layer's size, and where it is stretched always
|
|
161
|
+
* (`_stretch`).
|
|
142
162
|
*
|
|
143
163
|
* Anchored by gravity alone, the strip the frame did not cover showed
|
|
144
164
|
* whatever was under the pane, which is the `<Frame>`'s background: in
|
|
@@ -159,8 +179,7 @@ class PaneLayer {
|
|
|
159
179
|
const rect = this._rect;
|
|
160
180
|
let crop = null;
|
|
161
181
|
let edge = null;
|
|
162
|
-
|
|
163
|
-
if (frame && rect && budget > 0) {
|
|
182
|
+
if (frame && rect && this._stretch() !== 'always') {
|
|
164
183
|
// across at its size while the pane keeps up, and down always; an
|
|
165
184
|
// axis stretched is shown whole, the frame's every column across
|
|
166
185
|
const across = this._anchoredNow();
|
package/src/cocoa/window.js
CHANGED
|
@@ -520,7 +520,9 @@ export class CocoaWindow {
|
|
|
520
520
|
if (this.destroyed) return null;
|
|
521
521
|
const began = this._native.beginDrag(
|
|
522
522
|
this._h,
|
|
523
|
-
dragSpec(session, this._native, this.scale
|
|
523
|
+
dragSpec(session, this._native, this.scale, {
|
|
524
|
+
upFront: this.app._threaded,
|
|
525
|
+
}),
|
|
524
526
|
);
|
|
525
527
|
this.app._afterInput();
|
|
526
528
|
return began;
|
package/src/errors.js
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
// `data` handler and takes the process with it, or wedges the frame loop,
|
|
7
7
|
// which is worse: it looks like a hang rather than a crash.
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The `code` of the error a drawn element at the root of the tree throws
|
|
11
|
+
* (Reconciler.js, `notAtRoot`). A code because a tool acts on it: `react-x11
|
|
12
|
+
* dev` mounts a component that renders no `<window>` inside one of its own,
|
|
13
|
+
* and the message is prose that is free to change.
|
|
14
|
+
*/
|
|
15
|
+
export const NOT_IN_WINDOW = 'NOT_IN_WINDOW';
|
|
16
|
+
|
|
9
17
|
// container -> the root's onUncaughtError, when it set one
|
|
10
18
|
const perContainer = new WeakMap();
|
|
11
19
|
|
package/src/index.d.ts
CHANGED
|
@@ -306,6 +306,11 @@ export interface RootOptions {
|
|
|
306
306
|
* the next comes, and past it, it is stretched across to the pane's new
|
|
307
307
|
* width and stays at its size down it; at 0 every one is stretched both
|
|
308
308
|
* ways.
|
|
309
|
+
* `paneStretch` says when a `<Frame>` pane's last frame is stretched
|
|
310
|
+
* instead, whatever `resizeWait` is: `'behind'` (the default) as above,
|
|
311
|
+
* `'never'` keeps it at its size however late the pane's next frame is,
|
|
312
|
+
* and `'always'` stretches it both ways. `REACT_X11_COCOA_PANE_STRETCH`
|
|
313
|
+
* says the same from the environment.
|
|
309
314
|
* `screenPoll` is how often the screen layout is re-read while a
|
|
310
315
|
* {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
|
|
311
316
|
* never. macOS has no event for a display plugged in, unplugged or
|
|
@@ -333,6 +338,7 @@ export interface RootOptions {
|
|
|
333
338
|
frameInterval?: number;
|
|
334
339
|
pumpInterval?: number;
|
|
335
340
|
resizeWait?: number;
|
|
341
|
+
paneStretch?: 'behind' | 'never' | 'always';
|
|
336
342
|
screenPoll?: number;
|
|
337
343
|
appName?: string;
|
|
338
344
|
activationPolicy?: 'regular' | 'accessory' | 'prohibited';
|
package/src/refresh/loader.js
CHANGED
|
@@ -31,11 +31,22 @@ import { markHotReloadSession } from '../registry.js';
|
|
|
31
31
|
const RUNTIME_URL = new URL('./index.js', import.meta.url).href;
|
|
32
32
|
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url));
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The hot-reload toolchain: each specifier this loader imports, and the
|
|
36
|
+
* package it comes from. Optional peer dependencies, so `react-x11 dev`
|
|
37
|
+
* checks the same list before it starts (src/cli/dev.js).
|
|
38
|
+
*/
|
|
39
|
+
export const TOOLCHAIN = [
|
|
40
|
+
{ specifier: '@babel/core', name: '@babel/core' },
|
|
41
|
+
{
|
|
42
|
+
specifier: '@babel/plugin-transform-react-jsx',
|
|
43
|
+
name: '@babel/plugin-transform-react-jsx',
|
|
44
|
+
},
|
|
45
|
+
{ specifier: 'react-refresh/babel', name: 'react-refresh' },
|
|
46
|
+
{
|
|
47
|
+
specifier: 'hot-module-replacement/register',
|
|
48
|
+
name: 'hot-module-replacement',
|
|
49
|
+
},
|
|
39
50
|
];
|
|
40
51
|
|
|
41
52
|
async function loadToolchain() {
|
|
@@ -54,13 +65,22 @@ async function loadToolchain() {
|
|
|
54
65
|
'@babel/plugin-transform-react-jsx',
|
|
55
66
|
);
|
|
56
67
|
const refreshTransform = await load('react-refresh/babel', 'react-refresh');
|
|
68
|
+
// Resolved rather than imported: importing it registers its hooks, which
|
|
69
|
+
// has to wait until ours are in. Checked here all the same, so a missing
|
|
70
|
+
// one is this message and not a bare ERR_MODULE_NOT_FOUND after the
|
|
71
|
+
// babel hooks already went in.
|
|
72
|
+
try {
|
|
73
|
+
import.meta.resolve('hot-module-replacement/register');
|
|
74
|
+
} catch {
|
|
75
|
+
missing.push('hot-module-replacement');
|
|
76
|
+
}
|
|
57
77
|
if (missing.length > 0) {
|
|
58
78
|
throw new Error(
|
|
59
79
|
`react-x11/refresh: the hot-reload toolchain is not installed ` +
|
|
60
80
|
`(missing: ${missing.join(', ')}). It is a set of optional peer ` +
|
|
61
81
|
`dependencies — an app that never hot-reloads does not carry them. ` +
|
|
62
82
|
`Install them where the dev loop runs:\n\n` +
|
|
63
|
-
` npm install --save-dev ${TOOLCHAIN.join(' ')}\n`,
|
|
83
|
+
` npm install --save-dev ${TOOLCHAIN.map((t) => t.name).join(' ')}\n`,
|
|
64
84
|
);
|
|
65
85
|
}
|
|
66
86
|
return {
|
package/src/refresh/register.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// react-x11/refresh/register — hot reload with the defaults, for --import:
|
|
2
2
|
//
|
|
3
|
-
// node --enable-source-maps --import react-x11/refresh/register
|
|
3
|
+
// node --enable-source-maps --import react-x11/refresh/register main.jsx
|
|
4
4
|
//
|
|
5
|
-
// The app
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// The app writes no wiring of its own: the loader injects the runtime into
|
|
6
|
+
// every hot module, edits to components apply in place with hook state
|
|
7
|
+
// intact, and `onReload` (from react-x11/refresh) is there when a tool wants
|
|
8
|
+
// to observe the reloads. What the app does owe is a split: main.jsx is
|
|
9
|
+
// never watched — hot-module-replacement watches a module when another
|
|
10
|
+
// imports it — so the components it mounts live in modules it imports, and
|
|
11
|
+
// an app in one file never reloads. A tool that needs the seams —
|
|
9
12
|
// extensions, ignore, an injected prelude — writes its own two-line
|
|
10
13
|
// --import module calling registerRefresh(options) instead.
|
|
11
14
|
import { registerRefresh } from './loader.js';
|