react-x11 2.15.2 → 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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -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
+ }