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.
- package/README.md +37 -0
- package/package.json +4 -3
- package/src/Reconciler.js +85 -22
- package/src/anchor.js +60 -18
- package/src/application.js +25 -1
- package/src/capabilities.js +349 -0
- package/src/cocoa/app.js +28 -9
- package/src/cocoa/context2d.js +139 -6
- 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/dbusmenuexport.js +243 -0
- package/src/desktopcapabilityhooks.js +160 -0
- package/src/filedialoghooks.js +3 -5
- package/src/frame/childmain.js +8 -20
- package/src/frame/env.js +2 -10
- package/src/globalmenu.js +3 -205
- package/src/icontheme.js +240 -0
- package/src/imagesource.js +98 -3
- package/src/index.d.ts +1 -0
- package/src/index.js +11 -2
- package/src/launcher.js +235 -32
- package/src/launcherhooks.js +47 -28
- 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 +65 -2
- 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 +752 -0
- package/src/styles.js +212 -8
- package/src/symbols.js +200 -0
- package/src/testing/mock-app.js +10 -0
- package/src/trayhooks.js +193 -29
- package/src/types/capabilities.d.ts +139 -0
- package/src/types/components.d.ts +33 -0
- package/src/types/elements.d.ts +57 -6
- package/src/types/launcher.d.ts +50 -4
- package/src/types/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +64 -6
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
|
+
}
|