react-x11 2.16.1 → 2.17.1
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 +38 -23
- package/package.json +3 -1
- package/src/Reconciler.js +82 -23
- package/src/a11y.js +18 -1
- package/src/appcontext.js +8 -0
- package/src/appearance.js +36 -0
- package/src/{cocoa → backend}/context2d.js +27 -7
- package/src/capabilities.js +99 -1
- package/src/cocoa/app.js +17 -9
- package/src/cocoa/fonts.js +1 -1
- package/src/cocoa/glarea.js +48 -10
- package/src/cocoa/overlay.js +2 -2
- package/src/cocoa/panewindow.js +2 -2
- package/src/cocoa/presenter.js +2 -2
- package/src/cocoa/surface.js +3 -3
- package/src/cocoa/window.js +23 -2
- package/src/events.js +21 -0
- package/src/foreignnodes.js +8 -3
- package/src/frame/index.js +30 -4
- package/src/glnodes.js +21 -7
- package/src/idle.js +59 -1
- package/src/index.d.ts +41 -0
- package/src/index.js +30 -3
- package/src/launcher.js +17 -8
- package/src/launcherhooks.js +24 -10
- package/src/node.d.ts +1 -1
- package/src/nodes/cascade.js +9 -0
- package/src/nodes/node.js +6 -1
- package/src/nodes/window/hints.js +21 -2
- package/src/nodes/window/window.js +2 -2
- package/src/notifications.js +39 -14
- package/src/taskbarhooks.js +164 -0
- package/src/transfer.js +20 -1
- package/src/trayhooks.js +1 -1
- package/src/types/capabilities.d.ts +32 -3
- package/src/types/elements.d.ts +23 -1
- package/src/types/events.d.ts +16 -0
- package/src/types/launcher.d.ts +20 -6
- package/src/types/taskbar.d.ts +79 -0
- package/src/wayland/context2d.js +1 -1
- package/src/win32/a11y.js +604 -0
- package/src/win32/app.js +768 -0
- package/src/win32/bezels.js +158 -0
- package/src/win32/dnd.js +283 -0
- package/src/win32/fonts.js +497 -0
- package/src/win32/glarea.js +548 -0
- package/src/win32/ime.js +267 -0
- package/src/win32/keymap.js +116 -0
- package/src/win32/native.js +54 -0
- package/src/win32/panehost.js +106 -0
- package/src/win32/panewindow.js +343 -0
- package/src/win32/shell.js +426 -0
- package/src/win32/surface.js +192 -0
- package/src/win32/window.js +659 -0
- package/src/windowid.js +66 -0
package/src/win32/app.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
// The Windows backend's app object: the bridge's event channel on one side,
|
|
2
|
+
// the ntk-application shape the renderer expects on the other.
|
|
3
|
+
//
|
|
4
|
+
// There is no pump here, and that is the point. The Cocoa backend pumps AppKit
|
|
5
|
+
// from a libuv timer because AppKit insists on the process's main thread, and
|
|
6
|
+
// pays for it — an input waits for the next tick, and a modal loop freezes Node
|
|
7
|
+
// outright (#484). Win32 binds a window to the thread that created it, so the
|
|
8
|
+
// bridge runs every HWND and every modal loop on a thread of its own and
|
|
9
|
+
// reaches this side through a threadsafe function, which wakes Node's loop at
|
|
10
|
+
// once. What is left here is routing.
|
|
11
|
+
import { systemAppearance } from '../appearance.js';
|
|
12
|
+
import { setCompositingForTests } from '../compositing.js';
|
|
13
|
+
import { setScaleForTests } from '../scale.js';
|
|
14
|
+
import { setScreensForTests } from '../screens.js';
|
|
15
|
+
|
|
16
|
+
import { createBezels } from './bezels.js';
|
|
17
|
+
import { Win32InputMethod } from './ime.js';
|
|
18
|
+
import { decodeKey, modifierMask } from './keymap.js';
|
|
19
|
+
import { Win32PaneHost } from './panehost.js';
|
|
20
|
+
import { Win32PaneWindow } from './panewindow.js';
|
|
21
|
+
import { Win32Surface } from './surface.js';
|
|
22
|
+
import { installGl, Win32GlWindow } from './glarea.js';
|
|
23
|
+
import { Win32FontManager } from './fonts.js';
|
|
24
|
+
import { loadNative } from './native.js';
|
|
25
|
+
import {
|
|
26
|
+
installIdle,
|
|
27
|
+
installNotifications,
|
|
28
|
+
installTaskbar,
|
|
29
|
+
installTaskbarSurfaces,
|
|
30
|
+
Win32FilePanels,
|
|
31
|
+
Win32StatusItem,
|
|
32
|
+
} from './shell.js';
|
|
33
|
+
import { Win32Window } from './window.js';
|
|
34
|
+
|
|
35
|
+
// The fallback frame period, for a Windows with no compositor clock to wait
|
|
36
|
+
// on (before 10/1803) — and only that, because as a *clock* a JS timer is a
|
|
37
|
+
// bad one here. Node rounds a timer up to the next system tick, the default
|
|
38
|
+
// tick is 15.6ms, and a 16.67ms request therefore lands on the second one:
|
|
39
|
+
// measured, 155 of 200 intervals at 31ms and 24 at 16ms, which is 34.8
|
|
40
|
+
// frames a second alternating between one tick and two. Everything paced
|
|
41
|
+
// judders at that however cheap its frames are.
|
|
42
|
+
//
|
|
43
|
+
// So the clock is `frameClockRequest` (windows/src/frameclock.cc), which
|
|
44
|
+
// blocks a thread of its own on DCompositionWaitForCompositorClock and tells
|
|
45
|
+
// JS when the compositor is ready for a frame. This number is what is left
|
|
46
|
+
// when that is unavailable.
|
|
47
|
+
const FRAME_INTERVAL_MS = 1000 / 60;
|
|
48
|
+
|
|
49
|
+
// How long a promised compositor tick may take before the timer takes over
|
|
50
|
+
// for the rest of the session. A tick that is asked for and never arrives
|
|
51
|
+
// would stop every frame in the application for good — an app frozen with a
|
|
52
|
+
// clean event loop, which is the worst kind to be told about — so the clock
|
|
53
|
+
// is watched. One timer per frame costs nothing: what was wrong with a timer
|
|
54
|
+
// here was never its cost, only its granularity.
|
|
55
|
+
const CLOCK_WATCHDOG_MS = 250;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A pointer event's position on the screen, which X carries as `rootx`/
|
|
59
|
+
* `rooty` and Windows does not: WM_MOUSEMOVE and the button messages are all
|
|
60
|
+
* client-relative. The window knows where its client area is, so the sum is
|
|
61
|
+
* exact and costs nothing.
|
|
62
|
+
*
|
|
63
|
+
* Anything that places something *outside* the window reads these — a
|
|
64
|
+
* `ContextMenu` opens at the pointer, and a drag's feedback follows it
|
|
65
|
+
* (src/components/Menu.js, src/dnd.js). Without them both fall back to the
|
|
66
|
+
* window-relative coordinate and treat it as a screen one, which puts a
|
|
67
|
+
* right-click menu a whole window-offset away from the pointer.
|
|
68
|
+
*/
|
|
69
|
+
function rootOf(wnd, event) {
|
|
70
|
+
const origin = wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
|
|
71
|
+
return { rootx: event.a + origin.x, rooty: event.b + origin.y };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class Win32App {
|
|
75
|
+
constructor(native, options = {}) {
|
|
76
|
+
this._native = native;
|
|
77
|
+
this.options = options;
|
|
78
|
+
this._windows = new Map();
|
|
79
|
+
// The drag this process started, while the shell is carrying it — what
|
|
80
|
+
// an in-app drop reads to keep `e.items` by reference (src/dnd.js).
|
|
81
|
+
this._activeDrag = null;
|
|
82
|
+
this._rafQueue = [];
|
|
83
|
+
this._frameTimer = null;
|
|
84
|
+
/** A frame has been asked for and not yet delivered — one outstanding
|
|
85
|
+
* request at a time, whether the clock or the timer is answering it. */
|
|
86
|
+
this._framePending = false;
|
|
87
|
+
/** The compositor promised a tick and did not deliver one. Latched: the
|
|
88
|
+
* timer answers every frame from then on. */
|
|
89
|
+
this._clockLost = false;
|
|
90
|
+
this._closed = false;
|
|
91
|
+
this._atoms = new Map();
|
|
92
|
+
this._appearanceListeners = new Set();
|
|
93
|
+
/** Tray icons by the bridge's handle, so an event can find its item. */
|
|
94
|
+
this._statusItems = new Map();
|
|
95
|
+
/** The input method. Composition is a property of the platform rather
|
|
96
|
+
* than of a window, so there is one of these and it follows whichever
|
|
97
|
+
* field is focused (src/win32/ime.js). */
|
|
98
|
+
this.inputMethod = new Win32InputMethod(this);
|
|
99
|
+
/** Popups that asked for a pointer grab. Windows has none to give, so
|
|
100
|
+
* what the grab was for is watched for instead — see `grabPointer` in
|
|
101
|
+
* src/win32/window.js and `_dismissOutsidePopups` below. */
|
|
102
|
+
this._dismissOnOutside = new Set();
|
|
103
|
+
/** GL surfaces by the bridge's id, so a 'gl-ready' event finds its own. */
|
|
104
|
+
this._glWindows = new Map();
|
|
105
|
+
/** This process *is* a `<Frame>`'s pane: it has no windows of its own,
|
|
106
|
+
* only a buffer the host composites (REACT_X11_FRAME is what the Frame
|
|
107
|
+
* host sets on the fork). */
|
|
108
|
+
this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
|
|
109
|
+
this._paneSend = null;
|
|
110
|
+
/** The native open/save panels. Its *presence* is what puts the top rung
|
|
111
|
+
* on src/filedialog.js's ladder for this app. */
|
|
112
|
+
this.filePanels =
|
|
113
|
+
typeof native.fileDialog === 'function'
|
|
114
|
+
? new Win32FilePanels(this)
|
|
115
|
+
: null;
|
|
116
|
+
/** The system's own control bezels, when they would look right — see
|
|
117
|
+
* `_syncBezels`. Read by `useNativeControls()` as a capability. */
|
|
118
|
+
this.nativeBezels = null;
|
|
119
|
+
|
|
120
|
+
this.fonts = new Win32FontManager(native);
|
|
121
|
+
|
|
122
|
+
const screens = native.listScreens?.() ?? [];
|
|
123
|
+
const primary = screens[0] ?? {
|
|
124
|
+
x: 0,
|
|
125
|
+
y: 0,
|
|
126
|
+
width: 1920,
|
|
127
|
+
height: 1080,
|
|
128
|
+
scale: 1,
|
|
129
|
+
};
|
|
130
|
+
this.scale = primary.scale ?? 1;
|
|
131
|
+
this._screens = screens.length ? screens : [primary];
|
|
132
|
+
|
|
133
|
+
const listeners = {};
|
|
134
|
+
// The X connection's shape, for the code above that reaches into it —
|
|
135
|
+
// atoms, a root window, the connection's own events. Nothing here talks a
|
|
136
|
+
// protocol; it exists so the layers that were written against X do not
|
|
137
|
+
// each need a backend branch.
|
|
138
|
+
this.X = {
|
|
139
|
+
display: { screen: [{ root: 1 }] },
|
|
140
|
+
keycode2keysyms: {},
|
|
141
|
+
InternAtom: (onlyIfExists, name, cb) => {
|
|
142
|
+
if (!this._atoms.has(name))
|
|
143
|
+
this._atoms.set(name, 1000 + this._atoms.size);
|
|
144
|
+
cb(null, this._atoms.get(name));
|
|
145
|
+
},
|
|
146
|
+
ConfigureWindow() {},
|
|
147
|
+
SendClientMessage() {},
|
|
148
|
+
on(event, fn) {
|
|
149
|
+
(listeners[event] ??= []).push(fn);
|
|
150
|
+
},
|
|
151
|
+
emit(event, ...args) {
|
|
152
|
+
for (const fn of listeners[event] ?? []) fn(...args);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ntk's clipboard shape over the Windows clipboard. Opened with no owner
|
|
157
|
+
// window, which keeps it off the UI thread; what that gives up is delayed
|
|
158
|
+
// rendering, so a write here is eager rather than lazy. X's other everyday
|
|
159
|
+
// selection, PRIMARY, has no Windows equivalent at all — every name but
|
|
160
|
+
// CLIPBOARD is a selection nobody on this desktop can paste from, and is
|
|
161
|
+
// answered as empty rather than pretended at.
|
|
162
|
+
const isClipboard = (selection) => !selection || selection === 'CLIPBOARD';
|
|
163
|
+
this.clipboard = {
|
|
164
|
+
write: (data, { selection = 'CLIPBOARD' } = {}) => {
|
|
165
|
+
if (!isClipboard(selection)) return Promise.resolve();
|
|
166
|
+
const text =
|
|
167
|
+
typeof data === 'string'
|
|
168
|
+
? data
|
|
169
|
+
: (data?.['text/plain'] ?? data?.UTF8_STRING ?? data?.STRING);
|
|
170
|
+
if (typeof text !== 'string') {
|
|
171
|
+
return Promise.reject(
|
|
172
|
+
new Error(
|
|
173
|
+
'react-x11: the win32 clipboard writes text only for now — ' +
|
|
174
|
+
'images and file lists need the OLE data object, which is not ' +
|
|
175
|
+
'built. Pass a string, or a map with a text/plain entry.',
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return native.clipboardWriteText(text)
|
|
180
|
+
? Promise.resolve()
|
|
181
|
+
: Promise.reject(
|
|
182
|
+
new Error(
|
|
183
|
+
'react-x11: the clipboard refused the write — another program ' +
|
|
184
|
+
'held it open. Retrying usually succeeds.',
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
clear: (selection = 'CLIPBOARD') => {
|
|
189
|
+
if (isClipboard(selection)) native.clipboardWriteText('');
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
},
|
|
192
|
+
targets: ({ selection = 'CLIPBOARD' } = {}) =>
|
|
193
|
+
Promise.resolve(
|
|
194
|
+
isClipboard(selection) ? native.clipboardFormats() : [],
|
|
195
|
+
),
|
|
196
|
+
read: ({ selection = 'CLIPBOARD', target } = {}) => {
|
|
197
|
+
if (!isClipboard(selection)) {
|
|
198
|
+
return Promise.reject(
|
|
199
|
+
new Error(
|
|
200
|
+
`clipboard: nothing to paste — ${selection} has no owner`,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const text = native.clipboardReadText();
|
|
205
|
+
if (text === null) {
|
|
206
|
+
return Promise.reject(new Error('clipboard: nothing to paste'));
|
|
207
|
+
}
|
|
208
|
+
if (target === undefined) return Promise.resolve(text);
|
|
209
|
+
if (
|
|
210
|
+
target === 'text/plain' ||
|
|
211
|
+
target === 'UTF8_STRING' ||
|
|
212
|
+
target === 'STRING'
|
|
213
|
+
) {
|
|
214
|
+
return Promise.resolve(Buffer.from(text, 'utf8'));
|
|
215
|
+
}
|
|
216
|
+
return Promise.reject(
|
|
217
|
+
new Error(`clipboard: owner cannot convert to ${target}`),
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
// No owner window means no AddClipboardFormatListener, so a change is
|
|
221
|
+
// noticed by the sequence number rather than announced. Polled slowly
|
|
222
|
+
// and unref'd: this must never be the reason a process stays alive.
|
|
223
|
+
watch: (selection, handler) => {
|
|
224
|
+
if (!isClipboard(selection)) return Promise.resolve(() => {});
|
|
225
|
+
let last = native.clipboardSequence();
|
|
226
|
+
const timer = setInterval(() => {
|
|
227
|
+
const now = native.clipboardSequence();
|
|
228
|
+
if (now === last) return;
|
|
229
|
+
last = now;
|
|
230
|
+
handler({ selection: 'CLIPBOARD', owner: 1, reason: 'new-owner' });
|
|
231
|
+
}, 400);
|
|
232
|
+
timer.unref?.();
|
|
233
|
+
return Promise.resolve(() => clearInterval(timer));
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- the desktop ----------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/** Light or dark, the accent, contrast and reduced motion, as one answer —
|
|
241
|
+
* the rung `src/appearance.js` asks for by capability. */
|
|
242
|
+
systemAppearance() {
|
|
243
|
+
return this._native.systemAppearance();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
onAppearanceChange(fn) {
|
|
247
|
+
this._appearanceListeners.add(fn);
|
|
248
|
+
return () => this._appearanceListeners.delete(fn);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A pixel off the screen, for `useEyedropper()`. Windows asks no permission
|
|
252
|
+
* and draws no capture border, so the loupe is the renderer's to draw. */
|
|
253
|
+
screenColorAt(x, y) {
|
|
254
|
+
return this._native.screenColorAt(Math.round(x), Math.round(y));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
pointerPosition() {
|
|
258
|
+
return this._native.pointerPosition();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A tray icon. `useTray()` finds this by name, not by platform — the rule
|
|
262
|
+
* AGENTS.md sets for every ladder. */
|
|
263
|
+
/**
|
|
264
|
+
* Close every open `<popup grab>` that this press did not land in.
|
|
265
|
+
*
|
|
266
|
+
* `pressed` is the window the press was delivered to, or null when the
|
|
267
|
+
* application lost activation and there is no window of ours to exclude.
|
|
268
|
+
* Everything else open hears the press as an outside one and answers with
|
|
269
|
+
* `onDismiss` (src/win32/window.js `_dismissFromOutside`).
|
|
270
|
+
*
|
|
271
|
+
* Excluding the pressed window is what makes a submenu work: clicking the
|
|
272
|
+
* menu that opened it closes the submenu and keeps the menu, which is what
|
|
273
|
+
* an X11 grab does by handing the press to the innermost holder.
|
|
274
|
+
*
|
|
275
|
+
* A copy of the set is walked because `onDismiss` is what unmounts the
|
|
276
|
+
* popup, and unmounting runs `ungrabPointer` — which deletes from the set
|
|
277
|
+
* this loop would otherwise still be reading.
|
|
278
|
+
*/
|
|
279
|
+
_dismissOutsidePopups(pressed) {
|
|
280
|
+
if (this._dismissOnOutside.size === 0) return;
|
|
281
|
+
for (const wnd of [...this._dismissOnOutside]) {
|
|
282
|
+
if (wnd === pressed) continue;
|
|
283
|
+
if (wnd.destroyed) {
|
|
284
|
+
this._dismissOnOutside.delete(wnd);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
wnd._dismissFromOutside();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A drag session's point, as the tree's own units.
|
|
293
|
+
*
|
|
294
|
+
* The bridge reports screen device pixels, like every other event it
|
|
295
|
+
* sends; `DragSession.nativeEnded` multiplies by the node's scale to get
|
|
296
|
+
* back to them, because the cocoa bridge it was written against reports
|
|
297
|
+
* points. So this divides, and the two agree at any scale.
|
|
298
|
+
*/
|
|
299
|
+
_dragPoint(event) {
|
|
300
|
+
const s = this.scale || 1;
|
|
301
|
+
return { x: (event.a ?? 0) / s, y: (event.b ?? 0) / s };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
createStatusItem(options) {
|
|
305
|
+
return new Win32StatusItem(this, options);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Installed only where the system's own bezels would actually look right —
|
|
309
|
+
* see src/win32/bezels.js, which measures rather than assumes. */
|
|
310
|
+
_syncBezels(colorScheme) {
|
|
311
|
+
const wanted = colorScheme !== 'dark';
|
|
312
|
+
if (wanted === Boolean(this.nativeBezels)) return;
|
|
313
|
+
this.nativeBezels?.clear?.();
|
|
314
|
+
this.nativeBezels = wanted
|
|
315
|
+
? createBezels(this._native, { colorScheme, scale: this.scale })
|
|
316
|
+
: null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- the renderer's app surface -------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** Every window is composited by DWM and every surface has an alpha
|
|
322
|
+
* channel, so `<window transparent>` needs nothing found first. */
|
|
323
|
+
findArgbVisual() {
|
|
324
|
+
return { visual: 0x21, depth: 32 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The offscreen-surface seam `react-x11/ntk`'s `Surface` dispatches on:
|
|
329
|
+
* ntk's `Surface` contract over a Direct2D bitmap (src/win32/surface.js).
|
|
330
|
+
* Its presence is what makes `new Surface(app, { width, height })` answer
|
|
331
|
+
* a surface here rather than ntk's pixmap, which needs an X connection —
|
|
332
|
+
* a backend without the method gets ntk's, so an X app is never asked.
|
|
333
|
+
*/
|
|
334
|
+
createSurface(options) {
|
|
335
|
+
return new Win32Surface(this, options);
|
|
336
|
+
}
|
|
337
|
+
createWindow(attributes = {}) {
|
|
338
|
+
// `embeddable` is "created, not shown; somebody else will place me", and
|
|
339
|
+
// on this backend that cannot be a window: a composition target stops
|
|
340
|
+
// presenting the moment its window becomes a child, so an HWND is not
|
|
341
|
+
// something another process can take (docs/windows-embedding.md). What a
|
|
342
|
+
// host can take is the **buffer**, so an embeddable window here is the
|
|
343
|
+
// same shared surface a `<Frame>` pane draws into — whether this process
|
|
344
|
+
// was forked as a pane or is a guest somebody else started.
|
|
345
|
+
//
|
|
346
|
+
// Before the `parent` branch: an embeddable window has no parent, and
|
|
347
|
+
// this is the only place the two could be confused.
|
|
348
|
+
if (attributes.embeddable) {
|
|
349
|
+
return new Win32PaneWindow(this, attributes);
|
|
350
|
+
}
|
|
351
|
+
// A window with a parent is a `<glarea>`'s surface, which is a different
|
|
352
|
+
// thing entirely: a child HWND with a GL context and no DirectComposition
|
|
353
|
+
// surface at all. `glnodes.js` asks for one through this same door, as it
|
|
354
|
+
// does on X11 where a GL surface is also just a child window.
|
|
355
|
+
if (attributes.parent) return new Win32GlWindow(this, attributes);
|
|
356
|
+
return new Win32Window(this, attributes);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The seam `<Frame>` dispatches on (src/frame/index.js): a backend that
|
|
361
|
+
* composites a pane from a shared buffer answers here, and one that
|
|
362
|
+
* reparents the pane's real window does not. Its presence is the whole
|
|
363
|
+
* capability — the element, the props, the fallback and the restart are
|
|
364
|
+
* the same code on every backend.
|
|
365
|
+
*/
|
|
366
|
+
createPaneHost(wnd) {
|
|
367
|
+
return new Win32PaneHost(this, wnd);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The pane process's end of the frame channel: geometry and input in,
|
|
372
|
+
* presents out. Feature-detected by the pane bootstrap
|
|
373
|
+
* (src/frame/childmain.js), and a no-op in a process that is not a pane.
|
|
374
|
+
*/
|
|
375
|
+
attachPaneChannel(channel) {
|
|
376
|
+
if (!this._paneMode) return;
|
|
377
|
+
this._paneSend = (msg) => {
|
|
378
|
+
try {
|
|
379
|
+
channel.send(msg);
|
|
380
|
+
} catch {
|
|
381
|
+
// the host is going away; its shutdown owns the rest
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
channel.onMessage((msg) => {
|
|
385
|
+
const wnd = [...this._windows.values()][0];
|
|
386
|
+
if (!wnd) return;
|
|
387
|
+
if (msg?.type === 'pane-rect') {
|
|
388
|
+
wnd.setPaneSize?.(msg.width, msg.height, msg.scale);
|
|
389
|
+
} else if (msg?.type === 'pane-event') {
|
|
390
|
+
wnd.emit(msg.name, msg.ev);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
close() {
|
|
396
|
+
if (this._closed) return Promise.resolve();
|
|
397
|
+
this._closed = true;
|
|
398
|
+
if (this._frameTimer) clearTimeout(this._frameTimer);
|
|
399
|
+
this._frameTimer = null;
|
|
400
|
+
this._framePending = false;
|
|
401
|
+
this.inputMethod.destroy();
|
|
402
|
+
this._native.stop();
|
|
403
|
+
return Promise.resolve();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
frameIntervalFor() {
|
|
407
|
+
return FRAME_INTERVAL_MS;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- windows --------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
_register(wnd) {
|
|
413
|
+
this._windows.set(wnd.id, wnd);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_unregister(wnd) {
|
|
417
|
+
this._windows.delete(wnd.id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// --- the frame clock ------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
_requestFrame(cb, wnd = null) {
|
|
423
|
+
this._rafQueue.push({ cb, wnd });
|
|
424
|
+
if (this._framePending || this._closed) return this._rafQueue.length;
|
|
425
|
+
this._framePending = true;
|
|
426
|
+
// One tick, from the compositor if this Windows has a clock to wait on.
|
|
427
|
+
// It answers false when it has not, and then — and only then — a timer.
|
|
428
|
+
if (!this._clockLost && this._native.frameClockRequest?.()) {
|
|
429
|
+
this._frameTimer = setTimeout(() => {
|
|
430
|
+
this._frameTimer = null;
|
|
431
|
+
if (!this._framePending || this._closed) return;
|
|
432
|
+
this._clockLost = true;
|
|
433
|
+
this._framePending = false;
|
|
434
|
+
this._tickFrames();
|
|
435
|
+
}, CLOCK_WATCHDOG_MS);
|
|
436
|
+
this._frameTimer.unref?.();
|
|
437
|
+
return this._rafQueue.length;
|
|
438
|
+
}
|
|
439
|
+
this._frameTimer = setTimeout(() => {
|
|
440
|
+
this._frameTimer = null;
|
|
441
|
+
this._framePending = false;
|
|
442
|
+
this._tickFrames();
|
|
443
|
+
}, FRAME_INTERVAL_MS);
|
|
444
|
+
this._frameTimer.unref?.();
|
|
445
|
+
return this._rafQueue.length;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_tickFrames() {
|
|
449
|
+
const due = this._rafQueue;
|
|
450
|
+
this._rafQueue = [];
|
|
451
|
+
const now = performance.now();
|
|
452
|
+
for (const { cb } of due) {
|
|
453
|
+
try {
|
|
454
|
+
cb(now);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
// A throw from one window's frame must not take the others' with it;
|
|
457
|
+
// the renderer's own error plumbing has already had its say by here.
|
|
458
|
+
if (process.env.NODE_ENV !== 'production') console.error(err);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// The input method hears about the focused field now, after the frame has
|
|
462
|
+
// laid the tree out and the caret rectangle is one a candidate list can
|
|
463
|
+
// be put next to (src/win32/ime.js). Once per window, however many
|
|
464
|
+
// callbacks it had.
|
|
465
|
+
const seen = new Set();
|
|
466
|
+
for (const { wnd } of due) {
|
|
467
|
+
if (!wnd || seen.has(wnd)) continue;
|
|
468
|
+
seen.add(wnd);
|
|
469
|
+
this.inputMethod.sync(wnd);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- the event channel ----------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* One bridge event. Everything arrives on Node's own loop through the
|
|
477
|
+
* threadsafe function, inside a callback scope that drains microtasks when
|
|
478
|
+
* it closes — so a handler's setState commits on the event that caused it.
|
|
479
|
+
*/
|
|
480
|
+
_route(event) {
|
|
481
|
+
// The shell's events are not a window's: a tray icon, a file dialog and a
|
|
482
|
+
// hotkey each carry an id of their own, and looking one up in the window
|
|
483
|
+
// map would drop it.
|
|
484
|
+
switch (event.type) {
|
|
485
|
+
// The compositor is ready for a frame. Carries no id and belongs to no
|
|
486
|
+
// window: it is the clock, and every window that asked for a frame in
|
|
487
|
+
// the meantime is paced by this one tick.
|
|
488
|
+
case 'frame-clock':
|
|
489
|
+
if (this._frameTimer) clearTimeout(this._frameTimer);
|
|
490
|
+
this._frameTimer = null;
|
|
491
|
+
this._framePending = false;
|
|
492
|
+
if (!this._closed) this._tickFrames();
|
|
493
|
+
return;
|
|
494
|
+
case 'tray-click':
|
|
495
|
+
this._statusItems.get(event.id)?._emit('click', event);
|
|
496
|
+
return;
|
|
497
|
+
case 'tray-action':
|
|
498
|
+
this._statusItems.get(event.id)?._activate(event.text ?? '');
|
|
499
|
+
return;
|
|
500
|
+
case 'tray-ready':
|
|
501
|
+
case 'tray-failed':
|
|
502
|
+
return;
|
|
503
|
+
case 'gl-ready':
|
|
504
|
+
this._glWindows.get(event.id)?._onReady(event.a === 1, event.b);
|
|
505
|
+
return;
|
|
506
|
+
case 'file-dialog':
|
|
507
|
+
this.filePanels?._answer(event.id, event.a === 1, event.text ?? '');
|
|
508
|
+
return;
|
|
509
|
+
case 'hotkey':
|
|
510
|
+
case 'hotkey-registered':
|
|
511
|
+
return;
|
|
512
|
+
default:
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const wnd = this._windows.get(event.id);
|
|
517
|
+
if (!wnd) return;
|
|
518
|
+
switch (event.type) {
|
|
519
|
+
case 'window-ready':
|
|
520
|
+
wnd._onReady(event.a, event.b);
|
|
521
|
+
break;
|
|
522
|
+
case 'move':
|
|
523
|
+
wnd._noteOrigin(event.a, event.b);
|
|
524
|
+
break;
|
|
525
|
+
case 'resize': {
|
|
526
|
+
const width = Math.max(1, Math.round(event.a));
|
|
527
|
+
const height = Math.max(1, Math.round(event.b));
|
|
528
|
+
if (width === wnd.width && height === wnd.height) break;
|
|
529
|
+
wnd.width = width;
|
|
530
|
+
wnd.height = height;
|
|
531
|
+
if (wnd._composed) this._native.resize(wnd.id, width, height);
|
|
532
|
+
wnd.emit('resize', { width, height });
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case 'mousemove':
|
|
536
|
+
wnd.emit('mousemove', {
|
|
537
|
+
x: event.a,
|
|
538
|
+
y: event.b,
|
|
539
|
+
...rootOf(wnd, event),
|
|
540
|
+
});
|
|
541
|
+
break;
|
|
542
|
+
case 'mouseout':
|
|
543
|
+
wnd.emit('mouseout', {});
|
|
544
|
+
break;
|
|
545
|
+
case 'wheel': {
|
|
546
|
+
const deltaX = event.c;
|
|
547
|
+
const deltaY = event.d;
|
|
548
|
+
wnd.emit('wheel', {
|
|
549
|
+
name: 'wheel',
|
|
550
|
+
x: event.a,
|
|
551
|
+
y: event.b,
|
|
552
|
+
rootx: event.a,
|
|
553
|
+
rooty: event.b,
|
|
554
|
+
buttons: 0,
|
|
555
|
+
deltaX,
|
|
556
|
+
deltaY,
|
|
557
|
+
deltaMode: 'line',
|
|
558
|
+
// A precision touchpad sends fractions of a notch by default and an
|
|
559
|
+
// addon cannot opt out of them, so a fraction is exactly the signal
|
|
560
|
+
// that this came from one.
|
|
561
|
+
smooth: !Number.isInteger(deltaX) || !Number.isInteger(deltaY),
|
|
562
|
+
source: 'wheel',
|
|
563
|
+
});
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
case 'mousedown':
|
|
567
|
+
case 'mouseup':
|
|
568
|
+
// `keycode` is the button, numbered as X numbers them — the bridge
|
|
569
|
+
// already speaks that vocabulary. It used to be hardcoded to 1, so
|
|
570
|
+
// a right-click arrived as a left-click and no context menu ever
|
|
571
|
+
// opened.
|
|
572
|
+
wnd.emit(event.type, {
|
|
573
|
+
x: event.a,
|
|
574
|
+
y: event.b,
|
|
575
|
+
keycode: event.c || 1,
|
|
576
|
+
buttons: modifierMask(event.d),
|
|
577
|
+
...rootOf(wnd, event),
|
|
578
|
+
});
|
|
579
|
+
// A press in one window is a press *outside* every open menu but the
|
|
580
|
+
// one it landed in — which on X11 the grab would have delivered to
|
|
581
|
+
// the menu instead of to this window.
|
|
582
|
+
if (event.type === 'mousedown') this._dismissOutsidePopups(wnd);
|
|
583
|
+
break;
|
|
584
|
+
case 'keydown':
|
|
585
|
+
case 'keyup': {
|
|
586
|
+
// The bridge asked the active layout what the key types before the
|
|
587
|
+
// modifiers were stripped from it, because that question can only be
|
|
588
|
+
// answered where the keyboard state is (src/win32.cc, EmitKey).
|
|
589
|
+
const decoded = decodeKey(event);
|
|
590
|
+
wnd.emit(event.type, {
|
|
591
|
+
keycode: event.a,
|
|
592
|
+
keysym: decoded.keysym,
|
|
593
|
+
baseKeysym: decoded.baseKeysym,
|
|
594
|
+
codepoint: decoded.codepoint,
|
|
595
|
+
buttons: modifierMask(event.d),
|
|
596
|
+
group: 0,
|
|
597
|
+
time: Date.now(),
|
|
598
|
+
});
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
// The shell's drag, on its way through one of our windows. The
|
|
602
|
+
// transport answers each of these before returning — for a motion the
|
|
603
|
+
// answer is remembered for the next one, and for the drop the bridge's
|
|
604
|
+
// UI thread is waiting inside `IDropTarget::Drop` for exactly it.
|
|
605
|
+
case 'drag-enter':
|
|
606
|
+
case 'drag-over':
|
|
607
|
+
case 'drag-leave':
|
|
608
|
+
case 'drag-drop':
|
|
609
|
+
wnd._routeDrag(event);
|
|
610
|
+
return;
|
|
611
|
+
// Our own drag, which the shell is carrying. `DoDragDrop` reports no
|
|
612
|
+
// motion of its own, so these come from the source's feedback
|
|
613
|
+
// callback — the only news of the gesture while the shell owns the
|
|
614
|
+
// pointer, and what a `<popup dragPreview>` follows.
|
|
615
|
+
case 'drag-session-moved':
|
|
616
|
+
this._activeDrag?.nativeMoved(this._dragPoint(event));
|
|
617
|
+
return;
|
|
618
|
+
case 'drag-session-ended': {
|
|
619
|
+
const drag = this._activeDrag;
|
|
620
|
+
this._activeDrag = null;
|
|
621
|
+
drag?.nativeEnded({
|
|
622
|
+
...this._dragPoint(event),
|
|
623
|
+
operation: event.c ? String(event.text ?? '') || null : null,
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// Activation, which the tree reads as focus: a caret blinks, a focus
|
|
628
|
+
// ring is drawn, and a `<window>`'s `focused` state follows it.
|
|
629
|
+
// A thumbnail toolbar button. The shell sends the index it was given;
|
|
630
|
+
// the caller's own id for that button is what the handler wants.
|
|
631
|
+
case 'thumbbutton': {
|
|
632
|
+
const ids = this._thumbButtons?.get(event.id) ?? [];
|
|
633
|
+
wnd.emit('thumbbutton', {
|
|
634
|
+
id: ids[event.a] ?? event.a,
|
|
635
|
+
index: event.a,
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
case 'window-focus':
|
|
640
|
+
case 'window-blur':
|
|
641
|
+
wnd.emit(event.type === 'window-focus' ? 'focus' : 'blur', {
|
|
642
|
+
buttons: 0,
|
|
643
|
+
time: Date.now(),
|
|
644
|
+
});
|
|
645
|
+
// A field focused before the window got the keyboard should compose
|
|
646
|
+
// from the first key rather than from the first frame after it.
|
|
647
|
+
// Nothing is done on blur: Windows deactivates the IME for a window
|
|
648
|
+
// that is not in front, and a composition left open is one the user
|
|
649
|
+
// comes back to, which is what every other application does.
|
|
650
|
+
if (event.type === 'window-focus') this.inputMethod.sync(wnd);
|
|
651
|
+
// Losing activation is the other half of the grab this platform does
|
|
652
|
+
// not have: the press went to another application or to the desktop.
|
|
653
|
+
// A popup never takes activation (`WS_EX_NOACTIVATE`), so opening one
|
|
654
|
+
// raises no blur and this cannot fire on the menu's own appearance.
|
|
655
|
+
if (event.type === 'window-blur' && !this._dismissOnOutside.has(wnd)) {
|
|
656
|
+
this._dismissOutsidePopups(null);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
case 'ime-start':
|
|
660
|
+
case 'ime-preedit':
|
|
661
|
+
case 'ime-commit':
|
|
662
|
+
case 'ime-end':
|
|
663
|
+
this.inputMethod.handle(event, wnd);
|
|
664
|
+
return;
|
|
665
|
+
case 'uia-wanted':
|
|
666
|
+
// A screen reader just attached to this window. The tree it reads is
|
|
667
|
+
// built now rather than left at whatever the last commit produced.
|
|
668
|
+
this._a11y?.wanted(event.id);
|
|
669
|
+
return;
|
|
670
|
+
case 'uia-action':
|
|
671
|
+
// A screen reader asking for something to happen. A request, not a
|
|
672
|
+
// change: the tree decides, through the same click a pointer makes.
|
|
673
|
+
this._a11y?.action(event);
|
|
674
|
+
return;
|
|
675
|
+
case 'close':
|
|
676
|
+
// The same shape Cocoa sends. `preventDefault` is a no-op because
|
|
677
|
+
// nothing has happened yet to prevent: WM_CLOSE is answered with 0
|
|
678
|
+
// and the window is still there, so the request is the app's to
|
|
679
|
+
// grant. The listener calls it before deciding, and a close event
|
|
680
|
+
// without it throws before `onCloseRequest` is ever reached.
|
|
681
|
+
wnd.emit('close', { preventDefault() {} });
|
|
682
|
+
break;
|
|
683
|
+
case 'appearance': {
|
|
684
|
+
// Re-read whole, and the cached bezels go with it: every one of them
|
|
685
|
+
// was drawn in the appearance that just stopped being true.
|
|
686
|
+
const next = this.systemAppearance();
|
|
687
|
+
this._syncBezels(next?.colorScheme);
|
|
688
|
+
for (const fn of [...this._appearanceListeners]) {
|
|
689
|
+
try {
|
|
690
|
+
fn(next);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
if (process.env.NODE_ENV !== 'production') console.error(err);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case 'dpichanged':
|
|
698
|
+
// The renderer cannot re-scale a live window yet (docs/windows.md
|
|
699
|
+
// §Layout, open question 8), so this is recorded and not acted on.
|
|
700
|
+
wnd.emit('statechange', { scale: event.a / 96 });
|
|
701
|
+
break;
|
|
702
|
+
default:
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export async function createWin32App(options = {}) {
|
|
709
|
+
const native = loadNative();
|
|
710
|
+
const app = new Win32App(native, options);
|
|
711
|
+
|
|
712
|
+
native.start((event) => app._route(event));
|
|
713
|
+
|
|
714
|
+
// Before the first window, so a control's very first frame is drawn with the
|
|
715
|
+
// bezels it will keep rather than swapping to them a frame later.
|
|
716
|
+
app._syncBezels(app.systemAppearance()?.colorScheme);
|
|
717
|
+
// What each shell rung is actually built on. `src/capabilities.js` reports
|
|
718
|
+
// this as a capability's `backend`, and it has to be declared rather than
|
|
719
|
+
// inferred: this backend installs the same method names AppKit does -- that
|
|
720
|
+
// is what makes `useTray` and `useBadge` one hook each -- so a probe reading
|
|
721
|
+
// `createStatusItem` called Shell_NotifyIcon `cocoa` and handed out AppKit's
|
|
722
|
+
// feature map with it. Mechanisms, never platforms (AGENTS.md).
|
|
723
|
+
app.shellMechanisms = { tray: 'shellnotifyicon', launcher: 'taskbar' };
|
|
724
|
+
// The accessibility bridge this backend has of its own. Its presence is
|
|
725
|
+
// what `startA11y()` reads to know not to climb toward an AT-SPI bus that
|
|
726
|
+
// is not there; the import is deferred so a process with no screen reader
|
|
727
|
+
// never loads it (src/win32/a11y.js).
|
|
728
|
+
app.startAccessibility = async () => {
|
|
729
|
+
const { startWin32Accessibility } = await import('./a11y.js');
|
|
730
|
+
return startWin32Accessibility(app);
|
|
731
|
+
};
|
|
732
|
+
installTaskbar(app);
|
|
733
|
+
// A notification centre and the two session-wide facts — whether anybody is
|
|
734
|
+
// at the keyboard, and whether the screen may sleep. All three are seams the
|
|
735
|
+
// core looks for on the app and finds on no backend but the one it is on.
|
|
736
|
+
installNotifications(app);
|
|
737
|
+
installIdle(app);
|
|
738
|
+
// The three the taskbar has and no other desktop does. Installing them is
|
|
739
|
+
// what the launcher capability's `tasks`, `thumbnailToolbar` and
|
|
740
|
+
// `recentDocuments` features read, so an app asks what this desktop has
|
|
741
|
+
// rather than which platform it is on.
|
|
742
|
+
installTaskbarSurfaces(app);
|
|
743
|
+
// The GL ladder, which decides whether <glarea> has a rung here at all.
|
|
744
|
+
installGl(app);
|
|
745
|
+
|
|
746
|
+
// The ladder is otherwise climbed only when something asks — and its Windows
|
|
747
|
+
// rung needs an app to ask, which nothing but this has. Started here and not
|
|
748
|
+
// awaited: the first frame is drawn from the answer this machine gave last
|
|
749
|
+
// time and the live one lands behind it, which is the direction
|
|
750
|
+
// AGENTS.md asks appearance to settle in. A failure is the ladder's own to
|
|
751
|
+
// report; there is nothing useful to do with it here.
|
|
752
|
+
void systemAppearance({ app }).catch(() => {});
|
|
753
|
+
|
|
754
|
+
setScaleForTests(app, app.scale, 'win32');
|
|
755
|
+
setScreensForTests(app, {
|
|
756
|
+
monitors: app._screens.map((s) => ({
|
|
757
|
+
x: s.x ?? 0,
|
|
758
|
+
y: s.y ?? 0,
|
|
759
|
+
width: s.width,
|
|
760
|
+
height: s.height,
|
|
761
|
+
})),
|
|
762
|
+
});
|
|
763
|
+
// DWM is always compositing: there is no "no compositor" state to probe for
|
|
764
|
+
// the way there is on X11.
|
|
765
|
+
setCompositingForTests(app, true);
|
|
766
|
+
|
|
767
|
+
return app;
|
|
768
|
+
}
|