react-x11 2.15.3 → 2.16.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 +37 -0
- package/package.json +3 -3
- package/src/Reconciler.js +85 -22
- package/src/anchor.js +60 -18
- package/src/capabilities.js +29 -4
- package/src/cocoa/app.js +15 -9
- package/src/cocoa/context2d.js +23 -0
- package/src/cocoa/fonts.js +78 -0
- package/src/cocoa/presenter.js +17 -0
- package/src/cocoa/promotion.js +20 -0
- package/src/cocoa/relaunch.js +8 -3
- package/src/cocoa/symbols.js +64 -0
- package/src/cocoa/threaded.js +24 -4
- package/src/cocoa/window.js +362 -139
- package/src/components/ProgressBar.js +1 -1
- package/src/components/Slider.js +72 -39
- package/src/components/anchor.js +7 -2
- package/src/components/index.js +1 -0
- package/src/components/theme.js +32 -28
- package/src/desktopcapabilityhooks.js +29 -6
- package/src/filedialoghooks.js +3 -5
- package/src/frame/childmain.js +8 -20
- package/src/frame/env.js +2 -10
- package/src/icontheme.js +240 -0
- package/src/imagesource.js +83 -1
- package/src/index.js +3 -0
- package/src/node.d.ts +7 -0
- package/src/nodes/animation.js +17 -47
- package/src/nodes/cascade.js +17 -2
- package/src/nodes/image.js +63 -1
- package/src/nodes/kinds.js +12 -0
- package/src/nodes/layout.js +5 -1
- package/src/nodes/node.js +17 -3
- package/src/nodes/paint.js +117 -0
- package/src/nodes/scope.js +259 -0
- package/src/nodes/scrollable.js +53 -6
- package/src/nodes/text.js +2 -0
- package/src/nodes/textarea.js +1 -1
- package/src/nodes/textinput.js +1 -1
- package/src/nodes/window/anchoring.js +45 -18
- package/src/nodes/window/flush.js +6 -5
- package/src/nodes/window/popup.js +10 -0
- package/src/nodes/window/size.js +40 -2
- package/src/nodes/window/window.js +41 -14
- package/src/registry.js +2 -1
- package/src/settings.js +332 -0
- package/src/statusnotifier.js +164 -17
- package/src/styles.js +212 -8
- package/src/symbols.js +200 -0
- package/src/testing/mock-app.js +10 -0
- package/src/trayhooks.js +21 -5
- package/src/types/capabilities.d.ts +13 -1
- package/src/types/components.d.ts +33 -0
- package/src/types/elements.d.ts +57 -6
- package/src/types/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +14 -2
|
@@ -75,8 +75,8 @@ export class WindowFlush {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* The backend's half of a frame's cost, where it has one: the Cocoa
|
|
78
|
-
* present — the swapchain flip
|
|
79
|
-
*
|
|
78
|
+
* present — the swapchain flip — runs after the flush returns, on the
|
|
79
|
+
* same thread, and is part of what the frame cost
|
|
80
80
|
* (src/cocoa/window.js reports it). An ntk window's present is one
|
|
81
81
|
* request, and reports nothing.
|
|
82
82
|
*/
|
|
@@ -335,11 +335,12 @@ export class WindowFlush {
|
|
|
335
335
|
// after every region: an entry drawn in one damage rect must not be
|
|
336
336
|
// evicted before the next rect of the same frame asks for it
|
|
337
337
|
this._paintCache?.endFrame();
|
|
338
|
-
// The swapchain seam: a backend presenting from
|
|
338
|
+
// The swapchain seam: a backend presenting from a swapchain has to
|
|
339
339
|
// know exactly which pixels each flush touched — several flushes can
|
|
340
340
|
// land between two presents, so reading only the last frame's rects
|
|
341
|
-
// would leave the
|
|
342
|
-
// painted. Feature-detected like presentFrame; null means
|
|
341
|
+
// would leave the next buffer it draws into stale where an earlier
|
|
342
|
+
// flush painted. Feature-detected like presentFrame; null means
|
|
343
|
+
// everything.
|
|
343
344
|
this.window.noteFrameDamage?.(damage ?? null);
|
|
344
345
|
if (frameHook) {
|
|
345
346
|
frameHook({
|
|
@@ -30,15 +30,25 @@ export class PopupNode extends WindowNode {
|
|
|
30
30
|
* from realize looked equivalent until `hidden` existed, and would have
|
|
31
31
|
* left a revealed menu holding no grab: open forever behind the first
|
|
32
32
|
* outside click, with nothing saying why.
|
|
33
|
+
*
|
|
34
|
+
* `grabKeyboard` is the keyboard's half, taken and dropped with the map
|
|
35
|
+
* the same way: keys come to this popup while it is up, whatever holds
|
|
36
|
+
* the focus — what a popover a tray click opened needs, since a menu-bar
|
|
37
|
+
* app has no window of its own for the keys to reach. On Cocoa it is a
|
|
38
|
+
* property of the window rather than a grab, and was decided when the
|
|
39
|
+
* window was made (src/cocoa/window.js); on Wayland a grabbing popup has
|
|
40
|
+
* the keyboard already.
|
|
33
41
|
*/
|
|
34
42
|
_mapNow() {
|
|
35
43
|
if (!super._mapNow()) return false;
|
|
36
44
|
if (this.props.grab) this.window.grabPointer?.({}, () => {});
|
|
45
|
+
if (this.props.grabKeyboard) this.window.grabKeyboard?.({}, () => {});
|
|
37
46
|
return true;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
destroySubtree() {
|
|
41
50
|
if (this.props.grab) this.window?.ungrabPointer?.();
|
|
51
|
+
if (this.props.grabKeyboard) this.window?.ungrabKeyboard?.();
|
|
42
52
|
super.destroySubtree();
|
|
43
53
|
}
|
|
44
54
|
|
package/src/nodes/window/size.js
CHANGED
|
@@ -398,6 +398,18 @@ export class WindowSize {
|
|
|
398
398
|
return { width: props.width, height: props.height, hints };
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
// **The floors on hand are the last frame's content.** A node whose
|
|
402
|
+
// content shrank still carries the minimum the old content needed — the
|
|
403
|
+
// automatic minimum size this window wrote into yoga — and a natural-size
|
|
404
|
+
// pass would read it back as content the tree cannot give up, so an
|
|
405
|
+
// `'auto'` window could only ever grow (#586). The stale ones come off
|
|
406
|
+
// first, which is exactly what `_applyContentFloors` does ahead of its
|
|
407
|
+
// own measurement: a node whose extent is stale gets the minimum its
|
|
408
|
+
// style asks for, and a node whose extent still stands keeps the floor it
|
|
409
|
+
// earned. The pass after this one measures and writes them again.
|
|
410
|
+
this._writeFloors('width');
|
|
411
|
+
this._writeFloors('height');
|
|
412
|
+
|
|
401
413
|
const dir = this._rootDirection;
|
|
402
414
|
const measure = () => {
|
|
403
415
|
this._sweepLayoutHosts();
|
|
@@ -525,9 +537,14 @@ export class WindowSize {
|
|
|
525
537
|
);
|
|
526
538
|
if (!tracking && !bounded) return;
|
|
527
539
|
const asked = this._requestedSize;
|
|
528
|
-
const
|
|
529
|
-
this._sendSizeHints(props,
|
|
540
|
+
const measured = this._measure();
|
|
541
|
+
this._sendSizeHints(props, measured.hints);
|
|
530
542
|
if (!tracking) return;
|
|
543
|
+
// The size the backend will really give, compared against the last one
|
|
544
|
+
// for the same reason it is recorded: two natural sizes a third of a
|
|
545
|
+
// point apart are one window size, and asking again every frame for a
|
|
546
|
+
// size that cannot change is a configure per frame.
|
|
547
|
+
const next = this._snapSize(measured);
|
|
531
548
|
if (asked && next.width === asked.width && next.height === asked.height) {
|
|
532
549
|
return;
|
|
533
550
|
}
|
|
@@ -553,6 +570,27 @@ export class WindowSize {
|
|
|
553
570
|
this._followAnchor({ width: next.width, height: next.height });
|
|
554
571
|
}
|
|
555
572
|
|
|
573
|
+
/**
|
|
574
|
+
* The size the backend will really take for one this window asks for.
|
|
575
|
+
*
|
|
576
|
+
* A window is not always free to be exactly the size its content wants:
|
|
577
|
+
* Cocoa puts one on the **point** grid, so a natural height of 201 device
|
|
578
|
+
* pixels at scale 2 is a window 202 tall. Every record of
|
|
579
|
+
* `_requestedSize` goes through here, because that record is what the
|
|
580
|
+
* resize echo is compared against — and an echo that does not match it
|
|
581
|
+
* reads as the user or the window manager taking the size over, which
|
|
582
|
+
* ends an `'auto'` window's tracking for good (#586). A backend that
|
|
583
|
+
* takes any size, X11's, answers with the size it was handed.
|
|
584
|
+
*/
|
|
585
|
+
_snapSize(size) {
|
|
586
|
+
return (
|
|
587
|
+
this.window?.snapSize?.(size.width, size.height) ?? {
|
|
588
|
+
width: size.width,
|
|
589
|
+
height: size.height,
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
556
594
|
/** One layout pass at the window's size, with the content floors it
|
|
557
595
|
* needs: fresh ones when something changed them, the ones in hand during
|
|
558
596
|
* a live resize, none when nothing moved them. */
|
|
@@ -11,7 +11,6 @@ import { forgetTopLevel, hasDropProps } from '../../dnd.js';
|
|
|
11
11
|
import { clearPendingFrame } from '../../frames.js';
|
|
12
12
|
import { FramePacer } from '../../pacing.js';
|
|
13
13
|
import { endWindowState } from '../../windowstate.js';
|
|
14
|
-
import { anchorOffscreen } from '../../anchor.js';
|
|
15
14
|
import { topLevelWindows } from '../../windowid.js';
|
|
16
15
|
import { WindowAnimation } from '../animation.js';
|
|
17
16
|
import { WindowCascade } from '../cascade.js';
|
|
@@ -153,6 +152,12 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
153
152
|
// one flag everything reads (`_mapNow`, painting, a11y, anchoring).
|
|
154
153
|
this._reactHidden = false;
|
|
155
154
|
this.hidden = Boolean(props.hidden);
|
|
155
|
+
// A `<ThemeProvider>` this window takes its palette from other than its
|
|
156
|
+
// parent — one written above it at the root, or one directly inside the
|
|
157
|
+
// window it is nested in, which handed it on — and a third writer of
|
|
158
|
+
// `hidden`, since React hides the provider's node rather than the window
|
|
159
|
+
// under it (nodes/scope.js). Null everywhere else.
|
|
160
|
+
this._scope = null;
|
|
156
161
|
// whether this is the tree's own top-level window rather than a nested
|
|
157
162
|
// one or a popup — decided by realize(), read when it maps
|
|
158
163
|
this._topLevel = false;
|
|
@@ -360,6 +365,11 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
360
365
|
attributes.eventMask = (attributes.eventMask ?? 0) | WINDOW_EVENT_MASK;
|
|
361
366
|
const wnd = this.app.createWindow(attributes);
|
|
362
367
|
this.window = wnd;
|
|
368
|
+
// What the window actually took, which is not always what was asked
|
|
369
|
+
// for: Cocoa puts a window on the point grid. The record is what the
|
|
370
|
+
// resize echo is compared against, so it has to be the size the echo
|
|
371
|
+
// will carry (#586, `_snapSize`).
|
|
372
|
+
this._requestedSize = { width: wnd.width, height: wnd.height };
|
|
363
373
|
// Now that the visual is known: settle the capabilities, re-resolve any
|
|
364
374
|
// `@supports` block against them, and start following the compositor.
|
|
365
375
|
// Before the first paint, and before children realize against it.
|
|
@@ -422,8 +432,7 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
422
432
|
// and vanish.
|
|
423
433
|
if (this.props.anchor) {
|
|
424
434
|
this._watchAnchor();
|
|
425
|
-
|
|
426
|
-
this._anchorLost = !node || anchorOffscreen(node, this.props.anchor.at);
|
|
435
|
+
this._anchorLost = this._anchorGone();
|
|
427
436
|
}
|
|
428
437
|
// Queued rather than mapped, when there is a commit to queue behind:
|
|
429
438
|
// React hides a subtree only once it has inserted it (beginWindowMaps).
|
|
@@ -747,7 +756,7 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
747
756
|
// The flag `realize()`'s map will read — set directly, since there is
|
|
748
757
|
// nothing on screen yet for the notification half of `_applyHidden`
|
|
749
758
|
// to be about.
|
|
750
|
-
this.hidden = this.
|
|
759
|
+
this.hidden = this._hiddenByReact() || Boolean(newProps.hidden);
|
|
751
760
|
return;
|
|
752
761
|
}
|
|
753
762
|
|
|
@@ -804,10 +813,10 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
804
813
|
const geo = scaleWindowGeometry(newProps, this.scale);
|
|
805
814
|
if (sizeChanged) {
|
|
806
815
|
this._userSized = false;
|
|
807
|
-
this._requestedSize = {
|
|
816
|
+
this._requestedSize = this._snapSize({
|
|
808
817
|
width: isAutoSize(geo.width) ? wnd.width : geo.width,
|
|
809
818
|
height: isAutoSize(geo.height) ? wnd.height : geo.height,
|
|
810
|
-
};
|
|
819
|
+
});
|
|
811
820
|
}
|
|
812
821
|
if (geometryChanged) {
|
|
813
822
|
if (typeof wnd.setState === 'function') {
|
|
@@ -821,8 +830,15 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
821
830
|
height: isAutoSize(geo.height) ? undefined : geo.height,
|
|
822
831
|
});
|
|
823
832
|
} else {
|
|
824
|
-
|
|
825
|
-
|
|
833
|
+
// A window with no `setState` — Cocoa's — is resized whole or not at
|
|
834
|
+
// all, so an axis handed back to `'auto'` goes as the size recorded
|
|
835
|
+
// for it above: the one the window has, which this change leaves
|
|
836
|
+
// alone. Skipping the call for one `'auto'` axis dropped the other
|
|
837
|
+
// axis's change on the floor: `_refit()` then found the record
|
|
838
|
+
// already matching and sent nothing either (#585). With both axes
|
|
839
|
+
// `'auto'` there is no size to send, and `_refit()` works one out.
|
|
840
|
+
if (sizeChanged && !(isAutoSize(geo.width) && isAutoSize(geo.height))) {
|
|
841
|
+
wnd.resize?.(this._requestedSize.width, this._requestedSize.height);
|
|
826
842
|
}
|
|
827
843
|
if (movedByProps) {
|
|
828
844
|
wnd.move?.(geo.x, geo.y);
|
|
@@ -887,14 +903,25 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
887
903
|
}
|
|
888
904
|
|
|
889
905
|
/**
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
906
|
+
* Whether React is hiding this window: its own flag, or one on the theme
|
|
907
|
+
* scope it is written under — a `<Suspense>` around a `<ThemeProvider>` at
|
|
908
|
+
* the root hides the provider's node, the topmost host instance there.
|
|
909
|
+
* Kept apart from the window's own flag so an inner boundary that still
|
|
910
|
+
* hides the window is not overruled when an outer one reveals the scope.
|
|
911
|
+
*/
|
|
912
|
+
_hiddenByReact() {
|
|
913
|
+
return this._reactHidden || (this._scope?._hiddenByReact() ?? false);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Re-derive `this.hidden` from its writers — React, through
|
|
918
|
+
* `_hiddenByReact`, and the `hidden` prop — and make the window agree.
|
|
919
|
+
* Either saying "hidden" wins, so a `<Suspense>` revealing its content does
|
|
920
|
+
* not map a window the app is holding off screen, and clearing the prop
|
|
921
|
+
* does not map one React still hides.
|
|
895
922
|
*/
|
|
896
923
|
_applyHidden() {
|
|
897
|
-
const hidden = this.
|
|
924
|
+
const hidden = this._hiddenByReact() || Boolean(this.props.hidden);
|
|
898
925
|
if (hidden === this.hidden) return;
|
|
899
926
|
this.hidden = hidden;
|
|
900
927
|
// An unmapped window draws nothing, so a loop inside one is frames
|
package/src/registry.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
DRAWN_KINDS,
|
|
29
29
|
CUSTOM_SEMANTIC_NAMES,
|
|
30
30
|
CUSTOM_SELF_DAMAGED,
|
|
31
|
+
THEME_SCOPE,
|
|
31
32
|
} from './nodes/kinds.js';
|
|
32
33
|
import { Node } from './nodes/node.js';
|
|
33
34
|
import { markLayoutsHotReloadSession } from './layouts.js';
|
|
@@ -35,7 +36,7 @@ import { markLayoutsHotReloadSession } from './layouts.js';
|
|
|
35
36
|
/** kind -> definition. Insertion-ordered, which is the order errors list. */
|
|
36
37
|
const registry = new Map();
|
|
37
38
|
|
|
38
|
-
const RESERVED = new Set(['textchunk', 'svgchild']);
|
|
39
|
+
const RESERVED = new Set(['textchunk', 'svgchild', THEME_SCOPE]);
|
|
39
40
|
|
|
40
41
|
// The re-registration policy for hot reload (issue #318). Module-scope
|
|
41
42
|
// registration is the pattern the docs recommend and tree-shaking forces on
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// What an app remembers between launches (#592): a store of JSON values in
|
|
2
|
+
// the per-user directory for this app, read once, written atomically, and
|
|
3
|
+
// coalesced so a slider's stream of changes is one write when it settles.
|
|
4
|
+
//
|
|
5
|
+
// const settings = createSettings({
|
|
6
|
+
// appId: 'com.example.Hush',
|
|
7
|
+
// defaults: { noiseType: 'brown', volume: 0.5, dark: false },
|
|
8
|
+
// });
|
|
9
|
+
// const [volume, setVolume] = settings.use('volume');
|
|
10
|
+
//
|
|
11
|
+
// Every app that remembered anything did this by hand — a directory per
|
|
12
|
+
// platform, serialize, write, debounce — and the easy version of it is wrong
|
|
13
|
+
// in three quiet ways: a crash mid-write leaves half a file, a drag writes on
|
|
14
|
+
// every event, and quitting inside the debounce loses the last change.
|
|
15
|
+
//
|
|
16
|
+
// ## Where
|
|
17
|
+
//
|
|
18
|
+
// One JSON file, `settings.json`, in the app's own directory: under
|
|
19
|
+
// `~/Library/Application Support` on macOS and `$XDG_CONFIG_HOME` (`~/.config`)
|
|
20
|
+
// elsewhere. A file rather than `NSUserDefaults` on macOS, so the format and
|
|
21
|
+
// the behaviour are one thing on every platform. The directory is named by
|
|
22
|
+
// the app id, which is a reverse-DNS name like the one `registerApplication`
|
|
23
|
+
// takes — the store does not register anything, it only needs a name no
|
|
24
|
+
// other app is using.
|
|
25
|
+
//
|
|
26
|
+
// ## When it is read and written
|
|
27
|
+
//
|
|
28
|
+
// Read **synchronously**, the first time a value is asked for, so the first
|
|
29
|
+
// render already has what was saved: an asynchronous read would render the
|
|
30
|
+
// defaults and then jump. It is a small file read once. Written
|
|
31
|
+
// **asynchronously**, a quarter second after the last change and at least
|
|
32
|
+
// once a second while changes keep coming, through a temporary file and a
|
|
33
|
+
// rename, which is atomic: the file on disk is always the old one or the new
|
|
34
|
+
// one. Whatever is still waiting is written synchronously when the process
|
|
35
|
+
// exits, and `flush()` writes it now.
|
|
36
|
+
//
|
|
37
|
+
// Two processes of the same app share the file and the last write wins;
|
|
38
|
+
// nothing here watches for another process changing it.
|
|
39
|
+
|
|
40
|
+
import * as nodeFs from 'node:fs';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
43
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
44
|
+
|
|
45
|
+
/** A reverse-DNS app id: two or more dot-separated elements, the grammar
|
|
46
|
+
* `registerApplication` checks, which is also a safe directory name. */
|
|
47
|
+
const APP_ID_RE = /^[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+$/;
|
|
48
|
+
|
|
49
|
+
/** How long a change waits for the next one, and how long a stream of
|
|
50
|
+
* changes may put a write off. */
|
|
51
|
+
const DELAY_MS = 250;
|
|
52
|
+
const MAX_WAIT_MS = 1000;
|
|
53
|
+
|
|
54
|
+
/** The per-user directory apps keep their settings under, for a platform. */
|
|
55
|
+
export function settingsBaseDir({
|
|
56
|
+
platform = process.platform,
|
|
57
|
+
env = process.env,
|
|
58
|
+
home = homedir(),
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (platform === 'darwin') {
|
|
61
|
+
return join(home, 'Library', 'Application Support');
|
|
62
|
+
}
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
return env.APPDATA || join(home, 'AppData', 'Roaming');
|
|
65
|
+
}
|
|
66
|
+
return env.XDG_CONFIG_HOME || join(home, '.config');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One store per file in a process, so a module evaluated twice — a hot
|
|
70
|
+
* reload — and two call sites naming the same app share their values
|
|
71
|
+
* rather than overwriting each other's writes. */
|
|
72
|
+
const stores = new Map();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The settings store for `appId`: values read from and written to its
|
|
76
|
+
* `settings.json`, with `defaults` for what was never saved.
|
|
77
|
+
*/
|
|
78
|
+
export function createSettings(options = {}) {
|
|
79
|
+
const { appId, defaults = {}, directory, fs = nodeFs } = options;
|
|
80
|
+
if (typeof appId !== 'string' || !APP_ID_RE.test(appId)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`react-x11: createSettings({ appId: ${JSON.stringify(appId)} }) — the ` +
|
|
83
|
+
"app id names the app's settings directory, so it is a reverse-DNS " +
|
|
84
|
+
'name no other app uses: two or more dot-separated elements of ' +
|
|
85
|
+
'[A-Za-z_-][A-Za-z0-9_-]*, like "com.example.myapp".',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (defaults === null || typeof defaults !== 'object') {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'react-x11: createSettings({ defaults }) — expected an object of each ' +
|
|
91
|
+
"setting's value when nothing was saved, like { volume: 0.5 }.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const path = join(
|
|
95
|
+
directory ?? join(settingsBaseDir(), appId),
|
|
96
|
+
'settings.json',
|
|
97
|
+
);
|
|
98
|
+
let store = stores.get(path);
|
|
99
|
+
if (!store) {
|
|
100
|
+
store = new SettingsStore(path, fs, options);
|
|
101
|
+
stores.set(path, store);
|
|
102
|
+
}
|
|
103
|
+
// the latest module's defaults win, as a reload's edit to them should
|
|
104
|
+
store._defaults = { ...defaults };
|
|
105
|
+
return store.api;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class SettingsStore {
|
|
109
|
+
constructor(path, fs, { delay = DELAY_MS, maxWait = MAX_WAIT_MS }) {
|
|
110
|
+
this.path = path;
|
|
111
|
+
this.fs = fs;
|
|
112
|
+
this.delay = delay;
|
|
113
|
+
this.maxWait = maxWait;
|
|
114
|
+
this._defaults = {};
|
|
115
|
+
this._fallbacks = new Map(); // key -> the first object fallback asked with
|
|
116
|
+
this._values = null; // what was saved, read on first use
|
|
117
|
+
this._listeners = new Set();
|
|
118
|
+
this._timer = null;
|
|
119
|
+
this._firstPending = 0; // when the oldest unwritten change was made
|
|
120
|
+
// Changes are counted, and a write records the count it wrote, so a
|
|
121
|
+
// change is unsaved until a write *finished* with it in — which is what
|
|
122
|
+
// the exit path asks, since a write still in flight when the process
|
|
123
|
+
// exits never finishes.
|
|
124
|
+
this._version = 0;
|
|
125
|
+
this._savedVersion = 0;
|
|
126
|
+
this._writing = null; // the write in flight
|
|
127
|
+
this._onExit = () => this._flushSync();
|
|
128
|
+
|
|
129
|
+
const store = this;
|
|
130
|
+
this.api = {
|
|
131
|
+
path,
|
|
132
|
+
get: (key, fallback) => store.get(key, fallback),
|
|
133
|
+
set: (key, value) => store.set(key, value),
|
|
134
|
+
reset: (key) => store.reset(key),
|
|
135
|
+
flush: () => store.flush(),
|
|
136
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
137
|
+
/**
|
|
138
|
+
* `[value, setValue]` for one setting, like `useState` — and every
|
|
139
|
+
* component using the same key, in any window, sees the same value.
|
|
140
|
+
*/
|
|
141
|
+
use(key, fallback) {
|
|
142
|
+
const subscribe = useCallback(
|
|
143
|
+
(listener) => store.subscribe(listener),
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
const value = useSyncExternalStore(subscribe, () =>
|
|
147
|
+
store.get(key, fallback),
|
|
148
|
+
);
|
|
149
|
+
const setValue = useCallback(
|
|
150
|
+
(next) =>
|
|
151
|
+
store.set(
|
|
152
|
+
key,
|
|
153
|
+
typeof next === 'function'
|
|
154
|
+
? next(store.get(key, fallback))
|
|
155
|
+
: next,
|
|
156
|
+
),
|
|
157
|
+
[key, fallback],
|
|
158
|
+
);
|
|
159
|
+
return [value, setValue];
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_load() {
|
|
165
|
+
if (this._values) return this._values;
|
|
166
|
+
this._values = {};
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = this.fs.readFileSync(this.path, 'utf8');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// never saved is the ordinary first launch; anything else is worth a line
|
|
172
|
+
if (err?.code !== 'ENOENT') {
|
|
173
|
+
console.warn(
|
|
174
|
+
`react-x11: settings at ${this.path} could not be read ` +
|
|
175
|
+
`(${err.message}); the defaults stand.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return this._values;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(text);
|
|
182
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
183
|
+
this._values = parsed;
|
|
184
|
+
return this._values;
|
|
185
|
+
}
|
|
186
|
+
throw new Error('not an object');
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(
|
|
189
|
+
`react-x11: settings at ${this.path} are not a JSON object ` +
|
|
190
|
+
`(${err.message}); the defaults stand, and the next change ` +
|
|
191
|
+
'replaces the file.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return this._values;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get(key, fallback) {
|
|
198
|
+
const values = this._load();
|
|
199
|
+
if (Object.hasOwn(values, key)) return values[key];
|
|
200
|
+
if (Object.hasOwn(this._defaults, key)) return this._defaults[key];
|
|
201
|
+
// An object literal is a new object every render, and a hook's snapshot
|
|
202
|
+
// that changes every time it is read never settles: the first one stands.
|
|
203
|
+
if (fallback !== null && typeof fallback === 'object') {
|
|
204
|
+
if (!this._fallbacks.has(key)) this._fallbacks.set(key, fallback);
|
|
205
|
+
return this._fallbacks.get(key);
|
|
206
|
+
}
|
|
207
|
+
return fallback;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
set(key, value) {
|
|
211
|
+
let text;
|
|
212
|
+
try {
|
|
213
|
+
text = JSON.stringify(value);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
`react-x11: settings.set(${JSON.stringify(key)}) — the value has to ` +
|
|
217
|
+
`be JSON: ${err.message}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (text === undefined) {
|
|
221
|
+
throw new TypeError(
|
|
222
|
+
`react-x11: settings.set(${JSON.stringify(key)}) — ${typeof value} is ` +
|
|
223
|
+
'not a value JSON can keep; use reset() to go back to the default.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const values = this._load();
|
|
227
|
+
if (Object.hasOwn(values, key) && values[key] === value) return;
|
|
228
|
+
this._values = { ...values, [key]: value };
|
|
229
|
+
this._changed();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
reset(key) {
|
|
233
|
+
const values = this._load();
|
|
234
|
+
if (!Object.hasOwn(values, key)) return;
|
|
235
|
+
const rest = { ...values };
|
|
236
|
+
delete rest[key];
|
|
237
|
+
this._values = rest;
|
|
238
|
+
this._changed();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
subscribe(listener) {
|
|
242
|
+
this._listeners.add(listener);
|
|
243
|
+
return () => this._listeners.delete(listener);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
get _unsaved() {
|
|
247
|
+
return this._savedVersion !== this._version;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_changed() {
|
|
251
|
+
for (const listener of [...this._listeners]) listener();
|
|
252
|
+
if (!this._unsaved) {
|
|
253
|
+
this._firstPending = Date.now();
|
|
254
|
+
process.once('exit', this._onExit);
|
|
255
|
+
}
|
|
256
|
+
this._version++;
|
|
257
|
+
clearTimeout(this._timer);
|
|
258
|
+
const waited = Date.now() - this._firstPending;
|
|
259
|
+
const wait = Math.max(0, Math.min(this.delay, this.maxWait - waited));
|
|
260
|
+
this._timer = setTimeout(() => {
|
|
261
|
+
this._timer = null;
|
|
262
|
+
this.flush().catch((err) => {
|
|
263
|
+
console.warn(
|
|
264
|
+
`react-x11: settings at ${this.path} could not be written ` +
|
|
265
|
+
`(${err.message}).`,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
}, wait);
|
|
269
|
+
// a pending write must not be what keeps the process alive
|
|
270
|
+
this._timer.unref?.();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Write what is waiting now. Resolves when it is on disk. */
|
|
274
|
+
async flush() {
|
|
275
|
+
clearTimeout(this._timer);
|
|
276
|
+
this._timer = null;
|
|
277
|
+
// one write at a time, and a change made during one is written after it;
|
|
278
|
+
// the one before failing is that write's to report, not this one's
|
|
279
|
+
while (this._writing) await this._writing.catch(() => {});
|
|
280
|
+
if (!this._unsaved) return;
|
|
281
|
+
const version = this._version;
|
|
282
|
+
const text = `${JSON.stringify(this._values, null, 2)}\n`;
|
|
283
|
+
this._writing = this._write(text).finally(() => {
|
|
284
|
+
this._writing = null;
|
|
285
|
+
});
|
|
286
|
+
await this._writing;
|
|
287
|
+
this._savedVersion = version;
|
|
288
|
+
// saved, unless something changed while it was written
|
|
289
|
+
if (!this._unsaved) process.removeListener('exit', this._onExit);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _write(text) {
|
|
293
|
+
const fs = this.fs.promises;
|
|
294
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
295
|
+
await fs.mkdir(dirname(this.path), { recursive: true });
|
|
296
|
+
const handle = await fs.open(temp, 'w');
|
|
297
|
+
try {
|
|
298
|
+
await handle.writeFile(text);
|
|
299
|
+
await handle.sync();
|
|
300
|
+
} finally {
|
|
301
|
+
await handle.close();
|
|
302
|
+
}
|
|
303
|
+
await fs.rename(temp, this.path);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The exit path, where nothing asynchronous runs any more. */
|
|
307
|
+
_flushSync() {
|
|
308
|
+
if (!this._unsaved) return;
|
|
309
|
+
this._savedVersion = this._version;
|
|
310
|
+
try {
|
|
311
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
312
|
+
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
313
|
+
this.fs.writeFileSync(temp, `${JSON.stringify(this._values, null, 2)}\n`);
|
|
314
|
+
this.fs.renameSync(temp, this.path);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.warn(
|
|
317
|
+
`react-x11: settings at ${this.path} could not be written on exit ` +
|
|
318
|
+
`(${err.message}).`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Forget every store — for tests that create stores over temporary
|
|
325
|
+
* directories and want the next `createSettings` to read afresh. */
|
|
326
|
+
export function resetSettingsForTests() {
|
|
327
|
+
for (const store of stores.values()) {
|
|
328
|
+
clearTimeout(store._timer);
|
|
329
|
+
process.removeListener('exit', store._onExit);
|
|
330
|
+
}
|
|
331
|
+
stores.clear();
|
|
332
|
+
}
|