react-x11 2.45.3 → 2.47.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/package.json +5 -1
- package/src/backend/context2d.js +176 -15
- package/src/backend/filter.js +157 -12
- package/src/cocoa/relaunch.js +98 -12
- package/src/frame/child.d.ts +9 -0
- package/src/frame/childmain.js +12 -0
- package/src/frame/index.js +172 -14
- package/src/frame/protocol.js +6 -2
- package/src/node.d.ts +5 -4
- package/src/types/frame.d.ts +39 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.47.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -221,6 +221,10 @@
|
|
|
221
221
|
"./cocoa-main": {
|
|
222
222
|
"types": "./src/cocoa/main.d.ts",
|
|
223
223
|
"default": "./src/cocoa/main.js"
|
|
224
|
+
},
|
|
225
|
+
"./frame/child": {
|
|
226
|
+
"types": "./src/frame/child.d.ts",
|
|
227
|
+
"default": "./src/frame/child.js"
|
|
224
228
|
}
|
|
225
229
|
},
|
|
226
230
|
"types": "./src/index.d.ts"
|
package/src/backend/context2d.js
CHANGED
|
@@ -28,6 +28,7 @@ import { cssColorStraight } from 'ntk/color';
|
|
|
28
28
|
import { normalizeRadii, planShadowTiles } from 'ntk/shadow-tiles';
|
|
29
29
|
|
|
30
30
|
import {
|
|
31
|
+
blurPixels,
|
|
31
32
|
filterColour,
|
|
32
33
|
filterPixels,
|
|
33
34
|
filterStops,
|
|
@@ -183,9 +184,10 @@ function warnUndrawable(image) {
|
|
|
183
184
|
);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
/** A canvas gradient's stops, which `fillStyle` takes: `LinearGradient`
|
|
188
|
+
* and `RadialGradient` are where they run. */
|
|
189
|
+
class Gradient {
|
|
190
|
+
constructor() {
|
|
189
191
|
this._stops = [];
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -194,6 +196,25 @@ class LinearGradient {
|
|
|
194
196
|
this._stops.push(offset, r, g, b, a);
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
/** The stops as `[offset, r, g, b, a]`, in order of offset, two at
|
|
200
|
+
* least: one stop is a colour from end to end. */
|
|
201
|
+
_sorted() {
|
|
202
|
+
const stops = [];
|
|
203
|
+
for (let i = 0; i + 4 < this._stops.length; i += 5) {
|
|
204
|
+
stops.push(this._stops.slice(i, i + 5));
|
|
205
|
+
}
|
|
206
|
+
stops.sort((p, q) => p[0] - q[0]);
|
|
207
|
+
if (stops.length === 1) stops.push([...stops[0]]);
|
|
208
|
+
return stops;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
class LinearGradient extends Gradient {
|
|
213
|
+
constructor(x0, y0, x1, y1) {
|
|
214
|
+
super();
|
|
215
|
+
this._coords = [x0, y0, x1, y1];
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
/**
|
|
198
219
|
* CoreGraphics requires stop locations inside [0, 1]; the decorations
|
|
199
220
|
* parser deliberately pads a gradient's line past both ends (its end
|
|
@@ -203,13 +224,8 @@ class LinearGradient {
|
|
|
203
224
|
* remaps into [0, 1].
|
|
204
225
|
*/
|
|
205
226
|
_normalized() {
|
|
206
|
-
const stops =
|
|
207
|
-
for (let i = 0; i + 4 < this._stops.length; i += 5) {
|
|
208
|
-
stops.push(this._stops.slice(i, i + 5));
|
|
209
|
-
}
|
|
210
|
-
stops.sort((p, q) => p[0] - q[0]);
|
|
227
|
+
const stops = this._sorted();
|
|
211
228
|
if (stops.length === 0) return { coords: this._coords, flat: [] };
|
|
212
|
-
if (stops.length === 1) stops.push([...stops[0]]);
|
|
213
229
|
const min = Math.min(0, stops[0][0]);
|
|
214
230
|
const max = Math.max(1, stops[stops.length - 1][0]);
|
|
215
231
|
let [x0, y0, x1, y1] = this._coords;
|
|
@@ -230,6 +246,35 @@ class LinearGradient {
|
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Canvas's radial gradient: the colours spread from the start circle to
|
|
251
|
+
* the end one (@windowkit/appkit's `ctxFillRadialGradient`, whose
|
|
252
|
+
* CoreGraphics gradient is canvas's two-circle one). Canvas keeps every
|
|
253
|
+
* stop's offset in [0, 1], so they are only put in order.
|
|
254
|
+
*/
|
|
255
|
+
class RadialGradient extends Gradient {
|
|
256
|
+
constructor(x0, y0, r0, x1, y1, r1) {
|
|
257
|
+
super();
|
|
258
|
+
this._circles = [x0, y0, r0, x1, y1, r1];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_normalized() {
|
|
262
|
+
const stops = this._sorted();
|
|
263
|
+
for (const stop of stops) stop[0] = Math.min(1, Math.max(0, stop[0]));
|
|
264
|
+
return { circles: this._circles, flat: stops.flat() };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `createRadialGradient` on a bridge with no radial fill: a linear gradient
|
|
270
|
+
* along no line, which paints flat, as every radial gradient did before
|
|
271
|
+
* the bridge had one. It takes no circles, which is how a caller tells it
|
|
272
|
+
* from the one that draws them (`<Html>` asks `createRadialGradient.length`).
|
|
273
|
+
*/
|
|
274
|
+
function flatRadialGradient() {
|
|
275
|
+
return new LinearGradient(0, 0, 0, 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
233
278
|
const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
|
|
234
279
|
|
|
235
280
|
// What `textAlign`, `textBaseline` and `direction` take. A value outside
|
|
@@ -431,6 +476,10 @@ function meets(a, b) {
|
|
|
431
476
|
);
|
|
432
477
|
}
|
|
433
478
|
|
|
479
|
+
/** How many pixels across a blur's standard deviation is kept as the
|
|
480
|
+
* source is scaled down for it (`_drawBlurred`). */
|
|
481
|
+
const BLUR_SCALE = 2;
|
|
482
|
+
|
|
434
483
|
/** The qualities `imageSmoothingQuality` takes, canvas's. */
|
|
435
484
|
const SMOOTHING_QUALITIES = new Set(['low', 'medium', 'high']);
|
|
436
485
|
|
|
@@ -525,6 +574,15 @@ export class BackendContext2D {
|
|
|
525
574
|
this._filters = ['createSurface', 'ctxGetImageData', 'ctxPutImageData']
|
|
526
575
|
.map((name) => name in native && typeof native[name] === 'function')
|
|
527
576
|
.every(Boolean);
|
|
577
|
+
// and a radial gradient's fill, @windowkit/appkit 0.28.0's: where the
|
|
578
|
+
// bridge has none, the flat stand-in it always had, which takes no
|
|
579
|
+
// circles — the one a caller asks for, and keys on
|
|
580
|
+
if (!(
|
|
581
|
+
'ctxFillRadialGradient' in native &&
|
|
582
|
+
typeof native.ctxFillRadialGradient === 'function'
|
|
583
|
+
)) {
|
|
584
|
+
this.createRadialGradient = flatRadialGradient;
|
|
585
|
+
}
|
|
528
586
|
this._onDirty = null;
|
|
529
587
|
// the recorded path, and whether the native one still matches it (a
|
|
530
588
|
// chunked stroke leaves only its last chunk behind)
|
|
@@ -1330,9 +1388,13 @@ export class BackendContext2D {
|
|
|
1330
1388
|
return new LinearGradient(x0, y0, x1, y1);
|
|
1331
1389
|
}
|
|
1332
1390
|
|
|
1333
|
-
createRadialGradient() {
|
|
1334
|
-
|
|
1335
|
-
|
|
1391
|
+
createRadialGradient(x0, y0, r0, x1, y1, r1) {
|
|
1392
|
+
if (r0 < 0 || r1 < 0) {
|
|
1393
|
+
throw new RangeError(
|
|
1394
|
+
"Failed to execute 'createRadialGradient': a radius is negative.",
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
return new RadialGradient(x0, y0, r0, x1, y1, r1);
|
|
1336
1398
|
}
|
|
1337
1399
|
|
|
1338
1400
|
_applyFill() {
|
|
@@ -1452,7 +1514,14 @@ export class BackendContext2D {
|
|
|
1452
1514
|
_fillPath(rule) {
|
|
1453
1515
|
this._path();
|
|
1454
1516
|
const style = this._state.fillStyle;
|
|
1455
|
-
if (style instanceof
|
|
1517
|
+
if (style instanceof RadialGradient) {
|
|
1518
|
+
const { circles, flat: stops } = style._normalized();
|
|
1519
|
+
this._native.ctxFillRadialGradient(
|
|
1520
|
+
this._s(),
|
|
1521
|
+
...circles,
|
|
1522
|
+
this._stops(stops),
|
|
1523
|
+
);
|
|
1524
|
+
} else if (style instanceof LinearGradient) {
|
|
1456
1525
|
const { coords, flat: stops } = style._normalized();
|
|
1457
1526
|
const flat = this._stops(stops);
|
|
1458
1527
|
this._native.ctxFillLinearGradient(
|
|
@@ -1688,7 +1757,18 @@ export class BackendContext2D {
|
|
|
1688
1757
|
|
|
1689
1758
|
_fillRectNow(x, y, w, h) {
|
|
1690
1759
|
const style = this._state.fillStyle;
|
|
1691
|
-
if (style instanceof
|
|
1760
|
+
if (style instanceof RadialGradient) {
|
|
1761
|
+
const { circles, flat: stops } = style._normalized();
|
|
1762
|
+
this._native.ctxFillRadialGradient(
|
|
1763
|
+
this._s(),
|
|
1764
|
+
...circles,
|
|
1765
|
+
this._stops(stops),
|
|
1766
|
+
x,
|
|
1767
|
+
y,
|
|
1768
|
+
w,
|
|
1769
|
+
h,
|
|
1770
|
+
);
|
|
1771
|
+
} else if (style instanceof LinearGradient) {
|
|
1692
1772
|
const { coords, flat: stops } = style._normalized();
|
|
1693
1773
|
const flat = this._stops(stops);
|
|
1694
1774
|
this._native.ctxFillLinearGradient(
|
|
@@ -1802,6 +1882,10 @@ export class BackendContext2D {
|
|
|
1802
1882
|
* resampling an `imageSmoothingQuality` sets all take as before.
|
|
1803
1883
|
*/
|
|
1804
1884
|
_drawFiltered(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink) {
|
|
1885
|
+
if (ink.blur > 0) {
|
|
1886
|
+
this._drawBlurred(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1805
1889
|
const x0 = Math.max(0, Math.floor(sx));
|
|
1806
1890
|
const y0 = Math.max(0, Math.floor(sy));
|
|
1807
1891
|
const w = Math.min(size.width, Math.ceil(sx + sw)) - x0;
|
|
@@ -1832,6 +1916,83 @@ export class BackendContext2D {
|
|
|
1832
1916
|
}
|
|
1833
1917
|
}
|
|
1834
1918
|
|
|
1919
|
+
/**
|
|
1920
|
+
* `drawImage` under a filter with a `blur()`: the part of the source the
|
|
1921
|
+
* source rect covers and as far round it as the blur reaches, three
|
|
1922
|
+
* standard deviations, scaled down so that the blur is a few pixels
|
|
1923
|
+
* across — the resampling the bridge does as it draws, which averages —
|
|
1924
|
+
* blurred and run through the rest of the filter there, and drawn back up
|
|
1925
|
+
* over the destination and as far round it, smoothed. A Gaussian is
|
|
1926
|
+
* smooth to the scale it was taken at, so the pixels it lost scaling down
|
|
1927
|
+
* were ones it would have blurred away, as Skia blurs a large radius. What
|
|
1928
|
+
* is past the source is nothing, which a blur fades into, as canvas has it.
|
|
1929
|
+
*/
|
|
1930
|
+
_drawBlurred(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink) {
|
|
1931
|
+
if (!(sw > 0 && sh > 0 && dw !== 0 && dh !== 0)) return;
|
|
1932
|
+
// the blur in source pixels, across and down
|
|
1933
|
+
const bx = (ink.blur * sw) / Math.abs(dw);
|
|
1934
|
+
const by = (ink.blur * sh) / Math.abs(dh);
|
|
1935
|
+
const mx = Math.ceil(3 * bx);
|
|
1936
|
+
const my = Math.ceil(3 * by);
|
|
1937
|
+
// what is read, in source pixels, the source's part of it, and the
|
|
1938
|
+
// factor it is scaled down by
|
|
1939
|
+
const left = Math.floor(sx) - mx;
|
|
1940
|
+
const top = Math.floor(sy) - my;
|
|
1941
|
+
const across = Math.ceil(sx + sw) + mx - left;
|
|
1942
|
+
const down = Math.ceil(sy + sh) + my - top;
|
|
1943
|
+
const factor = Math.max(1, Math.floor(Math.min(bx, by) / BLUR_SCALE));
|
|
1944
|
+
const w = Math.max(1, Math.ceil(across / factor));
|
|
1945
|
+
const h = Math.max(1, Math.ceil(down / factor));
|
|
1946
|
+
const n = this._native;
|
|
1947
|
+
const small = n.createSurface(w, h, 1);
|
|
1948
|
+
const st = this._state;
|
|
1949
|
+
try {
|
|
1950
|
+
const ix = Math.max(0, left);
|
|
1951
|
+
const iy = Math.max(0, top);
|
|
1952
|
+
const iw = Math.min(size.width, left + across) - ix;
|
|
1953
|
+
const ih = Math.min(size.height, top + down) - iy;
|
|
1954
|
+
if (iw > 0 && ih > 0) {
|
|
1955
|
+
if (this._smoothing) n.ctxSetImageSmoothing(small, 'high');
|
|
1956
|
+
n.ctxDrawSurface(
|
|
1957
|
+
small,
|
|
1958
|
+
src,
|
|
1959
|
+
ix,
|
|
1960
|
+
iy,
|
|
1961
|
+
iw,
|
|
1962
|
+
ih,
|
|
1963
|
+
(ix - left) / factor,
|
|
1964
|
+
(iy - top) / factor,
|
|
1965
|
+
iw / factor,
|
|
1966
|
+
ih / factor,
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
const pixels = n.ctxGetImageData(small, 0, 0, w, h);
|
|
1970
|
+
blurPixels(pixels, w, h, Math.min(bx, by) / factor);
|
|
1971
|
+
if (ink.matrices.length || ink.alpha < 1) {
|
|
1972
|
+
filterPixels(pixels, pixels, ink);
|
|
1973
|
+
}
|
|
1974
|
+
n.ctxPutImageData(small, pixels, w, h, 0, 0);
|
|
1975
|
+
st.filterInk = null;
|
|
1976
|
+
// the destination's units a source pixel is
|
|
1977
|
+
const kx = dw / sw;
|
|
1978
|
+
const ky = dh / sh;
|
|
1979
|
+
this.drawImage(
|
|
1980
|
+
{ _surfaceHandle: small },
|
|
1981
|
+
0,
|
|
1982
|
+
0,
|
|
1983
|
+
across / factor,
|
|
1984
|
+
down / factor,
|
|
1985
|
+
dx + (left - sx) * kx,
|
|
1986
|
+
dy + (top - sy) * ky,
|
|
1987
|
+
across * kx,
|
|
1988
|
+
down * ky,
|
|
1989
|
+
);
|
|
1990
|
+
} finally {
|
|
1991
|
+
st.filterInk = ink;
|
|
1992
|
+
release(n, small);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1835
1996
|
/**
|
|
1836
1997
|
* Whether a surface is drawn under this `globalAlpha` from its pixels
|
|
1837
1998
|
* scaled by it (@windowkit/appkit's `ctxDrawSurfaceFaded`): where the
|
|
@@ -2129,7 +2290,7 @@ export class BackendContext2D {
|
|
|
2129
2290
|
];
|
|
2130
2291
|
}
|
|
2131
2292
|
const style = this._state.fillStyle;
|
|
2132
|
-
return style instanceof
|
|
2293
|
+
return style instanceof Gradient ? BLACK : parseColor(style);
|
|
2133
2294
|
}
|
|
2134
2295
|
|
|
2135
2296
|
// --- text (minimal: enough for <canvas onDraw> users) --------------------
|
package/src/backend/filter.js
CHANGED
|
@@ -9,10 +9,21 @@
|
|
|
9
9
|
// glyph run's ink and a symbol's colour are filtered by filtering the colour
|
|
10
10
|
// they are drawn in, exactly. An image and a surface are filtered pixel by
|
|
11
11
|
// pixel, and a text layout that carries colours of its own on a surface of
|
|
12
|
-
// its own (src/backend/context2d.js). `
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// its own (src/backend/context2d.js). `drop-shadow()` and a `url()` reach
|
|
13
|
+
// past what is drawn, which a colour cannot say; this context does not
|
|
14
|
+
// apply them, so a list with one in it does not stick, and a caller knows
|
|
15
|
+
// from reading the property back.
|
|
16
|
+
//
|
|
17
|
+
// `blur()` reaches past what is drawn too, and is applied to an image or a
|
|
18
|
+
// surface drawn — `drawImage`, what a renderer composites a layer of its
|
|
19
|
+
// own with, CSS's `backdrop-filter` among them — by blurring its pixels
|
|
20
|
+
// (`blurPixels`). A fill, a stroke and text are drawn sharp under it, in
|
|
21
|
+
// the colours the rest of the list makes. Its standard deviation is in the
|
|
22
|
+
// units `drawImage`'s destination is given in. Several `blur()`s are one,
|
|
23
|
+
// the root of the sum of their squares, as two Gaussians make one; and each
|
|
24
|
+
// colour function here is a linear map on premultiplied colour, which a
|
|
25
|
+
// blur, a weighted sum of premultiplied pixels, commutes with, so where in
|
|
26
|
+
// the list a blur is written changes nothing but where the clamps fall.
|
|
16
27
|
|
|
17
28
|
/** A function's amount where none is written: all of the effect for the
|
|
18
29
|
* ones whose argument is how much of it to take, and none for the others
|
|
@@ -38,17 +49,19 @@ const ANGLE_UNITS = {
|
|
|
38
49
|
};
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
|
-
* A filter list as this context applies it: `{ matrices, alpha }`,
|
|
42
|
-
* colour matrices in the order written, each over straight RGB in 0–1
|
|
43
|
-
* an offset — none for a function that changes no colour —
|
|
44
|
-
* is multiplied by
|
|
45
|
-
*
|
|
52
|
+
* A filter list as this context applies it: `{ matrices, alpha, blur }`,
|
|
53
|
+
* the colour matrices in the order written, each over straight RGB in 0–1
|
|
54
|
+
* with an offset — none for a function that changes no colour — what alpha
|
|
55
|
+
* is multiplied by, and the standard deviation of the blur, 0 for none.
|
|
56
|
+
* Null for `none`; undefined for what is no filter list, or one with a
|
|
57
|
+
* function this context does not apply.
|
|
46
58
|
*/
|
|
47
59
|
export function parseCanvasFilter(value) {
|
|
48
60
|
const text = String(value).trim();
|
|
49
61
|
if (text.toLowerCase() === 'none') return null;
|
|
50
62
|
const matrices = [];
|
|
51
63
|
let alpha = 1;
|
|
64
|
+
let blur2 = 0;
|
|
52
65
|
let rest = text;
|
|
53
66
|
let seen = 0;
|
|
54
67
|
while (rest.length) {
|
|
@@ -57,8 +70,14 @@ export function parseCanvasFilter(value) {
|
|
|
57
70
|
rest = rest.slice(m[0].length);
|
|
58
71
|
seen += 1;
|
|
59
72
|
const name = m[1].toLowerCase();
|
|
60
|
-
if (!COLOUR.has(name)) return undefined;
|
|
61
73
|
const arg = m[2].trim();
|
|
74
|
+
if (name === 'blur') {
|
|
75
|
+
const radius = arg ? lengthOf(arg) : 0;
|
|
76
|
+
if (radius === null || radius < 0) return undefined;
|
|
77
|
+
blur2 += radius * radius;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!COLOUR.has(name)) return undefined;
|
|
62
81
|
if (name === 'hue-rotate') {
|
|
63
82
|
const angle = arg ? angleOf(arg) : 0;
|
|
64
83
|
if (angle === null) return undefined;
|
|
@@ -76,12 +95,24 @@ export function parseCanvasFilter(value) {
|
|
|
76
95
|
if (matrix) matrices.push(matrix);
|
|
77
96
|
}
|
|
78
97
|
if (!seen) return undefined;
|
|
79
|
-
return { matrices, alpha };
|
|
98
|
+
return { matrices, alpha, blur: Math.sqrt(blur2) };
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
/** Whether a parsed filter changes anything drawn. */
|
|
83
102
|
export function filters(filter) {
|
|
84
|
-
return
|
|
103
|
+
return (
|
|
104
|
+
!!filter &&
|
|
105
|
+
(filter.matrices.length > 0 || filter.alpha < 1 || filter.blur > 0)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A length in pixels; a bare 0 is one. */
|
|
110
|
+
function lengthOf(arg) {
|
|
111
|
+
const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(px)?$/i.exec(arg);
|
|
112
|
+
if (!m) return null;
|
|
113
|
+
const n = Number(m[1]);
|
|
114
|
+
if (!Number.isFinite(n)) return null;
|
|
115
|
+
return m[2] || n === 0 ? n : null;
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
/** A number or a percentage of one. */
|
|
@@ -283,3 +314,117 @@ export function filterPixels(src, dst, filter) {
|
|
|
283
314
|
function clampByte(v) {
|
|
284
315
|
return v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v);
|
|
285
316
|
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Straight RGBA pixels, 0–255, `width` by `height`, blurred in place by a
|
|
320
|
+
* Gaussian of standard deviation `sigma` pixels: three box blurs across and
|
|
321
|
+
* three down, each as wide as the Gaussian they come to (Kovesi, "Fast
|
|
322
|
+
* almost-Gaussian filtering", 2010), on premultiplied colour — a blur of
|
|
323
|
+
* straight colour bleeds the colour of what is transparent into its edge —
|
|
324
|
+
* with nothing past the edge, as canvas has it.
|
|
325
|
+
*/
|
|
326
|
+
export function blurPixels(pixels, width, height, sigma) {
|
|
327
|
+
if (!(sigma > 0) || !(width > 0 && height > 0)) return;
|
|
328
|
+
const n = width * height;
|
|
329
|
+
// premultiplied, the four channels of a pixel side by side
|
|
330
|
+
const plane = new Float32Array(n * 4);
|
|
331
|
+
for (let p = 0; p < n * 4; p += 4) {
|
|
332
|
+
const alpha = pixels[p + 3] / 255;
|
|
333
|
+
plane[p] = pixels[p] * alpha;
|
|
334
|
+
plane[p + 1] = pixels[p + 1] * alpha;
|
|
335
|
+
plane[p + 2] = pixels[p + 2] * alpha;
|
|
336
|
+
plane[p + 3] = pixels[p + 3];
|
|
337
|
+
}
|
|
338
|
+
const line = new Float32Array(Math.max(width, height) * 4);
|
|
339
|
+
for (const radius of boxRadii(sigma)) {
|
|
340
|
+
if (radius < 1) continue;
|
|
341
|
+
boxPass(plane, width, height, radius, 4, width * 4, line);
|
|
342
|
+
boxPass(plane, height, width, radius, width * 4, 4, line);
|
|
343
|
+
}
|
|
344
|
+
for (let p = 0; p < n * 4; p += 4) {
|
|
345
|
+
const alpha = plane[p + 3];
|
|
346
|
+
if (alpha <= 0.5) {
|
|
347
|
+
pixels[p] = pixels[p + 1] = pixels[p + 2] = pixels[p + 3] = 0;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const k = 255 / alpha;
|
|
351
|
+
pixels[p] = clampByte(plane[p] * k);
|
|
352
|
+
pixels[p + 1] = clampByte(plane[p + 1] * k);
|
|
353
|
+
pixels[p + 2] = clampByte(plane[p + 2] * k);
|
|
354
|
+
pixels[p + 3] = clampByte(alpha);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The radii of three box blurs that come to a Gaussian of `sigma`. */
|
|
359
|
+
function boxRadii(sigma) {
|
|
360
|
+
const passes = 3;
|
|
361
|
+
const ideal = Math.sqrt((12 * sigma * sigma) / passes + 1);
|
|
362
|
+
let lower = Math.floor(ideal);
|
|
363
|
+
if (lower % 2 === 0) lower -= 1;
|
|
364
|
+
const upper = lower + 2;
|
|
365
|
+
const m = Math.round(
|
|
366
|
+
(12 * sigma * sigma -
|
|
367
|
+
passes * lower * lower -
|
|
368
|
+
4 * passes * lower -
|
|
369
|
+
3 * passes) /
|
|
370
|
+
(-4 * lower - 4),
|
|
371
|
+
);
|
|
372
|
+
const out = [];
|
|
373
|
+
for (let i = 0; i < passes; i += 1) {
|
|
374
|
+
out.push(((i < m ? lower : upper) - 1) / 2);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One box blur of `radius` along every line of a plane of RGBA pixels:
|
|
381
|
+
* `lines` lines of `length` pixels, a pixel `step` values from the next
|
|
382
|
+
* along a line and a line `stride` values from the next. Past either end
|
|
383
|
+
* is nothing.
|
|
384
|
+
*/
|
|
385
|
+
function boxPass(plane, length, lines, radius, step, stride, line) {
|
|
386
|
+
const scale = 1 / (2 * radius + 1);
|
|
387
|
+
for (let l = 0; l < lines; l += 1) {
|
|
388
|
+
const base = l * stride;
|
|
389
|
+
for (let i = 0, q = base; i < length; i += 1, q += step) {
|
|
390
|
+
const o = i * 4;
|
|
391
|
+
line[o] = plane[q];
|
|
392
|
+
line[o + 1] = plane[q + 1];
|
|
393
|
+
line[o + 2] = plane[q + 2];
|
|
394
|
+
line[o + 3] = plane[q + 3];
|
|
395
|
+
}
|
|
396
|
+
let r = 0;
|
|
397
|
+
let g = 0;
|
|
398
|
+
let b = 0;
|
|
399
|
+
let a = 0;
|
|
400
|
+
for (let i = 0; i <= radius && i < length; i += 1) {
|
|
401
|
+
const o = i * 4;
|
|
402
|
+
r += line[o];
|
|
403
|
+
g += line[o + 1];
|
|
404
|
+
b += line[o + 2];
|
|
405
|
+
a += line[o + 3];
|
|
406
|
+
}
|
|
407
|
+
for (let i = 0, q = base; i < length; i += 1, q += step) {
|
|
408
|
+
plane[q] = r * scale;
|
|
409
|
+
plane[q + 1] = g * scale;
|
|
410
|
+
plane[q + 2] = b * scale;
|
|
411
|
+
plane[q + 3] = a * scale;
|
|
412
|
+
const enter = i + radius + 1;
|
|
413
|
+
if (enter < length) {
|
|
414
|
+
const o = enter * 4;
|
|
415
|
+
r += line[o];
|
|
416
|
+
g += line[o + 1];
|
|
417
|
+
b += line[o + 2];
|
|
418
|
+
a += line[o + 3];
|
|
419
|
+
}
|
|
420
|
+
const leave = i - radius;
|
|
421
|
+
if (leave >= 0) {
|
|
422
|
+
const o = leave * 4;
|
|
423
|
+
r -= line[o];
|
|
424
|
+
g -= line[o + 1];
|
|
425
|
+
b -= line[o + 2];
|
|
426
|
+
a -= line[o + 3];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
package/src/cocoa/relaunch.js
CHANGED
|
@@ -143,6 +143,19 @@ export const WORKER_START = `(function (process, global) {
|
|
|
143
143
|
// process for ever.
|
|
144
144
|
const WORKER_END_MS = 5000;
|
|
145
145
|
|
|
146
|
+
// Where Node starts an app (`startedRunning`): the script that loads the
|
|
147
|
+
// `--require` preloads and then the entry, and the module that runs the
|
|
148
|
+
// entry, an ES module's graph from an async function.
|
|
149
|
+
const ENTRY_POINT = new Set([
|
|
150
|
+
'node:internal/main/run_main_module',
|
|
151
|
+
'node:internal/modules/run_main',
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
// How a process is started under an ES module loader, as a flag of its own
|
|
155
|
+
// or in NODE_OPTIONS: `--import tsx`, `--import=tsx`, `--loader
|
|
156
|
+
// ts-node/esm`, `--experimental-loader ./hooks.mjs`.
|
|
157
|
+
const LOADER_FLAG = /^--(?:import|loader|experimental-loader)(?:=|$)/;
|
|
158
|
+
|
|
146
159
|
/**
|
|
147
160
|
* Why this import should not move the app onto a worker, or null when it
|
|
148
161
|
* should. A pure answer over what the process says about itself, so that
|
|
@@ -152,6 +165,8 @@ export function relaunchVeto({
|
|
|
152
165
|
isMainThread: main,
|
|
153
166
|
platform,
|
|
154
167
|
env,
|
|
168
|
+
execArgv,
|
|
169
|
+
node,
|
|
155
170
|
entry,
|
|
156
171
|
compiled,
|
|
157
172
|
late,
|
|
@@ -184,13 +199,33 @@ export function relaunchVeto({
|
|
|
184
199
|
) {
|
|
185
200
|
return 'a test runner';
|
|
186
201
|
}
|
|
187
|
-
// Imported after the app started running
|
|
188
|
-
//
|
|
189
|
-
// entry has already done on this thread.
|
|
202
|
+
// Imported after the app started running: by an `import()` from a timer,
|
|
203
|
+
// say, or one the entry's body awaits. Relaunching would run again, on
|
|
204
|
+
// the worker, everything the entry has already done on this thread.
|
|
190
205
|
if (late) return 'imported after the app started running';
|
|
206
|
+
// Started under an ES module loader, before Node 24. The worker gets the
|
|
207
|
+
// loader's flags, and whether its hooks follow is the loader's to say:
|
|
208
|
+
// tsx, the one an app is likeliest to run under, keeps them to the main
|
|
209
|
+
// thread on Node 20 and 22 — before 22.22.3, by tsx 4.23's own check —
|
|
210
|
+
// and a worker without them stops on the entry's first .jsx or .ts module
|
|
211
|
+
// (measured, 20.20 and 22.16). No loader can be asked before the move, so
|
|
212
|
+
// any `--import` or `--loader` counts. From 24 an app under one moves,
|
|
213
|
+
// and tsx follows it there from 4.21.1 on 24.11.1 (an older one does not,
|
|
214
|
+
// docs/macos.md); `react-x11/cocoa-main` moves one on 20 or 22 whose
|
|
215
|
+
// loader follows.
|
|
216
|
+
if (node && node < 24 && startedUnderLoader(execArgv, env.NODE_OPTIONS)) {
|
|
217
|
+
return 'an ES module loader before Node 24';
|
|
218
|
+
}
|
|
191
219
|
return null;
|
|
192
220
|
}
|
|
193
221
|
|
|
222
|
+
/** Whether node's flags, its own or NODE_OPTIONS', name an ES module loader. */
|
|
223
|
+
function startedUnderLoader(execArgv = [], nodeOptions = '') {
|
|
224
|
+
return [...execArgv, ...nodeOptions.split(/\s+/)].some((flag) =>
|
|
225
|
+
LOADER_FLAG.test(flag),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
194
229
|
/** What `relaunchVeto` reads, from this process. */
|
|
195
230
|
export function describeProcess(moduleUrl) {
|
|
196
231
|
const bun = globalThis.Bun;
|
|
@@ -199,6 +234,9 @@ export function describeProcess(moduleUrl) {
|
|
|
199
234
|
isMainThread,
|
|
200
235
|
platform: process.platform,
|
|
201
236
|
env: process.env,
|
|
237
|
+
execArgv: process.execArgv,
|
|
238
|
+
// Node's major version; Bun answers with one of its own choosing
|
|
239
|
+
node: bun ? null : Number(process.versions.node.split('.')[0]),
|
|
202
240
|
entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
|
|
203
241
|
compiled: compiledExecutable(bun),
|
|
204
242
|
late: startedRunning(bun, moduleUrl, entry),
|
|
@@ -216,15 +254,36 @@ function compiledExecutable(bun) {
|
|
|
216
254
|
}
|
|
217
255
|
|
|
218
256
|
/**
|
|
219
|
-
* Whether the entry's own code has started
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
257
|
+
* Whether the entry's own code has started: whether this import is one the
|
|
258
|
+
* app made after its start, rather than part of it.
|
|
259
|
+
*
|
|
260
|
+
* Node tells by where the import is evaluated from. It starts an app from
|
|
261
|
+
* two modules of its own (`ENTRY_POINT`): a CommonJS entry, and every
|
|
262
|
+
* `require()` of its first run and of a `--require` preload, evaluate under
|
|
263
|
+
* them synchronously, and an ES module entry's graph — static imports, an
|
|
264
|
+
* `--import` preload's — from an async function there, which V8 keeps on
|
|
265
|
+
* the stack as an async frame. An `import()` is evaluated by a job of its
|
|
266
|
+
* own, whose stack ends in the module loader, or in the app's own code when
|
|
267
|
+
* that awaits it: an `await import()` in the entry's body, an async
|
|
268
|
+
* function of the app's. Measured on Node 20.20, 22.16, 23.2, 24.12, 25.4
|
|
269
|
+
* and 26.0, static imports nested and behind a module with a top-level
|
|
270
|
+
* await, and under tsx, `--experimental-loader`, source maps and a stack
|
|
271
|
+
* trace limit of 0. A Node that moved those modules would read every
|
|
272
|
+
* import as late, which keeps the app where it is rather than running it
|
|
273
|
+
* twice; test/cocoa-relaunch.test.js would say so on any OS.
|
|
274
|
+
*
|
|
275
|
+
* `performance.nodeTiming.loopStart`, -1 until the event loop turns, is the
|
|
276
|
+
* public signal and is wrong both ways: Node 20 and 22 turn the loop while
|
|
277
|
+
* they load an ES module entry, so no such entry moved there, and Node 24
|
|
278
|
+
* and later evaluate an `import()` the entry makes before the loop's first
|
|
279
|
+
* turn, so an app that made one moved and ran its entry twice.
|
|
280
|
+
*
|
|
281
|
+
* Bun reports 1 there from the first line; what tells in Bun is the entry's
|
|
282
|
+
* record in the module cache, which is there once the entry has been
|
|
283
|
+
* evaluated and absent while its imports are (measured, Bun 1.4).
|
|
225
284
|
*/
|
|
226
285
|
function startedRunning(bun, moduleUrl, entry) {
|
|
227
|
-
if (!bun) return
|
|
286
|
+
if (!bun) return !evaluatedFromEntryPoint();
|
|
228
287
|
try {
|
|
229
288
|
return Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
|
|
230
289
|
} catch {
|
|
@@ -232,13 +291,40 @@ function startedRunning(bun, moduleUrl, entry) {
|
|
|
232
291
|
}
|
|
233
292
|
}
|
|
234
293
|
|
|
294
|
+
/** Whether this stack, async frames and all, comes from `ENTRY_POINT`. */
|
|
295
|
+
function evaluatedFromEntryPoint() {
|
|
296
|
+
const { prepareStackTrace, stackTraceLimit } = Error;
|
|
297
|
+
let sites;
|
|
298
|
+
try {
|
|
299
|
+
// Reflect.set: an Error the app has frozen is not a reason to throw
|
|
300
|
+
// from its import of react-x11
|
|
301
|
+
Reflect.set(Error, 'stackTraceLimit', Infinity);
|
|
302
|
+
Reflect.set(Error, 'prepareStackTrace', (_, callSites) => callSites);
|
|
303
|
+
const held = {};
|
|
304
|
+
Error.captureStackTrace(held);
|
|
305
|
+
sites = held.stack;
|
|
306
|
+
} finally {
|
|
307
|
+
Reflect.set(Error, 'prepareStackTrace', prepareStackTrace);
|
|
308
|
+
Reflect.set(Error, 'stackTraceLimit', stackTraceLimit);
|
|
309
|
+
}
|
|
310
|
+
if (!Array.isArray(sites)) return false;
|
|
311
|
+
for (const site of sites) {
|
|
312
|
+
const file = site.getFileName() ?? '';
|
|
313
|
+
if (ENTRY_POINT.has(file)) return true;
|
|
314
|
+
// the app's own code awaits this import, so it has run already
|
|
315
|
+
if (site.isAsync() && !file.startsWith('node:')) return false;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
235
320
|
/**
|
|
236
321
|
* The import-time move (src/bootstrap.js): relaunch when nothing vetoes it
|
|
237
322
|
* and a bridge that can run AppKit's loop is installed. Returns why it did
|
|
238
323
|
* not, for anyone asking; when it does, it does not return.
|
|
239
324
|
*/
|
|
240
325
|
export function relaunchOnImport(moduleUrl) {
|
|
241
|
-
const
|
|
326
|
+
const facts = describeProcess(moduleUrl);
|
|
327
|
+
const veto = relaunchVeto(facts);
|
|
242
328
|
if (veto) return veto;
|
|
243
329
|
let native;
|
|
244
330
|
try {
|
|
@@ -248,7 +334,7 @@ export function relaunchOnImport(moduleUrl) {
|
|
|
248
334
|
return 'no cocoa bridge';
|
|
249
335
|
}
|
|
250
336
|
if (typeof native.runMain !== 'function') return 'the bridge has no runMain';
|
|
251
|
-
return relaunch(
|
|
337
|
+
return relaunch(facts.entry, native);
|
|
252
338
|
}
|
|
253
339
|
|
|
254
340
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `react-x11/frame/child` — the entry a `<Frame>` forks: a pane process's
|
|
3
|
+
* whole bootstrap, over the IPC channel `fork()` sets up. **Resolved, not
|
|
4
|
+
* imported**: a `transport` that starts the pane its own way — under a
|
|
5
|
+
* sandbox, with a runtime of its own choosing — forks
|
|
6
|
+
* `fileURLToPath(import.meta.resolve('react-x11/frame/child'))`, and
|
|
7
|
+
* importing it anywhere else exits the process. No exports.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/src/frame/childmain.js
CHANGED
|
@@ -168,6 +168,18 @@ export async function runFrameChild(transport, options = {}) {
|
|
|
168
168
|
}
|
|
169
169
|
markFramed();
|
|
170
170
|
|
|
171
|
+
// The host's watchdog asks whether this process is still answering; the
|
|
172
|
+
// answer comes from the event loop, which is the thing it asks about.
|
|
173
|
+
// From the first moment, since the host asks from its hello on.
|
|
174
|
+
transport.onMessage((msg) => {
|
|
175
|
+
if (msg?.type !== 'ping') return;
|
|
176
|
+
try {
|
|
177
|
+
transport.send({ type: 'pong', seq: msg.seq });
|
|
178
|
+
} catch {
|
|
179
|
+
// the host is going away
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
171
183
|
const hello = await new Promise((resolve) => {
|
|
172
184
|
const off = transport.onMessage((msg) => {
|
|
173
185
|
if (msg?.type !== 'hello') return;
|
package/src/frame/index.js
CHANGED
|
@@ -73,21 +73,22 @@ const childPath = () =>
|
|
|
73
73
|
|
|
74
74
|
/** The default transport: fork this package's child entry. `execArgv` is
|
|
75
75
|
* inherited, which is what carries a dev loader (tsx, the refresh loader)
|
|
76
|
-
* into the pane; a bundled app forks plain JS and needs none.
|
|
77
|
-
|
|
76
|
+
* into the pane; a bundled app forks plain JS and needs none. The pane's
|
|
77
|
+
* own flags and environment (`<Frame execArgv env>`) go on top. */
|
|
78
|
+
function forkTransport({ src, display, execArgv = [], env = {} }) {
|
|
78
79
|
const child = fork(childPath(), [], {
|
|
79
80
|
serialization: 'advanced',
|
|
80
81
|
// dev loaders (tsx, the refresh loader) follow into the pane; the test
|
|
81
82
|
// runner's own flags must not — `--test` in execArgv would run the pane
|
|
82
|
-
// entry as a test file
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
// entry as a test file. The pane's come after, so a flag it names is
|
|
84
|
+
// the one in force where the two disagree.
|
|
85
|
+
execArgv: [
|
|
86
|
+
...process.execArgv.filter(
|
|
87
|
+
(a) => a !== '--test' && !a.startsWith('--test-'),
|
|
88
|
+
),
|
|
89
|
+
...execArgv,
|
|
90
|
+
],
|
|
91
|
+
env: paneEnv(env, display),
|
|
91
92
|
});
|
|
92
93
|
void src; // resolved by the child from the hello, not from argv
|
|
93
94
|
return {
|
|
@@ -121,6 +122,84 @@ function forkTransport({ src, display }) {
|
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The environment a pane process starts with: this one's, with what the
|
|
127
|
+
* `<Frame>` names over it — `undefined` takes a variable away — and then
|
|
128
|
+
* the two that are not the app's to set: `REACT_X11_FRAME`, which is how a
|
|
129
|
+
* pane knows it is one (`isFramed`), and `DISPLAY`, which `display` names.
|
|
130
|
+
*/
|
|
131
|
+
function paneEnv(env, display) {
|
|
132
|
+
const out = { ...process.env };
|
|
133
|
+
for (const [name, value] of Object.entries(env)) {
|
|
134
|
+
if (value === undefined) delete out[name];
|
|
135
|
+
else out[name] = value;
|
|
136
|
+
}
|
|
137
|
+
out.REACT_X11_FRAME = '1';
|
|
138
|
+
if (display) out.DISPLAY = display;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a pane's process is started with, checked and as one string: the
|
|
144
|
+
* session's key, so that an `execArgv` array or an `env` object written in
|
|
145
|
+
* place — a new one each render — restarts nothing, and one that says
|
|
146
|
+
* something else restarts the pane. Throws on what a fork cannot take,
|
|
147
|
+
* which the session reports as a `spawn` failure.
|
|
148
|
+
*/
|
|
149
|
+
/** How long a pane may go without answering by default: long enough for a
|
|
150
|
+
* pane that is working, through a collection or a long layout, short
|
|
151
|
+
* enough that one that is not is noticed before the reader gives up. */
|
|
152
|
+
const WATCHDOG_MS = 15000;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The watchdog's limit, in ms, or 0 where the pane is not watched: `false`,
|
|
156
|
+
* or a pane started under the inspector — by its own flags, this process's
|
|
157
|
+
* that it inherits, or `NODE_OPTIONS` — which a debugger stops on purpose.
|
|
158
|
+
* The flags that turn the inspector on, and not its kin that only set it
|
|
159
|
+
* up: Node's test runner hands every test process `--inspect-port` and
|
|
160
|
+
* `--inspect-publish-uid`, and a pane in a test is not being debugged.
|
|
161
|
+
*/
|
|
162
|
+
function watchdogMs(watchdog, execArgv) {
|
|
163
|
+
if (watchdog === false) return 0;
|
|
164
|
+
const limit = Number(watchdog);
|
|
165
|
+
if (!(limit > 0)) return 0;
|
|
166
|
+
const flags = [
|
|
167
|
+
...process.execArgv,
|
|
168
|
+
...(execArgv ?? []),
|
|
169
|
+
...(process.env.NODE_OPTIONS ?? '').split(/\s+/),
|
|
170
|
+
];
|
|
171
|
+
return flags.some((flag) => INSPECTING.test(flag)) ? 0 : limit;
|
|
172
|
+
}
|
|
173
|
+
const INSPECTING = /^--inspect(?:-brk|-wait)?(?:=|$)/;
|
|
174
|
+
|
|
175
|
+
function launchOf(execArgv, env) {
|
|
176
|
+
if (
|
|
177
|
+
execArgv !== undefined &&
|
|
178
|
+
(!Array.isArray(execArgv) || execArgv.some((a) => typeof a !== 'string'))
|
|
179
|
+
) {
|
|
180
|
+
throw new TypeError(
|
|
181
|
+
'react-x11: <Frame execArgv> is an array of strings — the flags the ' +
|
|
182
|
+
"pane's process is started with.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
env !== undefined &&
|
|
187
|
+
(env === null ||
|
|
188
|
+
typeof env !== 'object' ||
|
|
189
|
+
Object.values(env).some((v) => v !== undefined && typeof v !== 'string'))
|
|
190
|
+
) {
|
|
191
|
+
throw new TypeError(
|
|
192
|
+
"react-x11: <Frame env> maps a variable's name to a string, or to " +
|
|
193
|
+
'undefined to take it away.',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
// `undefined` is kept apart from a variable left alone, which
|
|
197
|
+
// JSON.stringify would drop
|
|
198
|
+
return JSON.stringify([execArgv ?? [], env ?? {}], (_, v) =>
|
|
199
|
+
v === undefined ? { unset: true } : v,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
124
203
|
function srcString(src) {
|
|
125
204
|
if (src !== null && typeof src === 'object' && typeof src.href === 'string') {
|
|
126
205
|
return src.href;
|
|
@@ -220,6 +299,9 @@ export function Frame({
|
|
|
220
299
|
props = EMPTY_PROPS,
|
|
221
300
|
style,
|
|
222
301
|
display,
|
|
302
|
+
execArgv,
|
|
303
|
+
env: processEnv,
|
|
304
|
+
watchdog = WATCHDOG_MS,
|
|
223
305
|
bridge = true,
|
|
224
306
|
fallback,
|
|
225
307
|
focusable,
|
|
@@ -262,8 +344,22 @@ export function Frame({
|
|
|
262
344
|
const source = srcString(src);
|
|
263
345
|
const makeTransport = transport ?? forkTransport;
|
|
264
346
|
const containerRef = useRef(null);
|
|
347
|
+
// the pane process's own flags and environment, by what they say
|
|
348
|
+
let launch;
|
|
349
|
+
let launchError = null;
|
|
350
|
+
try {
|
|
351
|
+
launch = launchOf(execArgv, processEnv);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
launch = '';
|
|
354
|
+
launchError = err;
|
|
355
|
+
}
|
|
356
|
+
const paneProcess = useRef(null);
|
|
357
|
+
paneProcess.current = { execArgv: execArgv ?? [], env: processEnv ?? {} };
|
|
358
|
+
// how long the pane may go without answering, where it is watched at all
|
|
359
|
+
const watched = watchdogMs(watchdog, execArgv);
|
|
265
360
|
|
|
266
|
-
// The session: one child process per (src, display,
|
|
361
|
+
// The session: one child process per (src, display, the process's flags
|
|
362
|
+
// and environment, transport, restart).
|
|
267
363
|
// Declared before the update effect so that on the mounting commit the
|
|
268
364
|
// hello goes out first and the update pass sees itself already sent.
|
|
269
365
|
useEffect(() => {
|
|
@@ -297,7 +393,13 @@ export function Frame({
|
|
|
297
393
|
|
|
298
394
|
let t;
|
|
299
395
|
try {
|
|
300
|
-
|
|
396
|
+
if (launchError) throw launchError;
|
|
397
|
+
t = makeTransport({
|
|
398
|
+
src: source,
|
|
399
|
+
display,
|
|
400
|
+
execArgv: paneProcess.current.execArgv,
|
|
401
|
+
env: paneProcess.current.env,
|
|
402
|
+
});
|
|
301
403
|
} catch (err) {
|
|
302
404
|
fail(Object.assign(err, { phase: 'spawn' }));
|
|
303
405
|
return undefined;
|
|
@@ -309,6 +411,8 @@ export function Frame({
|
|
|
309
411
|
fatal: null,
|
|
310
412
|
sent: null,
|
|
311
413
|
shutdown: null,
|
|
414
|
+
answered: performance.now(),
|
|
415
|
+
watch: null,
|
|
312
416
|
};
|
|
313
417
|
session.current = s;
|
|
314
418
|
|
|
@@ -337,6 +441,8 @@ export function Frame({
|
|
|
337
441
|
pid: t.pid ?? null,
|
|
338
442
|
windowId: msg.windowId,
|
|
339
443
|
});
|
|
444
|
+
} else if (msg?.type === 'pong') {
|
|
445
|
+
s.answered = performance.now();
|
|
340
446
|
} else if (msg?.type === 'invoke') {
|
|
341
447
|
s.table.invoke(msg.id, msg.args);
|
|
342
448
|
} else if (msg?.type === 'fatal') {
|
|
@@ -354,6 +460,7 @@ export function Frame({
|
|
|
354
460
|
};
|
|
355
461
|
offExit = t.onExit(({ code = null, signal = null, error } = {}) => {
|
|
356
462
|
clearEscalation?.();
|
|
463
|
+
clearInterval(s.watch);
|
|
357
464
|
detach();
|
|
358
465
|
const expected = s.closing;
|
|
359
466
|
if (alive && !expected) {
|
|
@@ -382,6 +489,7 @@ export function Frame({
|
|
|
382
489
|
s.shutdown = () => {
|
|
383
490
|
if (s.closing) return;
|
|
384
491
|
s.closing = true;
|
|
492
|
+
clearInterval(s.watch);
|
|
385
493
|
let sent = false;
|
|
386
494
|
try {
|
|
387
495
|
t.send({ type: 'unmount' });
|
|
@@ -430,6 +538,48 @@ export function Frame({
|
|
|
430
538
|
...(paneApp?.playsForPanes && { players: true }),
|
|
431
539
|
});
|
|
432
540
|
|
|
541
|
+
// The watchdog. A pane whose event loop is stuck stays `running`, is
|
|
542
|
+
// sent updates it never reads, and on X11 holds the keys while the
|
|
543
|
+
// pointer is over it — and says nothing. So it is asked, about once a
|
|
544
|
+
// second, and answers from its event loop (`runFrameChild`); one that
|
|
545
|
+
// has not answered in `watchdog` ms has stopped, and goes the way a
|
|
546
|
+
// pane that crashed goes: failed, phase `'unresponsive'`, and its
|
|
547
|
+
// process ended, so `fallback` can offer to start it again. A tick of
|
|
548
|
+
// this side's own that comes late — the machine slept, or this
|
|
549
|
+
// process was the one stuck — is no news of the pane's, and the
|
|
550
|
+
// count starts again from it.
|
|
551
|
+
if (watched > 0) {
|
|
552
|
+
const every = Math.max(50, Math.min(1000, watched / 4));
|
|
553
|
+
let ticked = performance.now();
|
|
554
|
+
let seq = 0;
|
|
555
|
+
s.watch = setInterval(() => {
|
|
556
|
+
if (s.closing) return;
|
|
557
|
+
const now = performance.now();
|
|
558
|
+
const late = now - ticked > every * 3;
|
|
559
|
+
ticked = now;
|
|
560
|
+
if (late) s.answered = now;
|
|
561
|
+
else if (now - s.answered > watched) {
|
|
562
|
+
clearInterval(s.watch);
|
|
563
|
+
const message = `the pane stopped answering for ${Math.round(
|
|
564
|
+
now - s.answered,
|
|
565
|
+
)} ms`;
|
|
566
|
+
s.fatal = { phase: 'unresponsive', message };
|
|
567
|
+
fail(Object.assign(new Error(message), { phase: 'unresponsive' }));
|
|
568
|
+
// it cannot read an unmount: ended, from outside
|
|
569
|
+
t.kill?.('SIGTERM');
|
|
570
|
+
const kill = setTimeout(() => t.kill?.('SIGKILL'), 2000);
|
|
571
|
+
kill.unref?.();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
t.send({ type: 'ping', seq: (seq += 1) });
|
|
576
|
+
} catch {
|
|
577
|
+
// the channel is going; the exit says the rest
|
|
578
|
+
}
|
|
579
|
+
}, every);
|
|
580
|
+
s.watch.unref?.();
|
|
581
|
+
}
|
|
582
|
+
|
|
433
583
|
return () => {
|
|
434
584
|
alive = false;
|
|
435
585
|
s.shutdown();
|
|
@@ -439,7 +589,15 @@ export function Frame({
|
|
|
439
589
|
// ask, and everything is unref'd so nothing holds the host open
|
|
440
590
|
if (session.current === s) session.current = null;
|
|
441
591
|
};
|
|
442
|
-
}, [
|
|
592
|
+
}, [
|
|
593
|
+
source,
|
|
594
|
+
display,
|
|
595
|
+
launch,
|
|
596
|
+
generation,
|
|
597
|
+
makeTransport,
|
|
598
|
+
canShowPane,
|
|
599
|
+
watched,
|
|
600
|
+
]);
|
|
443
601
|
|
|
444
602
|
// One update per commit that changed the pane's inputs, props and env in
|
|
445
603
|
// the same message — so a theme flip and the state change that caused it
|
package/src/frame/protocol.js
CHANGED
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
// channel (the fork transport), a loopback pair (the tests), or whatever a
|
|
4
4
|
// custom transport is built on.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Eight messages, all objects with a `type`:
|
|
7
7
|
//
|
|
8
8
|
// parent → child
|
|
9
9
|
// hello { protocol, src, display, rect, props, env, players? } first, once
|
|
10
10
|
// update { props, env } full snapshots, one per parent commit
|
|
11
11
|
// unmount {} run close handlers, unmount, exit
|
|
12
|
+
// ping { seq } the watchdog: still answering?
|
|
12
13
|
//
|
|
13
14
|
// child → parent
|
|
14
15
|
// ready { windowId } the pane mounted; embed this
|
|
15
16
|
// invoke { id, args } a bridged callback fired
|
|
16
17
|
// fatal { phase, message, stack } why the exit about to happen is one
|
|
18
|
+
// pong { seq } yes, from the event loop
|
|
17
19
|
//
|
|
18
20
|
// Values cross by **structured clone** (fork uses `serialization:
|
|
19
21
|
// 'advanced'`), so Dates, Maps, TypedArrays and cycles survive — and
|
|
@@ -33,7 +35,9 @@
|
|
|
33
35
|
// what makes the grace window about racing messages rather than about how
|
|
34
36
|
// the app was written.
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
// 2: the watchdog's ping and pong. A pane of protocol 1 answers no ping,
|
|
39
|
+
// and would be ended as one that stopped; it fails the handshake instead.
|
|
40
|
+
export const PROTOCOL = 2;
|
|
37
41
|
|
|
38
42
|
/** The marker key a function in `props` becomes on the wire. */
|
|
39
43
|
export const CALLBACK = '$$reactX11FrameCallback';
|
package/src/node.d.ts
CHANGED
|
@@ -90,10 +90,11 @@ export interface Context2D {
|
|
|
90
90
|
* Canvas's: a CSS filter list applied to what is drawn after it. Native
|
|
91
91
|
* contexts only (macOS, Windows), and only the colour functions —
|
|
92
92
|
* `grayscale()`, `sepia()`, `saturate()`, `hue-rotate()`, `invert()`,
|
|
93
|
-
* `brightness()`, `contrast()` and `opacity()` —
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
93
|
+
* `brightness()`, `contrast()` and `opacity()` — and `blur()`, which
|
|
94
|
+
* blurs what `drawImage` draws and leaves fills, strokes and text sharp,
|
|
95
|
+
* over a bridge that reads a surface's pixels back. Anything else does
|
|
96
|
+
* not stick: assign and read back to know. Absent on X11, whose server
|
|
97
|
+
* runs no colour matrix and answers a read a round trip later.
|
|
97
98
|
*/
|
|
98
99
|
filter?: string;
|
|
99
100
|
save(): void;
|
package/src/types/frame.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type FrameProps = Record<string, unknown>;
|
|
|
16
16
|
/** Why a pane is not running, for `fallback` and `onExit` to read. */
|
|
17
17
|
export interface FrameError extends Error {
|
|
18
18
|
/** Where it went wrong: `'spawn' | 'load' | 'connect' | 'handshake' |
|
|
19
|
-
* 'runtime' | 'send' | 'embed' | 'exit'`. */
|
|
19
|
+
* 'runtime' | 'send' | 'embed' | 'unresponsive' | 'exit'`. */
|
|
20
20
|
phase?: string;
|
|
21
21
|
code?: number | null;
|
|
22
22
|
signal?: string | null;
|
|
@@ -45,6 +45,30 @@ export interface FrameComponentProps {
|
|
|
45
45
|
style?: StyleProp;
|
|
46
46
|
/** X display for the pane process; defaults to the inherited `$DISPLAY`. */
|
|
47
47
|
display?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Flags for the pane's process, after the ones it inherits from this one
|
|
50
|
+
* (a dev loader's): `['--max-old-space-size=256']` bounds the pane's heap,
|
|
51
|
+
* which nothing inside the pane can. They are handed to the runtime that
|
|
52
|
+
* forks it, so they are that runtime's flags. A change restarts the pane;
|
|
53
|
+
* the same flags in a new array do not.
|
|
54
|
+
*/
|
|
55
|
+
execArgv?: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* How long the pane may go without answering, in ms, before it is taken
|
|
58
|
+
* for one whose event loop is stuck: failed with phase `'unresponsive'`,
|
|
59
|
+
* its process ended, and `fallback` shown. Asked about once a second,
|
|
60
|
+
* from its event loop. Default 15000; `false` never. A pane started
|
|
61
|
+
* under the inspector is never watched.
|
|
62
|
+
*/
|
|
63
|
+
watchdog?: number | false;
|
|
64
|
+
/**
|
|
65
|
+
* Environment variables for the pane's process, over this one's; a value
|
|
66
|
+
* of `undefined` takes a variable away. `DISPLAY` is `display`'s and
|
|
67
|
+
* `REACT_X11_FRAME` marks the pane (`isFramed`), so neither can be set
|
|
68
|
+
* here. A change restarts the pane; the same variables in a new object do
|
|
69
|
+
* not.
|
|
70
|
+
*/
|
|
71
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
48
72
|
/**
|
|
49
73
|
* Which bridged contexts follow the app into this pane: `true` (default)
|
|
50
74
|
* for every registered one — the theme included — `false` for none, or an
|
|
@@ -75,10 +99,23 @@ export interface FrameComponentProps {
|
|
|
75
99
|
* (a loopback pair into `runFrameChild`), and the door to running a pane
|
|
76
100
|
* somewhere other than a child process. Advanced; see docs/frame.md.
|
|
77
101
|
*/
|
|
78
|
-
transport?(options:
|
|
102
|
+
transport?(options: FrameTransportOptions): FrameTransport;
|
|
79
103
|
ref?: Ref<FrameHandle>;
|
|
80
104
|
}
|
|
81
105
|
|
|
106
|
+
/** What a transport is asked to start: the pane module, and the display,
|
|
107
|
+
* flags and environment `<Frame>` names for its process. The default
|
|
108
|
+
* transport forks `react-x11/frame/child` with them. */
|
|
109
|
+
export interface FrameTransportOptions {
|
|
110
|
+
src: string;
|
|
111
|
+
display?: string;
|
|
112
|
+
/** `<Frame execArgv>`, or none: added to what the pane inherits. */
|
|
113
|
+
execArgv: readonly string[];
|
|
114
|
+
/** `<Frame env>`, or none: over this process's environment, `undefined`
|
|
115
|
+
* taking a variable away. */
|
|
116
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
117
|
+
}
|
|
118
|
+
|
|
82
119
|
/** The parent's end of the wire to a pane. */
|
|
83
120
|
export interface FrameTransport {
|
|
84
121
|
/** May throw when the channel is gone or the message will not clone. */
|