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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- 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 */
|