react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,748 @@
1
+ // What the desktop looks like, as four values an app can render from:
2
+ // light or dark, the accent colour, contrast, and whether the user asked for
3
+ // less motion.
4
+ //
5
+ // ## Why this is a ladder and not a call
6
+ //
7
+ // There is no cross-toolkit palette protocol. Nothing on a Linux desktop lets
8
+ // one toolkit ask another "what colour is a window background" — what exists
9
+ // is a shared *theme name*, each toolkit shipping its own implementation of a
10
+ // theme by that name, and exactly four standardised appearance **values**
11
+ // behind `org.freedesktop.portal.Settings`. Those four are what this reads,
12
+ // and the ladder is what happens where the portal is not running:
13
+ //
14
+ // 1. **the settings portal** — the real contract, the only source with an
15
+ // accent colour, live over `SettingChanged`. libadwaita, Qt 6.5+,
16
+ // Firefox and Electron all read this one.
17
+ // 2. **XSETTINGS** — pre-D-Bus, X11-only, and thin here: `Net/ThemeName`
18
+ // is a *name*, so "is this dark" comes down to trusting the `-dark`
19
+ // suffix. That guess is what the portal was invented to replace, which
20
+ // is why it is a fallback and never a correction.
21
+ // 3. **macOS** — `NSUserDefaults` and `NSWorkspace` through one long-lived
22
+ // `osascript` child, which is the only source on a Mac: a stock XQuartz
23
+ // has no portal, no XSETTINGS manager and an unset `RESOURCE_MANAGER`.
24
+ //
25
+ // `RESOURCE_MANAGER` is deliberately **not** a rung. It is where `Xft.dpi`,
26
+ // `Xft.rgba` and `Xcursor.*` live — font and cursor rendering — and there has
27
+ // never been an X resource for colour scheme, accent or contrast. It has
28
+ // nothing to say about any of the four values here.
29
+ //
30
+ // ## The rungs disagree, so the ladder is strictly ordered
31
+ //
32
+ // Measured on one GNOME 49 session, at one moment: the portal reports
33
+ // `reduced-motion: 0` while GNOME's own `enable-animations` is `false`.
34
+ // Taking the best-answered field from each rung would produce a combination
35
+ // no single desktop actually believes, so the first rung that answers owns
36
+ // every field, and the ones below it are not consulted again.
37
+ //
38
+ // ## The first render does not start from nothing
39
+ //
40
+ // None of these values changes more than a few times in a machine's life, so
41
+ // the answer is written to the cache directory and read back — synchronously,
42
+ // on the first read — before any rung has been asked. The first frame is
43
+ // drawn in the colours this desktop had last time and the ladder revalidates
44
+ // behind it, which is the only way to be right on frame one without making
45
+ // startup wait for D-Bus.
46
+ //
47
+ // ## Nothing here holds the process open
48
+ //
49
+ // The subscription is installed and the bus ref is *released*: the match rule
50
+ // stays on the shared connection, but the socket goes back to `unref()`d. An
51
+ // app whose windows have closed still exits; an app with a window on screen
52
+ // is awake anyway and gets the signal. Same for the macOS child, which is
53
+ // spawned `unref()`d and killed on exit.
54
+
55
+ import fs from 'node:fs';
56
+ import os from 'node:os';
57
+ import path from 'node:path';
58
+
59
+ import { sessionBus } from './bus.js';
60
+ import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
61
+ import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
62
+
63
+ const SETTINGS_IFACE = 'org.freedesktop.portal.Settings';
64
+ const APPEARANCE_NS = 'org.freedesktop.appearance';
65
+
66
+ /**
67
+ * What is known before anything has answered, and what stays true on a
68
+ * machine with none of the three sources.
69
+ *
70
+ * `'no-preference'` is the desktop declining to say — which per the portal
71
+ * spec means *use your own default*, not *use light*. It is not a loading
72
+ * state: `source` is what distinguishes "nobody has been asked yet" (null)
73
+ * from "asked, and this desktop has no opinion" — and `'cache'`, which is
74
+ * "this is what the answer was last time, and nobody has been asked yet".
75
+ */
76
+ const NOTHING = Object.freeze({
77
+ colorScheme: 'no-preference',
78
+ accent: null,
79
+ contrast: 'normal',
80
+ reducedMotion: false,
81
+ source: null,
82
+ });
83
+
84
+ /**
85
+ * The current answer, as one frozen object that is replaced rather than
86
+ * mutated.
87
+ *
88
+ * Identity matters: `useSystemAppearance()` reads this through
89
+ * `useSyncExternalStore`, whose `getSnapshot` must return the *same* object
90
+ * until something actually changes. Building `{ colorScheme, accent, … }` per
91
+ * call — the obvious shape — makes React see a new value every render and
92
+ * loop.
93
+ */
94
+ let snapshot = NOTHING;
95
+
96
+ /** Which rung owns the snapshot; once one does, the ones below it stop. */
97
+ let owner = null;
98
+
99
+ /** The in-flight ladder run, shared by concurrent callers. */
100
+ let probe = null;
101
+
102
+ const watchers = new Set();
103
+
104
+ // --------------------------------------------------------------------------
105
+ // Publishing
106
+ // --------------------------------------------------------------------------
107
+
108
+ const SAME = (a, b) =>
109
+ a.colorScheme === b.colorScheme &&
110
+ a.accent === b.accent &&
111
+ a.contrast === b.contrast &&
112
+ a.reducedMotion === b.reducedMotion &&
113
+ a.source === b.source;
114
+
115
+ /**
116
+ * Publish a rung's answer. A rung that reports the values it already reported
117
+ * notifies nobody — desktops re-announce settings for reasons of their own,
118
+ * and a re-render per announcement would be churn with nothing behind it.
119
+ */
120
+ function publish(values, source) {
121
+ const next = Object.freeze({ ...NOTHING, ...values, source });
122
+ if (SAME(next, snapshot)) return;
123
+ snapshot = next;
124
+ // What came *off* the disk does not go back onto it, and a pinned test
125
+ // value must never reach a developer's real cache file.
126
+ if (source !== 'cache' && source !== 'test') save(next);
127
+ for (const fn of [...watchers]) {
128
+ try {
129
+ fn(snapshot);
130
+ } catch {
131
+ // a subscriber that throws must not take the others with it
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Pin the appearance, and stop the ladder from running at all.
138
+ *
139
+ * `react-x11/test`'s `renderX11` calls this, and that is not a convenience —
140
+ * it is what keeps a test suite from rendering differently on a developer's
141
+ * dark desktop. The default palette now follows the system, so without a pin
142
+ * every pixel assertion in every suite, here and in applications, would be a
143
+ * function of whoever ran it.
144
+ *
145
+ * Pass `null` to release the pin and let the ladder run again.
146
+ */
147
+ export function setAppearanceForTests(values) {
148
+ probe = null;
149
+ // Never touch the developer's own remembered answer either, in either
150
+ // direction: `publish` will not write a pinned value, and marking the cache
151
+ // as already checked keeps `load()` from reading one back over it.
152
+ cacheChecked = true;
153
+ if (values === null) {
154
+ cacheChecked = false;
155
+ // Releasing undoes a *pin*, and only a pin. A real rung's answer is not
156
+ // this function's to throw away: `cleanup()` releases after every test,
157
+ // and a suite that resolved the appearance for real would otherwise find
158
+ // it wiped by the harness that was meant to leave it alone.
159
+ if (owner === 'test') {
160
+ owner = null;
161
+ publish({}, null);
162
+ }
163
+ return;
164
+ }
165
+ owner = 'test';
166
+ publish(values, 'test');
167
+ }
168
+
169
+ // --------------------------------------------------------------------------
170
+ // The last known answer
171
+ // --------------------------------------------------------------------------
172
+ //
173
+ // None of this changes more than a few times in a machine's life, so the
174
+ // first render does not have to start from the defaults: the answer from last
175
+ // time is on disk, it is read synchronously before anything else happens, and
176
+ // the ladder revalidates it in the background. Stale-while-revalidate, and
177
+ // the stale value is almost always the right one.
178
+ //
179
+ // It is written to the **cache** directory rather than a dotfile in $HOME,
180
+ // because that is exactly what it is — regenerable, disposable, and nothing a
181
+ // user would ever want to edit. `REACT_X11_NO_APPEARANCE_CACHE=1` turns it
182
+ // off for a process that must not touch the disk.
183
+
184
+ const CACHE_VERSION = 1;
185
+ let cacheChecked = false;
186
+
187
+ function cacheFile() {
188
+ if (process.env.REACT_X11_NO_APPEARANCE_CACHE) return null;
189
+ let base = process.env.XDG_CACHE_HOME;
190
+ if (!base) {
191
+ let home;
192
+ try {
193
+ home = os.homedir();
194
+ } catch {
195
+ return null;
196
+ }
197
+ // '/' is what the browser bundle's `os` shim answers; a process with no
198
+ // home has nowhere to put this and does without.
199
+ if (!home || home === '/') return null;
200
+ base =
201
+ process.platform === 'darwin'
202
+ ? path.join(home, 'Library', 'Caches')
203
+ : path.join(home, '.cache');
204
+ }
205
+ return path.join(base, 'react-x11', 'appearance.json');
206
+ }
207
+
208
+ const SCHEMES = new Set(['light', 'dark', 'no-preference']);
209
+
210
+ /**
211
+ * The file is ordinary user-writable JSON that has been sitting on a disk
212
+ * since some previous run, so every field is checked rather than trusted —
213
+ * `accent` in particular goes straight into a style, and the shape of a
214
+ * colour is the one thing worth being sure of.
215
+ */
216
+ function sanitize(saved) {
217
+ return {
218
+ colorScheme: SCHEMES.has(saved?.colorScheme)
219
+ ? saved.colorScheme
220
+ : 'no-preference',
221
+ accent: /^#[0-9a-f]{6}$/i.test(saved?.accent) ? saved.accent : null,
222
+ contrast: saved?.contrast === 'high' ? 'high' : 'normal',
223
+ reducedMotion: saved?.reducedMotion === true,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Seed the snapshot from disk. Once, synchronously, on the first read —
229
+ * never at import, so a process that does not ask what colour the desktop is
230
+ * never touches the filesystem for it.
231
+ *
232
+ * It assigns rather than publishing: this runs from `appearanceSnapshot()`,
233
+ * which React calls **during render**, and notifying subscribers from there
234
+ * is how you get a warning about updating a component while rendering. There
235
+ * is nothing to notify anyway — a subscription is an effect, so it cannot
236
+ * exist before the first render has read the store.
237
+ */
238
+ function load() {
239
+ if (cacheChecked) return;
240
+ cacheChecked = true;
241
+ const file = cacheFile();
242
+ if (!file) return;
243
+ try {
244
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
245
+ if (saved?.v !== CACHE_VERSION) return;
246
+ snapshot = Object.freeze({ ...sanitize(saved), source: 'cache' });
247
+ } catch {
248
+ // no cache yet, an unreadable one, a full disk, a container with no
249
+ // home — every one of them means the defaults stand, which is what they
250
+ // are for
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Write what a rung answered, for the next process to start with.
256
+ *
257
+ * Through a temporary file and a rename, which is atomic on POSIX: two apps
258
+ * launched together would otherwise be able to leave a half-written file for
259
+ * a third to parse. Synchronous, because it is 150 bytes and happens once at
260
+ * startup and once per theme change — an async write would buy a fraction of
261
+ * a millisecond and cost an error path that has to be got right.
262
+ */
263
+ function save(values) {
264
+ const file = cacheFile();
265
+ if (!file) return;
266
+ const temporary = `${file}.${process.pid}`;
267
+ try {
268
+ fs.mkdirSync(path.dirname(file), { recursive: true });
269
+ fs.writeFileSync(
270
+ temporary,
271
+ JSON.stringify({
272
+ v: CACHE_VERSION,
273
+ colorScheme: values.colorScheme,
274
+ accent: values.accent,
275
+ contrast: values.contrast,
276
+ reducedMotion: values.reducedMotion,
277
+ }),
278
+ );
279
+ fs.renameSync(temporary, file);
280
+ } catch {
281
+ try {
282
+ fs.unlinkSync(temporary);
283
+ } catch {
284
+ // read-only home, no disk space, no filesystem at all: the cache is an
285
+ // optimisation and never a requirement
286
+ }
287
+ }
288
+ }
289
+
290
+ // --------------------------------------------------------------------------
291
+ // Rung 1: org.freedesktop.portal.Settings
292
+ // --------------------------------------------------------------------------
293
+
294
+ /**
295
+ * `0` no preference, `1` **dark**, `2` **light**, anything else no
296
+ * preference.
297
+ *
298
+ * The ordering is the trap. 1-is-dark reads backwards, so a comparison
299
+ * written from memory inverts the whole desktop — and inverts it only on the
300
+ * machines that expressed a preference, which are the ones that care.
301
+ */
302
+ function schemeFromPortal(value) {
303
+ if (value === 1) return 'dark';
304
+ if (value === 2) return 'light';
305
+ return 'no-preference';
306
+ }
307
+
308
+ /**
309
+ * `(ddd)` of sRGB values in [0, 1] → `'#rrggbb'`, or null when unset.
310
+ *
311
+ * The spec spells "no accent colour" as values outside the range, which is
312
+ * `(-1, -1, -1)` in practice. A string rather than the triple because every
313
+ * style in this renderer takes a CSS colour: handing back `{ r: 0.93, … }`
314
+ * invites `rgb(0.93, 0.36, 0)`, which is black.
315
+ */
316
+ function accentFromPortal(triple) {
317
+ if (!Array.isArray(triple) || triple.length < 3) return null;
318
+ const channels = triple.slice(0, 3);
319
+ if (!channels.every((c) => typeof c === 'number' && c >= 0 && c <= 1)) {
320
+ return null;
321
+ }
322
+ return (
323
+ '#' +
324
+ channels
325
+ .map((c) =>
326
+ Math.round(c * 255)
327
+ .toString(16)
328
+ .padStart(2, '0'),
329
+ )
330
+ .join('')
331
+ );
332
+ }
333
+
334
+ /** The `org.freedesktop.appearance` dict → the four values. */
335
+ export function fromPortal(ns = {}) {
336
+ return {
337
+ colorScheme: schemeFromPortal(ns['color-scheme']),
338
+ accent: accentFromPortal(ns['accent-color']),
339
+ contrast: ns.contrast === 1 ? 'high' : 'normal',
340
+ // version 2 of the interface; on version 1 the key is simply absent and
341
+ // "no" is the right answer
342
+ reducedMotion: ns['reduced-motion'] === 1,
343
+ };
344
+ }
345
+
346
+ async function portalRung() {
347
+ const ref = await sessionBus();
348
+ if (!ref) return false;
349
+
350
+ let sub = null;
351
+ try {
352
+ // **Subscribe before reading.** A change landing between the read and the
353
+ // match rule is lost, and nothing ever corrects it — the app stays stale
354
+ // for its whole lifetime. Same shape as the portal Request race, and the
355
+ // cost of getting it right is the order of two lines.
356
+ sub = await ref.bus.watch(
357
+ `type='signal',sender='${PORTAL_NAME}',` +
358
+ `interface='${SETTINGS_IFACE}',member='SettingChanged'`,
359
+ );
360
+
361
+ // **And attach the handler before reading too.** The match rule alone
362
+ // only makes the daemon route the signal here; with no listener on the
363
+ // other end it is still dropped, and a change that landed while the
364
+ // initial read was in flight is exactly the change this ordering exists
365
+ // to catch.
366
+ //
367
+ // Which makes the two reads racy, so they are sequenced: a read never
368
+ // publishes over the answer of one that was *started* after it, however
369
+ // the replies happen to arrive.
370
+ let started = 0;
371
+ let published = 0;
372
+ const refresh = async () => {
373
+ const mine = ++started;
374
+ const values = await readAppearance(ref.bus);
375
+ if (values && mine > published) {
376
+ published = mine;
377
+ publish(fromPortal(values), 'portal');
378
+ }
379
+ return values;
380
+ };
381
+
382
+ const key = ref.bus.mangle(PORTAL_PATH, SETTINGS_IFACE, 'SettingChanged');
383
+ ref.bus.signals.on(key, ([namespace]) => {
384
+ if (namespace !== APPEARANCE_NS) return;
385
+ // Re-read rather than patch the one key the signal named: the value it
386
+ // carries has been reported stale under rapid switching, and a read is
387
+ // one round trip on a connection that is already open.
388
+ refresh().catch(() => {
389
+ // the portal went away mid-session; the last answer stands
390
+ });
391
+ });
392
+
393
+ // No `hasService()` probe first: this is one call either way, and a name
394
+ // that is neither owned nor activatable fails it immediately with
395
+ // ServiceUnknown. The probe would be two extra round trips to learn what
396
+ // the call itself reports.
397
+ if (!(await refresh())) {
398
+ throw new Error('the settings portal answered no appearance');
399
+ }
400
+ return true;
401
+ } catch {
402
+ await sub?.remove().catch(() => {});
403
+ return false;
404
+ } finally {
405
+ // The match rule outlives the ref, and must: holding one would keep the
406
+ // socket `ref()`d and the process alive for as long as the app cared what
407
+ // colour the desktop is.
408
+ await ref.release();
409
+ }
410
+ }
411
+
412
+ function invoke(bus, { member, signature, body }) {
413
+ return bus.invoke(
414
+ {
415
+ destination: PORTAL_NAME,
416
+ path: PORTAL_PATH,
417
+ interface: SETTINGS_IFACE,
418
+ member,
419
+ signature,
420
+ body,
421
+ },
422
+ { timeout: 5_000 },
423
+ );
424
+ }
425
+
426
+ /** `ReadAll(['org.freedesktop.appearance'])`, unwrapped to the namespace. */
427
+ const readAppearance = (bus) =>
428
+ invoke(bus, {
429
+ member: 'ReadAll',
430
+ signature: 'as',
431
+ body: [[APPEARANCE_NS]],
432
+ }).then((all) => all?.[APPEARANCE_NS] ?? null);
433
+
434
+ // --------------------------------------------------------------------------
435
+ // Rung 2: XSETTINGS
436
+ // --------------------------------------------------------------------------
437
+
438
+ /**
439
+ * A theme *name* is all this rung has, so reading it is a convention rather
440
+ * than a contract: `Adwaita-dark`, `Yaru-dark`, `Breeze-Dark`. Matched at the
441
+ * end of the name only — `Darkly` and `HighContrast` are not dark themes, and
442
+ * a substring match calls them one.
443
+ *
444
+ * `Gtk/ApplicationPreferDarkTheme` is checked first where a daemon exports
445
+ * it, because it is the answer rather than a hint about it.
446
+ */
447
+ export function fromXSettings(map) {
448
+ const name = map.get('Net/ThemeName');
449
+ const theme = typeof name === 'string' ? name : '';
450
+ const prefersDark = map.get('Gtk/ApplicationPreferDarkTheme');
451
+ const dark =
452
+ typeof prefersDark === 'number'
453
+ ? prefersDark === 1
454
+ : /[-_ :]dark$/i.test(theme);
455
+
456
+ // GNOME's are `HighContrast` and `HighContrastInverse`; matched with the
457
+ // separators stripped so `High-Contrast` and `high contrast` count too.
458
+ const high = /highcontrast/i.test(theme.replace(/[-_ ]/g, ''));
459
+
460
+ // `Gtk/EnableAnimations` is in GTK's key list but a settings daemon need
461
+ // not export it — gnome-settings-daemon does not, so on GNOME this rung
462
+ // cannot answer reduced motion and says no rather than guessing.
463
+ const animations = map.get('Gtk/EnableAnimations');
464
+
465
+ return {
466
+ colorScheme: dark ? 'dark' : theme ? 'light' : 'no-preference',
467
+ // XSETTINGS has no accent colour. Not "none set" — no such key exists.
468
+ accent: null,
469
+ contrast: high ? 'high' : 'normal',
470
+ reducedMotion: animations === 0,
471
+ };
472
+ }
473
+
474
+ async function xsettingsRung(app) {
475
+ if (!app) return false;
476
+ await beginXSettings(app);
477
+ const values = xsettings(app);
478
+ if (!values) return false;
479
+ publish(fromXSettings(values), 'xsettings');
480
+ watchXSettings(app, (next) => {
481
+ if (owner !== 'xsettings') return;
482
+ publish(next ? fromXSettings(next) : {}, 'xsettings');
483
+ });
484
+ return true;
485
+ }
486
+
487
+ // --------------------------------------------------------------------------
488
+ // Rung 3: macOS
489
+ // --------------------------------------------------------------------------
490
+
491
+ /**
492
+ * One JXA program: read the four values, print them as JSON, and print them
493
+ * again whenever macOS says they changed.
494
+ *
495
+ * **It reads the frameworks rather than `defaults`.** Three of the four
496
+ * `defaults` keys — `AppleHighlightColor`, `AppleAccentColor`,
497
+ * `com.apple.universalaccess increaseContrast` — do not exist until the user
498
+ * changes that setting, so "key not found" is the normal answer rather than
499
+ * the error case, and `AppleAccentColor` is an integer index into a table
500
+ * that has to be maintained by hand and is wrong the moment Apple adds a
501
+ * colour. `NSColor.controlAccentColor` is the colour itself, with Multicolor
502
+ * already resolved, and `NSWorkspace` answers the two accessibility flags
503
+ * directly.
504
+ *
505
+ * Exported so a test can pin the source; it cannot be executed on Linux.
506
+ */
507
+ export const MACOS_PROGRAM = `
508
+ ObjC.import('AppKit');
509
+ var ud = $.NSUserDefaults.standardUserDefaults;
510
+ var ws = $.NSWorkspace.sharedWorkspace;
511
+ function read() {
512
+ var style = ud.stringForKey('AppleInterfaceStyle');
513
+ var accent = null;
514
+ try {
515
+ var c = $.NSColor.controlAccentColor.colorUsingColorSpace(
516
+ $.NSColorSpace.sRGBColorSpace);
517
+ if (!c.isNil()) accent = [c.redComponent, c.greenComponent, c.blueComponent];
518
+ } catch (e) {}
519
+ return JSON.stringify({
520
+ dark: !style.isNil() && ObjC.unwrap(style) === 'Dark',
521
+ accent: accent,
522
+ reducedMotion: !!ws.accessibilityDisplayShouldReduceMotion,
523
+ contrast: !!ws.accessibilityDisplayShouldIncreaseContrast
524
+ });
525
+ }
526
+ function emit() { console.log(read()); }
527
+ emit();
528
+ var dnc = $.NSDistributedNotificationCenter.defaultCenter;
529
+ ['AppleInterfaceThemeChangedNotification',
530
+ 'AppleColorPreferencesChangedNotification'].forEach(function (name) {
531
+ dnc.addObserverForNameObjectQueueUsingBlock(
532
+ name, $(), $.NSOperationQueue.mainQueue, emit);
533
+ });
534
+ ws.notificationCenter.addObserverForNameObjectQueueUsingBlock(
535
+ 'NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification',
536
+ $(), $.NSOperationQueue.mainQueue, emit);
537
+ $.NSRunLoop.currentRunLoop.run();
538
+ `;
539
+
540
+ /** One line of the child's output → the four values, or null if it is noise. */
541
+ export function fromMacOS(line) {
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(line);
545
+ } catch {
546
+ return null;
547
+ }
548
+ if (!parsed || typeof parsed !== 'object') return null;
549
+ return {
550
+ // macOS always has a definite appearance, so an unset AppleInterfaceStyle
551
+ // is *light* rather than "no preference".
552
+ colorScheme: parsed.dark ? 'dark' : 'light',
553
+ accent: accentFromPortal(parsed.accent),
554
+ contrast: parsed.contrast ? 'high' : 'normal',
555
+ reducedMotion: Boolean(parsed.reducedMotion),
556
+ };
557
+ }
558
+
559
+ let child = null;
560
+
561
+ /**
562
+ * Spawn the watcher and resolve on its first line — or `false` if it dies,
563
+ * prints nothing usable, or takes more than a few seconds, any of which mean
564
+ * this Mac cannot answer and the ladder is finished.
565
+ *
566
+ * `console.log` in JXA has gone to stderr in some macOS releases and stdout in
567
+ * others, so both are read. It costs one extra listener to not depend on
568
+ * which.
569
+ */
570
+ async function macosRung() {
571
+ if (process.platform !== 'darwin' || child) return false;
572
+ const { spawn } = await import('node:child_process');
573
+
574
+ let proc;
575
+ try {
576
+ proc = spawn('osascript', ['-l', 'JavaScript', '-e', MACOS_PROGRAM], {
577
+ stdio: ['ignore', 'pipe', 'pipe'],
578
+ });
579
+ } catch {
580
+ return false;
581
+ }
582
+ child = proc;
583
+ // Never a reason for the process to stay alive.
584
+ proc.unref();
585
+ proc.stdout.unref?.();
586
+ proc.stderr.unref?.();
587
+ proc.on('error', () => {});
588
+
589
+ return await new Promise((resolve) => {
590
+ let settled = false;
591
+ const done = (ok) => {
592
+ if (settled) return;
593
+ settled = true;
594
+ clearTimeout(timer);
595
+ resolve(ok);
596
+ };
597
+ const timer = setTimeout(() => {
598
+ proc.kill();
599
+ done(false);
600
+ }, 5_000);
601
+ timer.unref?.();
602
+
603
+ let buffered = '';
604
+ const onData = (chunk) => {
605
+ buffered += chunk;
606
+ let at;
607
+ while ((at = buffered.indexOf('\n')) !== -1) {
608
+ const line = buffered.slice(0, at).trim();
609
+ buffered = buffered.slice(at + 1);
610
+ if (!line) continue;
611
+ const values = fromMacOS(line);
612
+ if (!values) continue;
613
+ if (settled && owner !== 'macos') return;
614
+ publish(values, 'macos');
615
+ done(true);
616
+ }
617
+ };
618
+ proc.stdout.setEncoding('utf8');
619
+ proc.stderr.setEncoding('utf8');
620
+ proc.stdout.on('data', onData);
621
+ proc.stderr.on('data', onData);
622
+ proc.on('exit', () => {
623
+ // Dying after it answered leaves the last value standing, which is more
624
+ // useful than reverting to the defaults.
625
+ child = null;
626
+ done(false);
627
+ });
628
+ });
629
+ }
630
+
631
+ // Killed rather than left behind: `unref()` keeps it from holding *this*
632
+ // process open, and nothing keeps it from outliving it.
633
+ process.on('exit', () => child?.kill());
634
+
635
+ // --------------------------------------------------------------------------
636
+ // The ladder
637
+ // --------------------------------------------------------------------------
638
+
639
+ async function runLadder(app) {
640
+ for (const [name, rung] of [
641
+ ['portal', portalRung],
642
+ // Before XSETTINGS, and only on a Mac: where the process is macOS, the
643
+ // Mac's own preference is the one the user set, and an XSETTINGS daemon
644
+ // there would be something they installed by hand. A *Linux* process on
645
+ // an XQuartz display never reaches this at all, which is correct — it
646
+ // cannot read that Mac's defaults.
647
+ ['macos', macosRung],
648
+ ['xsettings', () => xsettingsRung(app)],
649
+ ]) {
650
+ let answered = false;
651
+ try {
652
+ answered = await rung();
653
+ } catch {
654
+ answered = false;
655
+ }
656
+ if (answered) {
657
+ owner = name;
658
+ return snapshot;
659
+ }
660
+ }
661
+ return snapshot;
662
+ }
663
+
664
+ /**
665
+ * The desktop's appearance, resolved.
666
+ *
667
+ * ```js
668
+ * const { colorScheme, accent } = await systemAppearance();
669
+ * ```
670
+ *
671
+ * The imperative twin of `useSystemAppearance()`, and a **verified** answer
672
+ * rather than the remembered one the first render starts from:
673
+ *
674
+ * ```js
675
+ * const [root] = await Promise.all([createRoot(), systemAppearance()]);
676
+ * root.render(<App />);
677
+ * ```
678
+ *
679
+ * Most apps do not need that line, because the snapshot is seeded from the
680
+ * last run before the first render — see `load()` above. It is for the first
681
+ * launch on a machine, and for anything that must be exact rather than
682
+ * probably right.
683
+ *
684
+ * Waiting is a deliberate choice, and it is why the probe does not live
685
+ * inside `createRoot()`. Measured cold on a GNOME session: `createRoot()`
686
+ * alone 85 ms, `createRoot()` with a concurrent portal probe 124 ms —
687
+ * `dbus-native`'s import is CPU-bound, so it does not hide behind ntk's
688
+ * startup. An app that never asks what colour the desktop is should not pay
689
+ * 40 ms to find out.
690
+ *
691
+ * Never rejects. A machine with no portal, no settings daemon and no Mac
692
+ * answers `'no-preference'` with `source: null`, which is a real answer:
693
+ * use your own defaults.
694
+ *
695
+ * `app` lets the XSETTINGS rung run — pass the ntk connection where you have
696
+ * one. Without it that rung is skipped, so a call made before `createRoot()`
697
+ * resolves sees the portal and macOS only; the hook always passes its tree's
698
+ * connection.
699
+ *
700
+ * @param {{ app?: any }} [options]
701
+ * @returns {Promise<Readonly<SystemAppearance>>}
702
+ */
703
+ export function systemAppearance(options = {}) {
704
+ if (owner) return Promise.resolve(snapshot);
705
+ load();
706
+ if (!probe) {
707
+ // **Failure is not cached**, for the same reason `bus.js` does not cache
708
+ // it: a session bus can genuinely appear later, the moment something
709
+ // creates $XDG_RUNTIME_DIR/bus. Concurrent callers share the run in
710
+ // flight; the next call after it settles starts a fresh one.
711
+ probe = runLadder(options.app ?? null).finally(() => {
712
+ probe = null;
713
+ });
714
+ }
715
+ return probe;
716
+ }
717
+
718
+ /**
719
+ * What is known right now, without asking. Always a complete answer — and on
720
+ * the first call, the one this machine gave last time rather than the
721
+ * defaults.
722
+ */
723
+ export function appearanceSnapshot() {
724
+ load();
725
+ return snapshot;
726
+ }
727
+
728
+ /**
729
+ * Re-render when the desktop's appearance changes. Not public —
730
+ * `useSystemAppearance()` is the public shape.
731
+ */
732
+ export function watchAppearance(onChange) {
733
+ watchers.add(onChange);
734
+ return () => watchers.delete(onChange);
735
+ }
736
+
737
+ /** Test seam, not public: forget everything that was learned. */
738
+ export function _resetAppearance() {
739
+ snapshot = NOTHING;
740
+ owner = null;
741
+ probe = null;
742
+ cacheChecked = false;
743
+ watchers.clear();
744
+ child?.kill();
745
+ child = null;
746
+ }
747
+
748
+ /** @typedef {typeof NOTHING} SystemAppearance */