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
package/src/host.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `react-x11/host` — add a host element from outside the package.
3
+ *
4
+ * The element also has to be declared to JSX, which is module augmentation
5
+ * against `react-x11/jsx-runtime` (see docs/typescript.md and
6
+ * docs/extending.md):
7
+ *
8
+ * ```ts
9
+ * declare module 'react-x11/jsx-runtime' {
10
+ * namespace JSX {
11
+ * interface IntrinsicElements {
12
+ * sparkline: { data: number[]; stroke?: string; style?: Style };
13
+ * }
14
+ * }
15
+ * }
16
+ * ```
17
+ */
18
+ import type { Node } from './node.js';
19
+ import type { NtkApp } from './types/nodes.js';
20
+
21
+ export interface HostContext {
22
+ isInsideText: boolean;
23
+ isInsideSvg: boolean;
24
+ isInside3d: boolean;
25
+ }
26
+
27
+ export interface ElementDefinition {
28
+ /**
29
+ * Build the node. `app` is the ntk connection the tree renders through —
30
+ * the second argument every built-in node constructor takes. Must return
31
+ * a `Node` whose `kind` is the registered element name.
32
+ */
33
+ create(
34
+ props: Record<string, unknown>,
35
+ app: NtkApp,
36
+ hostContext: HostContext,
37
+ ): Node;
38
+ /**
39
+ * Lays out with yoga and paints into the owning window (default true).
40
+ * `false` is for a node owning a real child X window instead — see
41
+ * `GlAreaNode`.
42
+ */
43
+ drawn?: boolean;
44
+ /**
45
+ * Prop names this element owns even though they are also style names, so
46
+ * `<sparkline stroke="red">` is not reported as a flat style prop in
47
+ * development.
48
+ */
49
+ semanticNames?: string[];
50
+ /**
51
+ * Prop names whose damage this element's own `applyProps` claims — so a
52
+ * commit that changes one of them contributes no damage of its own,
53
+ * instead of widening the frame to the whole node.
54
+ *
55
+ * For an element that draws a **scene** into one node: a graph view handed
56
+ * a new `nodes` array per drag step invalidates the box the dragged node
57
+ * moved through, and without this the commit claims the whole pane over
58
+ * the top of it. Everything left out keeps core's conservative answer, and
59
+ * an element that names a prop it does not actually claim shows stale
60
+ * pixels — see `Node.paintChanged` and docs/extending.md.
61
+ */
62
+ selfDamagedProps?: string[];
63
+ /** Reject children, naming this element, instead of laying out something
64
+ * that will never paint. Default true. */
65
+ childrenAllowed?: boolean;
66
+ /** Replace an existing registration. Off by default. */
67
+ override?: boolean;
68
+ }
69
+
70
+ export function registerElement(
71
+ type: string,
72
+ definition: ElementDefinition,
73
+ ): void;
74
+
75
+ /** Undo a registration; true if there was one. */
76
+ export function unregisterElement(type: string): boolean;
77
+
78
+ /** Registered element names, in registration order. */
79
+ export function registeredElements(): string[];
80
+
81
+ /** The built-in element names (a copy). */
82
+ export function hostTypes(): string[];
83
+
84
+ /** Every element name currently known, built-in and registered. */
85
+ export function knownElements(): string[];
86
+
87
+ /** Kinds that lay out with yoga and paint into the owning window (a copy). */
88
+ export function drawnKinds(): string[];
package/src/host.js ADDED
@@ -0,0 +1,42 @@
1
+ // `react-x11/host` — the seam a package that is not react-x11 uses to add
2
+ // a host element. See docs/extending.md for the node contract; this file is
3
+ // only the entry point.
4
+ export {
5
+ registerElement,
6
+ unregisterElement,
7
+ registeredElements,
8
+ } from './registry.js';
9
+
10
+ import { DRAWN_KINDS as DRAWN } from './nodes.js';
11
+ import { registeredElements } from './registry.js';
12
+
13
+ const BUILT_IN = Object.freeze([
14
+ 'window',
15
+ 'popup',
16
+ 'box',
17
+ 'text',
18
+ 'image',
19
+ 'canvas',
20
+ 'textinput',
21
+ 'textarea',
22
+ 'svg',
23
+ 'glarea',
24
+ 'foreign',
25
+ ]);
26
+
27
+ /** The built-in element names. A copy: the vocabulary grows through
28
+ * `registerElement`, never by mutating this. */
29
+ export function hostTypes() {
30
+ return [...BUILT_IN];
31
+ }
32
+
33
+ /** Every element name currently known, built-in and registered. */
34
+ export function knownElements() {
35
+ return [...BUILT_IN, ...registeredElements()];
36
+ }
37
+
38
+ /** Kinds that lay out with yoga and paint into the owning window. A copy —
39
+ * to add one, register the element with `drawn: true`. */
40
+ export function drawnKinds() {
41
+ return [...DRAWN];
42
+ }
package/src/idle.js ADDED
@@ -0,0 +1,490 @@
1
+ // Whether the user is still there, and how to tell the desktop not to blank
2
+ // the screen while something is playing.
3
+ //
4
+ // ## Idle: alarms, not polling
5
+ //
6
+ // X has a counter for this — `IDLETIME`, a SYNC system counter carrying
7
+ // milliseconds since the last input on any device — and SYNC *alarms* fire an
8
+ // event when a counter crosses a value. So "tell me when the user has been
9
+ // idle for five minutes" is one alarm and no timer at all, which is what
10
+ // every idle daemon on the desktop (xss-lock, KDE's, GNOME's) is built on.
11
+ //
12
+ // The idiom is a flip-flop, and it is the whole mechanism:
13
+ //
14
+ // idle ← alarm on IDLETIME >= timeout (PositiveComparison)
15
+ // active ← alarm on IDLETIME <= timeout (NegativeComparison)
16
+ //
17
+ // One fires, the state flips, and the alarm is changed to watch for the
18
+ // crossing back. Nothing runs in between — no interval, nothing waking the
19
+ // process up to ask a question whose answer is usually "no".
20
+ //
21
+ // **`delta` must be sent as 0.** It defaults to 1, and the protocol rejects a
22
+ // positive delta on a negative test with a Match error, so the half of the
23
+ // flip-flop that waits for the user to come back would fail — and fail
24
+ // silently, since a void request's error goes to the connection's error hook
25
+ // rather than to a callback.
26
+ //
27
+ // Where there is no `IDLETIME` counter the fallback is MIT-SCREEN-SAVER's
28
+ // `QueryInfo`, which answers the same number for one round trip but has no
29
+ // event behind it, so that rung polls. It schedules against the answer rather
30
+ // than on a fixed tick — with 12 seconds elapsed of a 300-second timeout the
31
+ // next check is 288 seconds away — so an active user costs one round trip per
32
+ // timeout period.
33
+ //
34
+ // ## Inhibition: three rungs that agree on what they do
35
+ //
36
+ // All three keep the **screen** awake and none of them stops a suspend. That
37
+ // is not a simplification, it is the intersection: `ScreenSaverSuspend` is
38
+ // an X request about blanking, `org.freedesktop.ScreenSaver` is the pre-portal
39
+ // desktop interface for the same thing, and only the portal can also inhibit
40
+ // logout and suspend. Exposing a `mode` that works on one rung out of three
41
+ // would be a seam that mostly does not.
42
+
43
+ import { sessionBus } from './bus.js';
44
+ import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
45
+
46
+ import { requireExtension } from './extensions.js';
47
+
48
+ const IDLETIME = 'IDLETIME';
49
+ const SCREENSAVER_NAME = 'org.freedesktop.ScreenSaver';
50
+ const SCREENSAVER_PATH = '/org/freedesktop/ScreenSaver';
51
+ const INHIBIT_IFACE = 'org.freedesktop.portal.Inhibit';
52
+ const REQUEST_IFACE = 'org.freedesktop.portal.Request';
53
+ /** `org.freedesktop.portal.Inhibit`'s flag for "do not let the session idle". */
54
+ const INHIBIT_IDLE = 8;
55
+
56
+ const sessions = new WeakMap();
57
+
58
+ // --------------------------------------------------------------------------
59
+ // Idle
60
+ // --------------------------------------------------------------------------
61
+
62
+ /**
63
+ * One connection's idle machinery: the extensions it resolved once, and a
64
+ * watcher per distinct timeout. Two components asking for the same timeout
65
+ * share an alarm; asking for two different ones costs two.
66
+ */
67
+ class IdleSession {
68
+ constructor(app) {
69
+ this.app = app;
70
+ this.watchers = new Map();
71
+ this.stopped = false;
72
+ this._sync = undefined;
73
+ this._saver = undefined;
74
+ this._counter = undefined;
75
+ this._handler = null;
76
+ }
77
+
78
+ stop() {
79
+ this.stopped = true;
80
+ for (const watcher of this.watchers.values()) watcher.stop();
81
+ this.watchers.clear();
82
+ if (this._handler && this.app?.X?.off) {
83
+ try {
84
+ this.app.X.off('event', this._handler);
85
+ } catch {
86
+ // an ntk old enough to hand back a client with no `off`
87
+ }
88
+ }
89
+ this._handler = null;
90
+ }
91
+
92
+ /** The SYNC extension, resolved once per connection. */
93
+ sync() {
94
+ if (this._sync === undefined)
95
+ this._sync = requireExtension(this.app, 'sync');
96
+ return this._sync;
97
+ }
98
+
99
+ /** MIT-SCREEN-SAVER, likewise. */
100
+ saver() {
101
+ if (this._saver === undefined) {
102
+ this._saver = requireExtension(this.app, 'screen-saver');
103
+ }
104
+ return this._saver;
105
+ }
106
+
107
+ /**
108
+ * The `IDLETIME` counter's id, or null where the server has no such
109
+ * counter — which is every server without the XInput-driven idle tracking
110
+ * Xorg has, XQuartz among them.
111
+ */
112
+ counter() {
113
+ if (this._counter !== undefined) return this._counter;
114
+ this._counter = this.sync().then((sync) => {
115
+ if (!sync?.ListSystemCounters) return null;
116
+ return new Promise((resolve) => {
117
+ try {
118
+ sync.ListSystemCounters((err, counters) => {
119
+ if (err || !counters) return resolve(null);
120
+ resolve(counters.find((c) => c.name === IDLETIME)?.counter ?? null);
121
+ });
122
+ } catch {
123
+ resolve(null);
124
+ }
125
+ });
126
+ });
127
+ return this._counter;
128
+ }
129
+
130
+ /** One `event` listener for every alarm, installed once. */
131
+ listen(fn) {
132
+ if (this._handler) return;
133
+ const X = this.app?.X;
134
+ if (!X?.on) return;
135
+ this._handler = fn;
136
+ X.on('event', fn);
137
+ }
138
+ }
139
+
140
+ class IdleWatcher {
141
+ constructor(session, timeout) {
142
+ this.session = session;
143
+ this.timeout = timeout;
144
+ this.idle = false;
145
+ this.listeners = new Set();
146
+ this.stopped = false;
147
+ this.alarm = 0;
148
+ this._sync = null;
149
+ this._timer = null;
150
+ }
151
+
152
+ set(idle) {
153
+ if (this.stopped || this.idle === idle) return;
154
+ this.idle = idle;
155
+ for (const fn of [...this.listeners]) {
156
+ try {
157
+ fn();
158
+ } catch {
159
+ // one subscriber throwing must not take the others with it, nor the
160
+ // X event loop this runs on
161
+ }
162
+ }
163
+ }
164
+
165
+ stop() {
166
+ this.stopped = true;
167
+ this.listeners.clear();
168
+ clearTimeout(this._timer);
169
+ this._timer = null;
170
+ if (this.alarm) {
171
+ try {
172
+ this._sync?.DestroyAlarm(this.alarm);
173
+ } catch {
174
+ // the connection is going away with it
175
+ }
176
+ this.alarm = 0;
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Arm a watcher: SYNC alarms where the counter exists, polling where it does
183
+ * not, and nothing at all where neither extension is present — on which the
184
+ * watcher reports "not idle" for good, which is the honest answer for a
185
+ * display that cannot be asked.
186
+ */
187
+ async function armIdle(watcher) {
188
+ const session = watcher.session;
189
+ const counter = await session.counter();
190
+ if (watcher.stopped) return;
191
+
192
+ if (counter) {
193
+ const sync = await session.sync();
194
+ if (watcher.stopped || !sync) return;
195
+ watcher._sync = sync;
196
+ const X = session.app.X;
197
+ watcher.alarm = X.AllocID();
198
+
199
+ session.listen((ev) => {
200
+ if (session.stopped) return;
201
+ if (ev.type !== sync.firstEvent + sync.events.AlarmNotify) return;
202
+ for (const w of session.watchers.values()) {
203
+ if (w.alarm !== ev.alarm || w.stopped) continue;
204
+ // The counter value the alarm fired at says which way it crossed,
205
+ // rather than trusting the flip-flop's own idea of where it was: an
206
+ // alarm changed twice in a frame can deliver events out of order.
207
+ const idle = ev.counterValue >= w.timeout;
208
+ w.set(idle);
209
+ retest(sync, w, idle);
210
+ }
211
+ });
212
+
213
+ try {
214
+ sync.CreateAlarm(watcher.alarm, {
215
+ counter,
216
+ valueType: sync.ValueType.Absolute,
217
+ value: watcher.timeout,
218
+ testType: sync.TestType.PositiveComparison,
219
+ delta: 0,
220
+ events: 1,
221
+ });
222
+ } catch {
223
+ watcher.alarm = 0;
224
+ }
225
+ return;
226
+ }
227
+
228
+ // No counter: poll MIT-SCREEN-SAVER instead.
229
+ const saver = await session.saver();
230
+ if (watcher.stopped || !saver?.QueryInfo) return;
231
+ poll(watcher, saver);
232
+ }
233
+
234
+ /** Flip the alarm over to watch for the crossing back. */
235
+ function retest(sync, watcher, idle) {
236
+ if (!watcher.alarm) return;
237
+ try {
238
+ sync.ChangeAlarm(watcher.alarm, {
239
+ valueType: sync.ValueType.Absolute,
240
+ value: watcher.timeout,
241
+ testType: idle
242
+ ? sync.TestType.NegativeComparison
243
+ : sync.TestType.PositiveComparison,
244
+ delta: 0,
245
+ events: 1,
246
+ });
247
+ } catch {
248
+ // the connection went away; nothing further will arrive either way
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The fallback rung. Sleeps for exactly as long as the answer says it can:
254
+ * an idle time of 12s against a 300s timeout cannot become idle for another
255
+ * 288s, so that is when to look again.
256
+ *
257
+ * Once idle, there is nothing to compute a wait from — the counter only goes
258
+ * up until input resets it, and no input reaches this process when the user
259
+ * is typing in another window. So that direction polls on an interval scaled
260
+ * to the timeout, floored at a second and capped at half a minute: a
261
+ * five-minute away marker clears within 30s of the user coming back, and a
262
+ * ten-second one clears within a second.
263
+ */
264
+ function poll(watcher, saver) {
265
+ if (watcher.stopped) return;
266
+ const root = watcher.session.app.X.display?.screen?.[0]?.root;
267
+ if (root == null) return;
268
+ saver.QueryInfo(root, (err, info) => {
269
+ if (watcher.stopped) return;
270
+ if (err || !info) return schedule(watcher, saver, watcher.timeout);
271
+ const elapsed = info.idle ?? 0;
272
+ const idle = elapsed >= watcher.timeout;
273
+ watcher.set(idle);
274
+ schedule(
275
+ watcher,
276
+ saver,
277
+ idle
278
+ ? Math.min(30_000, Math.max(1_000, watcher.timeout / 4))
279
+ : Math.max(250, watcher.timeout - elapsed),
280
+ );
281
+ });
282
+ }
283
+
284
+ function schedule(watcher, saver, delay) {
285
+ clearTimeout(watcher._timer);
286
+ watcher._timer = setTimeout(() => poll(watcher, saver), delay);
287
+ // Never a reason for this to keep the process alive: an app whose windows
288
+ // have closed does not care whether anyone is at the keyboard.
289
+ watcher._timer.unref?.();
290
+ }
291
+
292
+ /** Whether this timeout is currently elapsed. Not public — `useIdle()` is. */
293
+ export function idleSnapshot(app, timeout) {
294
+ return sessions.get(app)?.watchers.get(timeout)?.idle ?? false;
295
+ }
296
+
297
+ /** Subscribe to a timeout elapsing and un-elapsing. */
298
+ export function watchIdle(app, timeout, onChange) {
299
+ if (!app || !(timeout > 0)) return () => {};
300
+ let session = sessions.get(app);
301
+ if (!session) {
302
+ session = new IdleSession(app);
303
+ sessions.set(app, session);
304
+ }
305
+ let watcher = session.watchers.get(timeout);
306
+ if (!watcher) {
307
+ watcher = new IdleWatcher(session, timeout);
308
+ session.watchers.set(timeout, watcher);
309
+ armIdle(watcher).catch(() => {
310
+ // an extension that is not there is not an error, it is a rung
311
+ });
312
+ }
313
+ watcher.listeners.add(onChange);
314
+ return () => watcher.listeners.delete(onChange);
315
+ }
316
+
317
+ /** Tear down with the root that started it. */
318
+ export function endIdle(app) {
319
+ const session = sessions.get(app);
320
+ if (!session) return;
321
+ session.stop();
322
+ sessions.delete(app);
323
+ }
324
+
325
+ /**
326
+ * Test seam: state that a timeout has or has not elapsed, without a server
327
+ * that has SYNC. The watcher it creates is inert — nothing arms it — so a
328
+ * test drives the value itself.
329
+ */
330
+ export function setIdleForTests(app, timeout, idle) {
331
+ let session = sessions.get(app);
332
+ if (!session) {
333
+ session = new IdleSession(app);
334
+ sessions.set(app, session);
335
+ }
336
+ let watcher = session.watchers.get(timeout);
337
+ if (!watcher) {
338
+ watcher = new IdleWatcher(session, timeout);
339
+ session.watchers.set(timeout, watcher);
340
+ }
341
+ watcher.set(idle);
342
+ return watcher;
343
+ }
344
+
345
+ // --------------------------------------------------------------------------
346
+ // Inhibition
347
+ // --------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Ask the desktop not to blank the screen, and get back the release.
351
+ *
352
+ * ```js
353
+ * const release = await keepAwake({ reason: 'Playing a video', app });
354
+ * // …later
355
+ * release();
356
+ * ```
357
+ *
358
+ * Never rejects and never throws: a desktop with no portal, no screensaver
359
+ * service and no MIT-SCREEN-SAVER hands back a release that does nothing,
360
+ * because "could not ask" and "asked and it was ignored" are the same outcome
361
+ * for the caller and neither is worth an error path in a video player.
362
+ *
363
+ * `useKeepAwake()` is the shape a component wants; this is for imperative
364
+ * code and for tests.
365
+ */
366
+ export async function keepAwake({ reason = 'Busy', app = null } = {}) {
367
+ for (const rung of [portalInhibit, screenSaverInhibit, xInhibit]) {
368
+ try {
369
+ const release = await rung(reason, app);
370
+ if (release) return once(release);
371
+ } catch {
372
+ // the next rung down
373
+ }
374
+ }
375
+ return () => {};
376
+ }
377
+
378
+ /** A release that runs once however many times it is called — a double
379
+ * release would drop somebody else's inhibition on the counted X rung. */
380
+ function once(fn) {
381
+ let done = false;
382
+ return () => {
383
+ if (done) return;
384
+ done = true;
385
+ try {
386
+ fn();
387
+ } catch {
388
+ // releasing something already gone is the outcome we wanted
389
+ }
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Rung 1: `org.freedesktop.portal.Inhibit`.
395
+ *
396
+ * Not `portalRequest()`, though it is Request-shaped: this call's `Response`
397
+ * fires when the inhibition *ends*, so a helper that awaits one would hang
398
+ * for exactly as long as the feature is working. What is wanted is the
399
+ * handle, to `Close()` later.
400
+ */
401
+ async function portalInhibit(reason, app) {
402
+ void app;
403
+ const ref = await sessionBus();
404
+ if (!ref) return null;
405
+ try {
406
+ const path = await ref.bus.invoke(
407
+ {
408
+ destination: PORTAL_NAME,
409
+ path: PORTAL_PATH,
410
+ interface: INHIBIT_IFACE,
411
+ member: 'Inhibit',
412
+ signature: 'sua{sv}',
413
+ // No parent window: an inhibition is not modal to anything, and the
414
+ // portal only uses the handle to place a dialog it does not show here.
415
+ body: ['', INHIBIT_IDLE, [['reason', ['s', reason]]]],
416
+ },
417
+ { timeout: 5_000 },
418
+ );
419
+ if (!path) return null;
420
+ return () => {
421
+ ref.bus
422
+ .invoke({
423
+ destination: PORTAL_NAME,
424
+ path,
425
+ interface: REQUEST_IFACE,
426
+ member: 'Close',
427
+ signature: '',
428
+ body: [],
429
+ })
430
+ .catch(() => {});
431
+ };
432
+ } finally {
433
+ // The inhibition belongs to the connection, not to this reference, and
434
+ // the connection stays open — holding a ref would keep the socket
435
+ // `ref()`d and the process alive for as long as a video was playing,
436
+ // which the window on screen is already doing.
437
+ await ref.release();
438
+ }
439
+ }
440
+
441
+ /** Rung 2: the pre-portal desktop interface, which KDE, Xfce, MATE and
442
+ * every screensaver of that era implement. */
443
+ async function screenSaverInhibit(reason, app) {
444
+ void app;
445
+ const ref = await sessionBus();
446
+ if (!ref) return null;
447
+ try {
448
+ const cookie = await ref.bus.invoke(
449
+ {
450
+ destination: SCREENSAVER_NAME,
451
+ path: SCREENSAVER_PATH,
452
+ interface: SCREENSAVER_NAME,
453
+ member: 'Inhibit',
454
+ signature: 'ss',
455
+ body: [process.title || 'react-x11', reason],
456
+ },
457
+ { timeout: 5_000 },
458
+ );
459
+ if (typeof cookie !== 'number') return null;
460
+ return () => {
461
+ ref.bus
462
+ .invoke({
463
+ destination: SCREENSAVER_NAME,
464
+ path: SCREENSAVER_PATH,
465
+ interface: SCREENSAVER_NAME,
466
+ member: 'UnInhibit',
467
+ signature: 'u',
468
+ body: [cookie],
469
+ })
470
+ .catch(() => {});
471
+ };
472
+ } finally {
473
+ await ref.release();
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Rung 3: `ScreenSaverSuspend`, which needs no bus at all — the reason this
479
+ * ladder has a floor on a bare `startx` with no session services running.
480
+ *
481
+ * The server counts suspensions per client, so every `Suspend(true)` owes a
482
+ * `Suspend(false)`; `once()` above is what guarantees the pairing.
483
+ */
484
+ async function xInhibit(reason, app) {
485
+ void reason;
486
+ const saver = app ? await requireExtension(app, 'screen-saver') : null;
487
+ if (!saver?.Suspend) return null;
488
+ saver.Suspend(true);
489
+ return () => saver.Suspend(false);
490
+ }