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
package/src/portal.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// xdg-desktop-portal: the shared machinery every portal-backed feature needs,
|
|
2
|
+
// and nothing user-visible.
|
|
3
|
+
//
|
|
4
|
+
// A portal is a D-Bus API, not a widget. An app asks "the desktop" to run a
|
|
5
|
+
// dialog *in another process* and hand back the answer — which is how a
|
|
6
|
+
// sandboxed app gets a file picker it is not allowed to draw, and how a Qt app
|
|
7
|
+
// on GNOME gets a GTK dialog with your bookmarks in it.
|
|
8
|
+
//
|
|
9
|
+
// ## The one thing that is easy to get wrong
|
|
10
|
+
//
|
|
11
|
+
// Every portal method that shows UI has the same shape: it returns an object
|
|
12
|
+
// path **immediately** and answers later with a
|
|
13
|
+
// `org.freedesktop.portal.Request.Response` signal. Subscribe after the call
|
|
14
|
+
// returns and you can lose the answer — the dialog can be dismissed before
|
|
15
|
+
// your match rule is in place.
|
|
16
|
+
//
|
|
17
|
+
// So the path is predictable *by design*. From the upstream
|
|
18
|
+
// `org.freedesktop.portal.Request.xml`:
|
|
19
|
+
//
|
|
20
|
+
// > Since version 0.9 of xdg-desktop-portal, the handle will be of the form
|
|
21
|
+
// > `/org/freedesktop/portal/desktop/request/SENDER/TOKEN` where SENDER is the
|
|
22
|
+
// > callers unique name, with the initial ':' removed and all '.' replaced by
|
|
23
|
+
// > '_' […] This change was made to let applications subscribe to the Response
|
|
24
|
+
// > signal before making the initial portal call, thereby avoiding a race
|
|
25
|
+
// > condition.
|
|
26
|
+
//
|
|
27
|
+
// `portalRequest()` is that subscribe-then-call, written once, so every portal
|
|
28
|
+
// added later is a method name and an options dict rather than a race to
|
|
29
|
+
// re-solve.
|
|
30
|
+
|
|
31
|
+
import { loadTransport, sessionBus } from './bus.js';
|
|
32
|
+
|
|
33
|
+
export const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
34
|
+
export const PORTAL_PATH = '/org/freedesktop/portal/desktop';
|
|
35
|
+
const REQUEST_IFACE = 'org.freedesktop.portal.Request';
|
|
36
|
+
|
|
37
|
+
/** The portal answered; `response` is what it means. */
|
|
38
|
+
export const RESPONSE_OK = 0;
|
|
39
|
+
export const RESPONSE_CANCELLED = 1;
|
|
40
|
+
export const RESPONSE_ENDED = 2;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* There is no portal here — no bus, no service, or a version too old for what
|
|
44
|
+
* was asked. A **typed** rejection rather than a crash: it is the signal that
|
|
45
|
+
* a caller should fall back to something it can draw itself, which is exactly
|
|
46
|
+
* what the file dialog does.
|
|
47
|
+
*/
|
|
48
|
+
export class NoPortalError extends Error {
|
|
49
|
+
constructor(message, cause) {
|
|
50
|
+
super(`react-x11: ${message}`, { cause });
|
|
51
|
+
this.name = 'NoPortalError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The user closed the dialog, or the desktop ended the request some other way. */
|
|
56
|
+
export class PortalCancelledError extends Error {
|
|
57
|
+
constructor(response = RESPONSE_CANCELLED) {
|
|
58
|
+
super(
|
|
59
|
+
response === RESPONSE_CANCELLED
|
|
60
|
+
? 'react-x11: the portal dialog was cancelled'
|
|
61
|
+
: 'react-x11: the portal request ended without an answer',
|
|
62
|
+
);
|
|
63
|
+
this.name = 'PortalCancelledError';
|
|
64
|
+
/** 1 when the user cancelled, 2 when it ended some other way. */
|
|
65
|
+
this.response = response;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `':1.42'` → `'1_42'`.
|
|
71
|
+
*
|
|
72
|
+
* The transform is three characters of code and the whole reason the Request
|
|
73
|
+
* race is avoidable, so it is named and tested rather than inlined.
|
|
74
|
+
*/
|
|
75
|
+
export const senderPath = (uniqueName) =>
|
|
76
|
+
uniqueName.replace(/^:/, '').replaceAll('.', '_');
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A token that is a legal object-path element and not guessable — the XML asks
|
|
80
|
+
* for "a per-library prefix combined with a random number", so: ours plus
|
|
81
|
+
* eight random bytes.
|
|
82
|
+
*/
|
|
83
|
+
async function newToken() {
|
|
84
|
+
const { randomBytes } = await import('node:crypto');
|
|
85
|
+
return `rx11_${randomBytes(8).toString('hex')}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --------------------------------------------------------------------------
|
|
89
|
+
// hasService
|
|
90
|
+
// --------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Cache per bus generation. `NameOwnerChanged` would invalidate it precisely;
|
|
94
|
+
* a portal appearing mid-session is rare enough that the cheap version — cache
|
|
95
|
+
* only the *positive* answer — is the honest trade. A false negative that
|
|
96
|
+
* cached would be the bug (a feature disabled for the life of the process),
|
|
97
|
+
* and this cannot produce one.
|
|
98
|
+
*/
|
|
99
|
+
const reachable = new Map();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is `name` reachable — owned right now, **or activatable on demand**?
|
|
103
|
+
*
|
|
104
|
+
* `NameHasOwner` alone is the wrong question and gets this wrong on a healthy
|
|
105
|
+
* desktop. `org.freedesktop.portal.Desktop` ships a D-Bus service file:
|
|
106
|
+
*
|
|
107
|
+
* [D-BUS Service]
|
|
108
|
+
* Name=org.freedesktop.portal.Desktop
|
|
109
|
+
* Exec=…/xdg-desktop-portal
|
|
110
|
+
*
|
|
111
|
+
* so on a GNOME session where no app has touched a portal yet the name has no
|
|
112
|
+
* owner and would answer *false* — and a feature gated on it takes the
|
|
113
|
+
* fallback path forever, on a machine where the portal works perfectly.
|
|
114
|
+
*/
|
|
115
|
+
export async function hasService(name, busRef) {
|
|
116
|
+
if (reachable.get(name)) return true;
|
|
117
|
+
const ref = busRef ?? (await sessionBus());
|
|
118
|
+
if (!ref) return false;
|
|
119
|
+
try {
|
|
120
|
+
const [owned, activatable] = await Promise.all([
|
|
121
|
+
ref.bus.listNames(),
|
|
122
|
+
ref.bus.listActivatableNames().catch(() => []),
|
|
123
|
+
]);
|
|
124
|
+
const found = owned.includes(name) || activatable.includes(name);
|
|
125
|
+
if (found) reachable.set(name, true);
|
|
126
|
+
return found;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
} finally {
|
|
130
|
+
if (!busRef) await ref.release();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Test seam, not public: forget what was learned about the bus. */
|
|
135
|
+
export function _resetServiceCache() {
|
|
136
|
+
reachable.clear();
|
|
137
|
+
versions.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// portalVersion
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Positive answers only, like {@link hasService}'s cache and for the same
|
|
146
|
+
* reason: a version can only move by the portal restarting, which is rare
|
|
147
|
+
* enough to ignore, while a cached "not there" would disable a feature for
|
|
148
|
+
* the life of the process on a machine where the portal appears one call
|
|
149
|
+
* later.
|
|
150
|
+
*/
|
|
151
|
+
const versions = new Map();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The version of one portal *interface*, or `0` when it is not there.
|
|
155
|
+
*
|
|
156
|
+
* `hasService()` answers a different question. The portal service being
|
|
157
|
+
* reachable says nothing about which interfaces its backends provide —
|
|
158
|
+
* XFCE's portal has no `Screenshot` interface at all, and a GNOME old enough
|
|
159
|
+
* has it at version 1, before `PickColor` existed. Capability lives in the
|
|
160
|
+
* interface's own `version` property, so gating a feature means reading it:
|
|
161
|
+
*
|
|
162
|
+
* ```js
|
|
163
|
+
* if ((await portalVersion('org.freedesktop.portal.Screenshot', ref)) >= 2) {
|
|
164
|
+
* // PickColor is there
|
|
165
|
+
* }
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* `0` collapses "no bus", "no portal", "no such interface" and "no version
|
|
169
|
+
* property" into one answer, because every caller would branch the same way
|
|
170
|
+
* on all four.
|
|
171
|
+
*/
|
|
172
|
+
export async function portalVersion(iface, busRef) {
|
|
173
|
+
const cached = versions.get(iface);
|
|
174
|
+
if (cached !== undefined) return cached;
|
|
175
|
+
const ref = busRef ?? (await sessionBus());
|
|
176
|
+
if (!ref) return 0;
|
|
177
|
+
try {
|
|
178
|
+
const value = await ref.bus.invoke(
|
|
179
|
+
{
|
|
180
|
+
destination: PORTAL_NAME,
|
|
181
|
+
path: PORTAL_PATH,
|
|
182
|
+
interface: 'org.freedesktop.DBus.Properties',
|
|
183
|
+
member: 'Get',
|
|
184
|
+
signature: 'ss',
|
|
185
|
+
body: [iface, 'version'],
|
|
186
|
+
},
|
|
187
|
+
{ timeout: 10_000 },
|
|
188
|
+
);
|
|
189
|
+
// dbus-native unwraps the `v` reply to its value.
|
|
190
|
+
const version = typeof value === 'number' && value > 0 ? value : 0;
|
|
191
|
+
if (version > 0) versions.set(iface, version);
|
|
192
|
+
return version;
|
|
193
|
+
} catch {
|
|
194
|
+
// `org.freedesktop.DBus.Error.InvalidArgs` — the portal is there and the
|
|
195
|
+
// interface is not, which is XFCE today for Screenshot. Or no portal at
|
|
196
|
+
// all. The caller's branch is the same either way.
|
|
197
|
+
return 0;
|
|
198
|
+
} finally {
|
|
199
|
+
if (!busRef) await ref.release();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --------------------------------------------------------------------------
|
|
204
|
+
// The Request helper
|
|
205
|
+
// --------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Call a portal method that answers through a `Request`, with the
|
|
209
|
+
* subscription already in place before the call goes out.
|
|
210
|
+
*
|
|
211
|
+
* ```js
|
|
212
|
+
* const { response, results } = await portalRequest(ref, {
|
|
213
|
+
* iface: 'org.freedesktop.portal.FileChooser',
|
|
214
|
+
* member: 'OpenFile',
|
|
215
|
+
* parentWindow: 'x11:1a00007',
|
|
216
|
+
* title: 'Open',
|
|
217
|
+
* options: { multiple: true },
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*
|
|
221
|
+
* The default argument shape is FileChooser's, `(s parent_window, s title,
|
|
222
|
+
* a{sv} options)` — but that shape is FileChooser's alone. Every other portal
|
|
223
|
+
* that answers through a `Request` has its own leading arguments
|
|
224
|
+
* (`Screenshot.PickColor` has no title, `Print` carries a serial, …), so a
|
|
225
|
+
* caller states them, as `signature` and `args` together:
|
|
226
|
+
*
|
|
227
|
+
* ```js
|
|
228
|
+
* await portalRequest(ref, {
|
|
229
|
+
* iface: 'org.freedesktop.portal.Screenshot',
|
|
230
|
+
* member: 'PickColor',
|
|
231
|
+
* signature: 'sa{sv}',
|
|
232
|
+
* args: [parentWindow],
|
|
233
|
+
* });
|
|
234
|
+
* ```
|
|
235
|
+
*
|
|
236
|
+
* `signature` is the method's full argument signature and must end in
|
|
237
|
+
* `a{sv}` — the options dict is where `handle_token` rides, so a method
|
|
238
|
+
* without one cannot be Request-shaped. `args` is everything before it;
|
|
239
|
+
* the options dict itself stays in `options` and is appended here.
|
|
240
|
+
*
|
|
241
|
+
* Three things it owns, so that no caller reimplements them:
|
|
242
|
+
*
|
|
243
|
+
* - **Cancellation.** An `AbortSignal` calls `Request.Close()` on the same
|
|
244
|
+
* path. Per the XML that means **no `Response` is emitted**, so the promise
|
|
245
|
+
* has to be settled here rather than left waiting for one. The listener goes
|
|
246
|
+
* on **before the first await**, because `addEventListener('abort')` on a
|
|
247
|
+
* signal that has already fired never runs: an abort that lands while
|
|
248
|
+
* `AddMatch` or the initial call is still in flight would otherwise be
|
|
249
|
+
* dropped, leaving a promise nothing can settle and a dialog on screen with
|
|
250
|
+
* nobody listening — the exact leak this is here to prevent.
|
|
251
|
+
* - **Deadlines.** There is deliberately **no timeout on the answer** — a
|
|
252
|
+
* dialog can legitimately be open for an hour, which is the whole reason
|
|
253
|
+
* portals are request-shaped instead of plain calls. Only the initial method
|
|
254
|
+
* call gets a short one: a portal that does not hand back a handle in a few
|
|
255
|
+
* seconds is not there.
|
|
256
|
+
* - **A pre-0.9 portal** that returns an unpredictable path. Cheap insurance:
|
|
257
|
+
* re-subscribe on whatever came back.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ bus: any, uniqueName: string }} busRef
|
|
260
|
+
* @returns {Promise<{ response: number, results: object }>}
|
|
261
|
+
*/
|
|
262
|
+
export async function portalRequest(
|
|
263
|
+
busRef,
|
|
264
|
+
{
|
|
265
|
+
iface,
|
|
266
|
+
member,
|
|
267
|
+
parentWindow = '',
|
|
268
|
+
title = '',
|
|
269
|
+
signature,
|
|
270
|
+
args,
|
|
271
|
+
options = {},
|
|
272
|
+
signal,
|
|
273
|
+
},
|
|
274
|
+
) {
|
|
275
|
+
// They travel as a pair: the signature says what the args are, and either
|
|
276
|
+
// half alone is a call that marshals wrong at the far end with no error
|
|
277
|
+
// anywhere near the mistake.
|
|
278
|
+
if ((signature === undefined) !== (args === undefined)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`react-x11: portalRequest(${iface}.${member}) got ` +
|
|
281
|
+
`${signature === undefined ? '`args` without `signature`' : '`signature` without `args`'}` +
|
|
282
|
+
" — they describe each other, so pass both (e.g. signature: 'sa{sv}'," +
|
|
283
|
+
' args: [parentWindow]), or neither for the FileChooser shape.',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (signature === undefined) {
|
|
287
|
+
signature = 'ssa{sv}';
|
|
288
|
+
args = [parentWindow, title];
|
|
289
|
+
} else if (!signature.endsWith('a{sv}')) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`react-x11: portalRequest(${iface}.${member}) — the signature ` +
|
|
292
|
+
`'${signature}' does not end in the options dict (a{sv}). ` +
|
|
293
|
+
'handle_token rides in that dict, so a method without one does not ' +
|
|
294
|
+
'answer through a Request; call bus.invoke directly instead.',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { bus, uniqueName } = busRef;
|
|
299
|
+
const token = await newToken();
|
|
300
|
+
const path = `${PORTAL_PATH}/request/${senderPath(uniqueName)}/${token}`;
|
|
301
|
+
|
|
302
|
+
if (signal?.aborted) throw signal.reason ?? new PortalCancelledError();
|
|
303
|
+
|
|
304
|
+
const subscribe = async (requestPath) => {
|
|
305
|
+
const sub = await bus.watch(
|
|
306
|
+
`type='signal',sender='${PORTAL_NAME}',` +
|
|
307
|
+
`interface='${REQUEST_IFACE}',member='Response',path='${requestPath}'`,
|
|
308
|
+
);
|
|
309
|
+
const key = bus.mangle(requestPath, REQUEST_IFACE, 'Response');
|
|
310
|
+
return { sub, key };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const closeRequest = (requestPath) =>
|
|
314
|
+
bus
|
|
315
|
+
.invoke({
|
|
316
|
+
destination: PORTAL_NAME,
|
|
317
|
+
path: requestPath,
|
|
318
|
+
interface: REQUEST_IFACE,
|
|
319
|
+
member: 'Close',
|
|
320
|
+
signature: '',
|
|
321
|
+
body: [],
|
|
322
|
+
})
|
|
323
|
+
.catch(() => {
|
|
324
|
+
// The request may already be gone — that is the outcome we wanted.
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// The abort wiring, before anything that can suspend. `sent` is the guard on
|
|
328
|
+
// Close(): there is no request to close until the call has actually gone out,
|
|
329
|
+
// and the path the portal will answer on is the predictable one from here
|
|
330
|
+
// until a pre-0.9 portal says otherwise.
|
|
331
|
+
let sent = false;
|
|
332
|
+
let requestPath = path;
|
|
333
|
+
/** The path the abort actually closed, if it has fired. */
|
|
334
|
+
let closedPath = null;
|
|
335
|
+
let onAbort;
|
|
336
|
+
let aborted;
|
|
337
|
+
if (signal) {
|
|
338
|
+
aborted = new Promise((_, reject) => {
|
|
339
|
+
onAbort = () => {
|
|
340
|
+
if (sent) {
|
|
341
|
+
closedPath = requestPath;
|
|
342
|
+
closeRequest(requestPath);
|
|
343
|
+
}
|
|
344
|
+
reject(signal.reason ?? new PortalCancelledError());
|
|
345
|
+
};
|
|
346
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
347
|
+
});
|
|
348
|
+
// Nothing awaits `aborted` until the races below, and an abort can land
|
|
349
|
+
// before then. Mark it handled so a rejection in that window is not an
|
|
350
|
+
// unhandled one; the races still see it.
|
|
351
|
+
aborted.catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Subscribe FIRST. Everything else in this function is bookkeeping around
|
|
355
|
+
// the fact that this line comes before the invoke. It is not raced against
|
|
356
|
+
// the abort: a match rule half-added is a match rule leaked, so let it
|
|
357
|
+
// finish and let the `finally` below take it away again.
|
|
358
|
+
let { sub, key } = await subscribe(path);
|
|
359
|
+
const answered = new Promise((resolve) => {
|
|
360
|
+
// `bus.signals` emits the signal's argument array.
|
|
361
|
+
bus.signals.once(key, ([response, results]) =>
|
|
362
|
+
resolve({ response, results: results ?? {} }),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
// An abort during `AddMatch` above: nothing has been asked for yet, so
|
|
368
|
+
// there is nothing to Close — just leave, and let the `finally` unsubscribe.
|
|
369
|
+
if (signal?.aborted) throw signal.reason ?? new PortalCancelledError();
|
|
370
|
+
|
|
371
|
+
// `sent` flips *before* the await, not after: from the moment the call is
|
|
372
|
+
// on the wire the portal may already have a dialog up, and an abort in that
|
|
373
|
+
// window has to Close it even though no handle has come back yet.
|
|
374
|
+
sent = true;
|
|
375
|
+
const call = bus
|
|
376
|
+
.invoke(
|
|
377
|
+
{
|
|
378
|
+
destination: PORTAL_NAME,
|
|
379
|
+
path: PORTAL_PATH,
|
|
380
|
+
interface: iface,
|
|
381
|
+
member,
|
|
382
|
+
signature,
|
|
383
|
+
body: [...args, { ...options, handle_token: token }],
|
|
384
|
+
},
|
|
385
|
+
{ timeout: 10_000 },
|
|
386
|
+
)
|
|
387
|
+
// Settled either way, so that an abort winning the race below does not
|
|
388
|
+
// leave this one rejecting into nobody's hands.
|
|
389
|
+
.then(
|
|
390
|
+
(handle) => ({ handle }),
|
|
391
|
+
(cause) => ({ cause }),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// A pre-0.9 portal that picks its own path is the one case where an abort
|
|
395
|
+
// that beat the reply closed the wrong one — and which path it really was
|
|
396
|
+
// is not knowable until the handle arrives, quite possibly after this
|
|
397
|
+
// function has already rejected.
|
|
398
|
+
call.then(({ handle }) => {
|
|
399
|
+
if (closedPath && typeof handle === 'string' && handle !== closedPath) {
|
|
400
|
+
closeRequest(handle);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const { handle, cause } = await Promise.race([call, aborted ?? call]);
|
|
405
|
+
if (cause)
|
|
406
|
+
throw new NoPortalError(`${iface}.${member} did not answer`, cause);
|
|
407
|
+
|
|
408
|
+
// Pre-0.9 portals chose their own path. Re-subscribe on the real one; the
|
|
409
|
+
// first subscription stays until the finally below, which is cheaper than
|
|
410
|
+
// getting the ordering wrong.
|
|
411
|
+
if (typeof handle === 'string' && handle !== path) {
|
|
412
|
+
requestPath = handle;
|
|
413
|
+
const moved = await subscribe(handle);
|
|
414
|
+
bus.signals.once(moved.key, ([response, results]) => {
|
|
415
|
+
bus.signals.emit(key, [response, results]);
|
|
416
|
+
});
|
|
417
|
+
sub = { remove: () => Promise.all([sub.remove(), moved.sub.remove()]) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// `Close()` produces no Response, so the abort has to settle the race
|
|
421
|
+
// itself rather than wait for one that will never arrive.
|
|
422
|
+
return await Promise.race([answered, aborted ?? answered]);
|
|
423
|
+
} finally {
|
|
424
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
425
|
+
await sub.remove().catch(() => {});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A `parent_window` handle for the portal, or `''` when there is no window to
|
|
431
|
+
* name — which is legal and just means the dialog floats.
|
|
432
|
+
*
|
|
433
|
+
* Lowercase hex, no `0x`. Both shipping backends tolerate a prefix, which is
|
|
434
|
+
* precisely why it is easy to get wrong and never notice; Qt's parser answers
|
|
435
|
+
* 0 on failure with no error path, so a third backend would silently give an
|
|
436
|
+
* unparented, non-modal dialog rather than throw.
|
|
437
|
+
*/
|
|
438
|
+
export function parentWindowHandle(xid) {
|
|
439
|
+
return typeof xid === 'number' && xid > 0 ? `x11:${xid.toString(16)}` : '';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* `Variant`, from the one copy of the transport the bus layer loaded.
|
|
444
|
+
*
|
|
445
|
+
* Needed for the option types inference cannot produce — `a(sa(us))` for
|
|
446
|
+
* filters, `u` where a plain number would marshal as `i`.
|
|
447
|
+
*/
|
|
448
|
+
export async function variant(signature, value) {
|
|
449
|
+
const dbus = await loadTransport();
|
|
450
|
+
return new dbus.Variant(signature, value);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* A NUL-terminated byte array, which is how the FileChooser portal spells a
|
|
455
|
+
* path in its options (`current_folder`, `current_file`). A plain string there
|
|
456
|
+
* marshals as `s` and the backend ignores it — silently, which is the failure
|
|
457
|
+
* mode worth writing a helper to avoid.
|
|
458
|
+
*/
|
|
459
|
+
export function pathBytes(value) {
|
|
460
|
+
return Buffer.from(`${value}\0`, 'utf8');
|
|
461
|
+
}
|
package/src/priority.js
CHANGED
|
@@ -27,6 +27,32 @@ export function resolveUpdatePriority() {
|
|
|
27
27
|
: DefaultEventPriority;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// The renderer's `flushSyncWork`, registered by Reconciler.js when it builds
|
|
31
|
+
// the reconciler. The dispatcher needs it and cannot import the Reconciler
|
|
32
|
+
// back without a cycle, and there is only ever one reconciler per process.
|
|
33
|
+
let syncFlush = null;
|
|
34
|
+
|
|
35
|
+
export function setSyncFlush(fn) {
|
|
36
|
+
syncFlush = fn;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Land every discrete-priority update React is holding, now.
|
|
41
|
+
*
|
|
42
|
+
* `scheduleMicrotask` in the host config is what makes this necessary: a
|
|
43
|
+
* sync-lane update is committed in a microtask, so an event handler that has
|
|
44
|
+
* returned has *not* yet seen React's half of its own response. Painting
|
|
45
|
+
* before this ran would show the default action's half — the `:active` flip,
|
|
46
|
+
* the caret — and leave React's to the next frame, which is both a frame
|
|
47
|
+
* late and a second paint.
|
|
48
|
+
*
|
|
49
|
+
* A no-op when there is nothing pending, and when React is already rendering
|
|
50
|
+
* or committing (`flushSyncWork` checks its own execution context).
|
|
51
|
+
*/
|
|
52
|
+
export function flushSyncWork() {
|
|
53
|
+
syncFlush?.();
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
/** Run fn (an event handler batch) at the given update priority. */
|
|
31
57
|
export function runWithPriority(priority, fn) {
|
|
32
58
|
const previous = currentUpdatePriority;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `react-x11/refresh` — the runtime half of state-preserving hot reload.
|
|
3
|
+
* Run the app under the loader (`node --import react-x11/refresh/register`)
|
|
4
|
+
* and import from here only for the two seams below.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One applied hot-reload batch. */
|
|
8
|
+
export interface ReloadEvent {
|
|
9
|
+
/** Canonical URLs of the refresh-boundary modules that re-evaluated. */
|
|
10
|
+
urls: string[];
|
|
11
|
+
/** False when the batch left no component updates to apply. */
|
|
12
|
+
refreshed: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Called after each hot-reload batch has re-rendered the edited
|
|
17
|
+
* components in place. Returns an unsubscribe function.
|
|
18
|
+
*/
|
|
19
|
+
export function onReload(listener: (event: ReloadEvent) => void): () => void;
|
|
20
|
+
|
|
21
|
+
/** Apply pending component updates now. Returns null if nothing was pending. */
|
|
22
|
+
export function performReactRefresh(): unknown;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Internal surface for the loader's injected prelude/footer — reached as
|
|
26
|
+
* the default export because only default bindings initialize
|
|
27
|
+
* synchronously inside a hot module. Not part of the public API.
|
|
28
|
+
*/
|
|
29
|
+
declare const runtime: {
|
|
30
|
+
register(type: unknown, id: string): void;
|
|
31
|
+
createSignatureFunctionForTransform(): unknown;
|
|
32
|
+
moduleReady(
|
|
33
|
+
hot: unknown,
|
|
34
|
+
url: string,
|
|
35
|
+
exportsMap: Record<string, unknown> | null,
|
|
36
|
+
): void;
|
|
37
|
+
performReactRefresh: typeof performReactRefresh;
|
|
38
|
+
onReload: typeof onReload;
|
|
39
|
+
};
|
|
40
|
+
export default runtime;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// react-x11/refresh — the runtime half of state-preserving hot reload
|
|
2
|
+
// (Fast Refresh). The loader half is ./loader.js; `react-x11/refresh/register`
|
|
3
|
+
// wires both up for `node --import`. See docs/ecosystem/dev-tooling.md.
|
|
4
|
+
//
|
|
5
|
+
// Import order is what this module exists to own:
|
|
6
|
+
//
|
|
7
|
+
// 1. injectIntoGlobalHook must patch the DevTools global hook before any
|
|
8
|
+
// renderer registers with it — so the renderer import below is dynamic,
|
|
9
|
+
// after the patch;
|
|
10
|
+
// 2. injectIntoDevTools hands the reconciler's internals (dev build:
|
|
11
|
+
// scheduleRefresh & friends) to the patched hook, which is how
|
|
12
|
+
// react-refresh learns to re-render mounted roots. It must run before
|
|
13
|
+
// the first commit or the root won't be tracked.
|
|
14
|
+
//
|
|
15
|
+
// A host app does not need to import this module at all: the loader injects
|
|
16
|
+
// an import of it into every hot module's prelude, and the entry is itself a
|
|
17
|
+
// hot module, so the patch always lands before the app's own imports
|
|
18
|
+
// evaluate React or the renderer. Importing it explicitly is only for the
|
|
19
|
+
// two exports — `onReload` and `performReactRefresh`.
|
|
20
|
+
//
|
|
21
|
+
// react-refresh/runtime lives in node_modules, outside the hot graph, so
|
|
22
|
+
// every reloaded module registers into this one runtime instance.
|
|
23
|
+
import RefreshRuntime from 'react-refresh/runtime';
|
|
24
|
+
|
|
25
|
+
RefreshRuntime.injectIntoGlobalHook(globalThis);
|
|
26
|
+
|
|
27
|
+
const ReactX11 = await import('../index.js');
|
|
28
|
+
// no argument: react-reconciler 0.33 takes none, and the renderer metadata
|
|
29
|
+
// comes from the host config in src/Reconciler.js
|
|
30
|
+
ReactX11.Renderer.injectIntoDevTools();
|
|
31
|
+
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Called after a hot-reload batch has been applied: the edited components
|
|
36
|
+
* have re-rendered in place. The seam a dev tool sits on — log the reload,
|
|
37
|
+
* clear an error state, update a workbench UI. Returns an unsubscribe.
|
|
38
|
+
*/
|
|
39
|
+
export function onReload(listener) {
|
|
40
|
+
if (typeof listener !== 'function') {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
`react-x11/refresh: onReload takes a function, got ${typeof listener}.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
listeners.add(listener);
|
|
46
|
+
return () => listeners.delete(listener);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Manual seam: apply pending component updates now. null = nothing pending. */
|
|
50
|
+
export function performReactRefresh() {
|
|
51
|
+
return RefreshRuntime.performReactRefresh();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A reload batch re-evaluates each module along the changed → accepting
|
|
55
|
+
// path as a separate dynamic import, and an editor save can fire the file
|
|
56
|
+
// watcher more than once — so the refresh is debounced. The delay is
|
|
57
|
+
// imperceptible next to the save itself; what it buys is one re-render and
|
|
58
|
+
// one onReload event per save instead of one per module.
|
|
59
|
+
const REFRESH_DEBOUNCE_MS = 30;
|
|
60
|
+
const pendingUrls = new Set();
|
|
61
|
+
let pendingTimer = null;
|
|
62
|
+
|
|
63
|
+
function flushRefresh() {
|
|
64
|
+
pendingTimer = null;
|
|
65
|
+
const urls = [...pendingUrls];
|
|
66
|
+
pendingUrls.clear();
|
|
67
|
+
const result = RefreshRuntime.performReactRefresh();
|
|
68
|
+
const event = { urls, refreshed: result !== null };
|
|
69
|
+
for (const listener of [...listeners]) {
|
|
70
|
+
try {
|
|
71
|
+
listener(event);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('react-x11/refresh: onReload listener threw', err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The loader's injected footer calls this at the end of every hot module's
|
|
79
|
+
// evaluation, handing over the module's exported values (or null when the
|
|
80
|
+
// exports cannot all be named statically — re-exports, an anonymous
|
|
81
|
+
// default). A module whose exports are all components is a refresh
|
|
82
|
+
// boundary: it self-accepts, so an edit to it (or to anything below it
|
|
83
|
+
// with no boundary of its own) re-evaluates up to here and no further,
|
|
84
|
+
// and the re-evaluation schedules the refresh. Anything else stays
|
|
85
|
+
// un-accepted and the change propagates to the nearest boundary above.
|
|
86
|
+
function moduleReady(hot, url, exportsMap) {
|
|
87
|
+
const boundary =
|
|
88
|
+
exportsMap !== null &&
|
|
89
|
+
Object.values(exportsMap).every((value) =>
|
|
90
|
+
RefreshRuntime.isLikelyComponentType(value),
|
|
91
|
+
);
|
|
92
|
+
if (boundary) {
|
|
93
|
+
hot.accept();
|
|
94
|
+
} else {
|
|
95
|
+
// The hot context survives reloads, so an edit that adds a
|
|
96
|
+
// non-component export must also retract the acceptance the previous
|
|
97
|
+
// version registered — otherwise importers keep stale values forever.
|
|
98
|
+
// hot-module-replacement has no public un-accept, hence the field.
|
|
99
|
+
hot._selfAccepted = false;
|
|
100
|
+
}
|
|
101
|
+
const busted = /\?hmr=\d+$/.test(url);
|
|
102
|
+
if (boundary && busted) {
|
|
103
|
+
pendingUrls.add(url.replace(/\?hmr=\d+$/, ''));
|
|
104
|
+
if (pendingTimer) clearTimeout(pendingTimer);
|
|
105
|
+
pendingTimer = setTimeout(flushRefresh, REFRESH_DEBOUNCE_MS);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The injected prelude and footer reach everything through this default
|
|
110
|
+
// export: inside a hot module, named imports are rewritten to live
|
|
111
|
+
// bindings that initialize in a microtask, and module-scope code runs
|
|
112
|
+
// before that — only the default binding is initialized synchronously.
|
|
113
|
+
export default {
|
|
114
|
+
register(type, id) {
|
|
115
|
+
RefreshRuntime.register(type, id);
|
|
116
|
+
},
|
|
117
|
+
createSignatureFunctionForTransform:
|
|
118
|
+
RefreshRuntime.createSignatureFunctionForTransform,
|
|
119
|
+
moduleReady,
|
|
120
|
+
performReactRefresh,
|
|
121
|
+
onReload,
|
|
122
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `react-x11/refresh/loader` — the loader half of state-preserving hot
|
|
3
|
+
* reload. `react-x11/refresh/register` calls registerRefresh() with the
|
|
4
|
+
* defaults; a tool that needs the seams writes its own --import module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface RefreshOptions {
|
|
8
|
+
/** File extensions treated as hot modules. Default: ['.jsx']. */
|
|
9
|
+
extensions?: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Keep additional modules out of the hot graph (identity that must
|
|
12
|
+
* survive a reload: contexts, stores). node_modules and react-x11's own
|
|
13
|
+
* sources are always excluded.
|
|
14
|
+
*/
|
|
15
|
+
ignore?: (path: string) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Only 'classic' is supported: the automatic runtime injects an import
|
|
18
|
+
* that breaks under the hot-module import rewrite. Passing anything
|
|
19
|
+
* else throws.
|
|
20
|
+
*/
|
|
21
|
+
jsxRuntime?: 'classic';
|
|
22
|
+
/**
|
|
23
|
+
* Extra statements injected into every hot module, one statement per
|
|
24
|
+
* entry — an entry holding two statements or a newline throws.
|
|
25
|
+
*/
|
|
26
|
+
prelude?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Register the loader + hot-module hooks. Once per process. */
|
|
30
|
+
export function registerRefresh(options?: RefreshOptions): Promise<void>;
|
|
31
|
+
|
|
32
|
+
/** The transform alone, for a tool hosting its own module hooks. */
|
|
33
|
+
export function createTransformer(options?: RefreshOptions): Promise<{
|
|
34
|
+
preludeLineCount: number;
|
|
35
|
+
matches(pathname: string): boolean;
|
|
36
|
+
transform(source: string, filename: string): { code: string };
|
|
37
|
+
}>;
|