@desklink/react-native 0.1.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.
@@ -0,0 +1,1164 @@
1
+ import type { NativeDesklinkModule, NativeEventName, NativeSessionEvent } from './native';
2
+ import { observeWebKeyboardMotion } from './webKeyboardMotion';
3
+
4
+ /**
5
+ * The browser implementation of the client's session interface.
6
+ *
7
+ * Metro resolves `./native` to this file on web, so `useDesktopSession` and the
8
+ * app's screen are unchanged: the same states, the same control messages, the
9
+ * same lifecycle. What differs is underneath — the browser's own
10
+ * `RTCPeerConnection` instead of the native binding, a `<video>` element instead
11
+ * of a native renderer, and DOM keyboard and pointer handling instead of the
12
+ * platform's.
13
+ *
14
+ * The gestures live here rather than in the view for the same reason they live
15
+ * in the native view: a drag produces tens of events a second, and routing each
16
+ * one through a React render would add jitter to the interaction the user judges
17
+ * the whole feature by.
18
+ */
19
+
20
+ /** Travel in CSS pixels below which a touch is a tap or a hold, not a move. */
21
+ const TOUCH_SLOP = 8;
22
+
23
+ /** A touch held this long without moving arms the right click and the drag. */
24
+ const LONG_PRESS_MS = 400;
25
+
26
+ /** Two taps this close in time are a double click on one desktop point. */
27
+ const DOUBLE_TAP_MS = 300;
28
+ const DOUBLE_TAP_SLOP = 40;
29
+
30
+ /** Two fingers that land and lift this quickly without moving are a right click. */
31
+ const TWO_FINGER_TAP_MS = 300;
32
+
33
+ /** The closest the picture can be zoomed: CSS pixels per desktop pixel. */
34
+ const MAX_SCALE = 2.5;
35
+
36
+ /**
37
+ * Desktop pixels one wheel detent stands for, so a two-finger scroll moves the
38
+ * content about as far as the fingers moved at any zoom.
39
+ */
40
+ const PIXELS_PER_DETENT = 120;
41
+
42
+ /** Wheel steps smaller than this wait for more movement. */
43
+ const MIN_WHEEL_STEP = 0.05;
44
+ const MOVE_BUFFER_LIMIT = 4096;
45
+
46
+ /** The first picture comes up out of black rather than cutting in. */
47
+ const REVEAL = 'opacity 280ms ease-out';
48
+
49
+ /**
50
+ * The pointer, drawn at a size a phone can see: black with a white edge, so it
51
+ * reads on a light page and a dark one. The tip is the hotspot, 3 px in from
52
+ * the element's corner to leave room for the edge and its shadow.
53
+ */
54
+ const POINTER_HOTSPOT = 3;
55
+ const POINTER_MARK = '<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">'
56
+ + '<path d="M3 3 L3 22.25 L7.73 17.96 L10.92 25.22 L14 23.9 L10.92 16.86 L17.08 16.86 Z" '
57
+ + 'fill="#000" stroke="#fff" stroke-width="2.2" stroke-linejoin="round" paint-order="stroke"/></svg>';
58
+
59
+ type Gesture = 'none' | 'pending' | 'letterbox' | 'pan' | 'hover' | 'armed' | 'drag' | 'two' | 'pinch' | 'scroll' | 'spent' | 'mouse';
60
+
61
+ const MODIFIER_NAMES = new Set(['Control', 'Shift', 'Alt', 'Meta']);
62
+
63
+ interface WebSession {
64
+ id: string;
65
+ peer: RTCPeerConnection;
66
+ channel: RTCDataChannel | null;
67
+ pendingMove: Record<string, unknown> | null;
68
+ moveFrame: number | null;
69
+ video: HTMLVideoElement;
70
+ keyboard: HTMLTextAreaElement;
71
+ /** Where the next click lands, drawn over the picture once a touch has put it somewhere. */
72
+ mark: HTMLDivElement;
73
+ /** The desktop point the pointer was last sent to by touch; null hides the mark. */
74
+ pointerAt: { x: number; y: number } | null;
75
+ /** The last press came from a mouse, whose own cursor needs no mark. */
76
+ mouse: boolean;
77
+ surface: HTMLDivElement | null;
78
+ width: number;
79
+ height: number;
80
+ /**
81
+ * The picture's placement in the surface: CSS pixels per desktop pixel and
82
+ * where the desktop's top-left sits. `fitted` keeps a picture that shows
83
+ * the whole desktop showing it when the surface changes size.
84
+ */
85
+ view: { scale: number; originX: number; originY: number; fitted: boolean };
86
+ /**
87
+ * How much of the surface's bottom the phone's keyboard and the app's own
88
+ * controls above it cover, in CSS pixels. The picture is placed in the rest.
89
+ */
90
+ covered: number;
91
+ /** Space the app keeps above the keyboard for its controls. */
92
+ clearance: number;
93
+ gesture: Gesture;
94
+ /** Touches on the surface, by pointer id, in surface coordinates. */
95
+ touches: Map<number, { x: number; y: number }>;
96
+ downX: number;
97
+ downY: number;
98
+ downOnPicture: boolean;
99
+ lastX: number;
100
+ lastY: number;
101
+ /** The last desktop point a held button was moved to. */
102
+ dragX: number;
103
+ dragY: number;
104
+ twoStart: number;
105
+ startSpan: number;
106
+ lastSpan: number;
107
+ startFocusX: number;
108
+ startFocusY: number;
109
+ lastFocusX: number;
110
+ lastFocusY: number;
111
+ wheelX: number;
112
+ wheelY: number;
113
+ lastTap: { at: number; x: number; y: number; point: { x: number; y: number } } | null;
114
+ longPress: ReturnType<typeof setTimeout> | null;
115
+ /** While a sticky modifier waits for its key, typing is the session's to chord. */
116
+ captured: boolean;
117
+ inputEnabled: boolean;
118
+ /** The word the keyboard is composing, and how much of it has already gone. */
119
+ composition: string;
120
+ compositionSent: string;
121
+ composing: boolean;
122
+ /** Chorded keys that are down on the desktop, by the character sent for them. */
123
+ chordsDown: Set<string>;
124
+ /** Re-measure what the keyboard covers; null until a surface is attached. */
125
+ followKeyboard: (() => void) | null;
126
+ /** Candidates that arrived before the offer; applied once it is set. */
127
+ remoteDescriptionSet: boolean;
128
+ pendingCandidates: RTCIceCandidateInit[];
129
+ detach: (() => void) | null;
130
+ }
131
+
132
+ const sessions = new Map<string, WebSession>();
133
+ const listeners = new Set<(event: NativeSessionEvent) => void>();
134
+ let counter = 0;
135
+
136
+ function emit(sessionId: string, name: NativeEventName, payload: Record<string, unknown>): void {
137
+ for (const listener of listeners) listener({ sessionId, name, payload });
138
+ }
139
+
140
+ const NAMED_KEYS: Record<string, string> = {
141
+ Enter: 'Enter',
142
+ Backspace: 'Backspace',
143
+ Tab: 'Tab',
144
+ Escape: 'Escape',
145
+ Delete: 'Delete',
146
+ ArrowLeft: 'ArrowLeft',
147
+ ArrowRight: 'ArrowRight',
148
+ ArrowUp: 'ArrowUp',
149
+ ArrowDown: 'ArrowDown',
150
+ Home: 'Home',
151
+ End: 'End',
152
+ PageUp: 'PageUp',
153
+ PageDown: 'PageDown',
154
+ Control: 'Control',
155
+ Shift: 'Shift',
156
+ Alt: 'Alt',
157
+ Meta: 'Meta',
158
+ };
159
+
160
+ /** The modifiers a keyboard event says are held, in the engine's own names. */
161
+ function heldModifiers(event: KeyboardEvent): string[] {
162
+ const modifiers: string[] = [];
163
+ if (event.ctrlKey) modifiers.push('Control');
164
+ if (event.altKey) modifiers.push('Alt');
165
+ if (event.metaKey) modifiers.push('Meta');
166
+ if (event.shiftKey) modifiers.push('Shift');
167
+ return modifiers;
168
+ }
169
+
170
+ function createSessionElements(): { video: HTMLVideoElement; keyboard: HTMLTextAreaElement; mark: HTMLDivElement } {
171
+ const video = document.createElement('video');
172
+ video.autoplay = true;
173
+ video.muted = true;
174
+ video.playsInline = true;
175
+ video.setAttribute('playsinline', '');
176
+ video.style.position = 'absolute';
177
+ video.style.left = '0px';
178
+ video.style.top = '0px';
179
+ video.style.width = '100%';
180
+ video.style.height = '100%';
181
+ video.style.objectFit = 'contain';
182
+ video.style.touchAction = 'none';
183
+ video.style.display = 'block';
184
+ video.style.opacity = '0';
185
+ video.style.transition = REVEAL;
186
+
187
+ // A real focusable element is what raises the platform keyboard on a phone
188
+ // browser and what owns the composing region; it is invisible because the
189
+ // desktop shows the text, not this.
190
+ const keyboard = document.createElement('textarea');
191
+ keyboard.setAttribute('aria-label', 'Remote keyboard');
192
+ keyboard.style.position = 'absolute';
193
+ keyboard.style.width = '1px';
194
+ keyboard.style.height = '1px';
195
+ keyboard.style.opacity = '0.01';
196
+ keyboard.style.border = '0';
197
+ // No automatic capitals: the desktop decides what a word is, and a shell
198
+ // command that arrives as "Ls" is wrong.
199
+ keyboard.autocapitalize = 'off';
200
+
201
+ const mark = document.createElement('div');
202
+ mark.innerHTML = POINTER_MARK;
203
+ mark.style.position = 'absolute';
204
+ mark.style.left = '0px';
205
+ mark.style.top = '0px';
206
+ mark.style.width = '28px';
207
+ mark.style.height = '28px';
208
+ mark.style.pointerEvents = 'none';
209
+ mark.style.display = 'none';
210
+ mark.style.filter = 'drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35))';
211
+ return { video, keyboard, mark };
212
+ }
213
+
214
+ function scheduleMove(session: WebSession): void {
215
+ if (session.moveFrame !== null || session.pendingMove === null) return;
216
+ session.moveFrame = requestAnimationFrame(() => {
217
+ session.moveFrame = null;
218
+ const channel = session.channel;
219
+ if (channel?.readyState !== 'open' || channel.bufferedAmount > MOVE_BUFFER_LIMIT || session.pendingMove === null) return;
220
+ channel.send(JSON.stringify(session.pendingMove));
221
+ session.pendingMove = null;
222
+ });
223
+ }
224
+
225
+ function control(session: WebSession, message: Record<string, unknown>): void {
226
+ const channel = session.channel;
227
+ if (channel?.readyState !== 'open' || (!session.inputEnabled && ['pointer', 'wheel', 'key', 'text', 'clipboard_read', 'clipboard_write'].includes(String(message.kind)))) return;
228
+ if (message.kind === 'pointer' && message.phase === 'move') {
229
+ session.pendingMove = message;
230
+ scheduleMove(session);
231
+ return;
232
+ }
233
+ if (session.pendingMove !== null) {
234
+ channel.send(JSON.stringify(session.pendingMove));
235
+ session.pendingMove = null;
236
+ }
237
+ channel.send(JSON.stringify(message));
238
+ }
239
+
240
+ /**
241
+ * What a composition has typed beyond what already went while the keyboard was
242
+ * captured. A composition the keyboard rewrote (an autocorrection) cannot take
243
+ * back what the desktop already has, so nothing more of it is sent.
244
+ */
245
+ function unsentComposition(session: WebSession, data: string): string {
246
+ return data.startsWith(session.compositionSent) ? data.slice(session.compositionSent.length) : '';
247
+ }
248
+
249
+ /** The surface's box on the page; the picture is placed inside it. */
250
+ function surfaceRect(session: WebSession): { left: number; top: number; width: number; height: number } {
251
+ return (session.surface ?? session.video).getBoundingClientRect();
252
+ }
253
+
254
+ /**
255
+ * The whole surface decides the fitted size, so the keyboard coming up moves
256
+ * the picture rather than shrinking it.
257
+ */
258
+ function fitScale(session: WebSession): number {
259
+ const rect = surfaceRect(session);
260
+ if (session.width === 0 || session.height === 0 || rect.width === 0 || rect.height === 0) return 1;
261
+ return Math.min(rect.width / session.width, rect.height / session.height);
262
+ }
263
+
264
+ /** The height the keyboard leaves uncovered, which is where the picture is placed. */
265
+ function visibleHeight(session: WebSession): number {
266
+ return Math.max(1, surfaceRect(session).height - session.covered);
267
+ }
268
+
269
+ /** Centre a picture smaller than what is visible on that axis; keep a larger one covering it. */
270
+ function clampOrigin(session: WebSession): void {
271
+ const rect = surfaceRect(session);
272
+ const visible = visibleHeight(session);
273
+ const { view } = session;
274
+ const width = session.width * view.scale;
275
+ const height = session.height * view.scale;
276
+ view.originX = width <= rect.width ? (rect.width - width) / 2 : Math.min(0, Math.max(rect.width - width, view.originX));
277
+ view.originY = height <= visible ? (visible - height) / 2 : Math.min(0, Math.max(visible - height, view.originY));
278
+ }
279
+
280
+ /** Place the video where the view says; the browser scales it on the GPU. */
281
+ function layoutPicture(session: WebSession): void {
282
+ if (session.width === 0 || session.height === 0) return;
283
+ const fit = fitScale(session);
284
+ const { view } = session;
285
+ view.scale = view.fitted ? fit : Math.min(Math.max(view.scale, fit), Math.max(fit, MAX_SCALE));
286
+ view.fitted = view.scale <= fit * 1.001;
287
+ clampOrigin(session);
288
+ const { style } = session.video;
289
+ style.objectFit = 'fill';
290
+ style.left = `${view.originX}px`;
291
+ style.top = `${view.originY}px`;
292
+ style.width = `${session.width * view.scale}px`;
293
+ style.height = `${session.height * view.scale}px`;
294
+ placePointer(session);
295
+ }
296
+
297
+ /** Put the pointer mark on the desktop point it was last sent to. */
298
+ function placePointer(session: WebSession): void {
299
+ const { mark, pointerAt, view } = session;
300
+ if (pointerAt === null) {
301
+ mark.style.display = 'none';
302
+ return;
303
+ }
304
+ const x = view.originX + (pointerAt.x + 0.5) * view.scale - POINTER_HOTSPOT;
305
+ const y = view.originY + (pointerAt.y + 0.5) * view.scale - POINTER_HOTSPOT;
306
+ mark.style.transform = `translate3d(${x}px, ${y}px, 0)`;
307
+ mark.style.display = 'block';
308
+ }
309
+
310
+ /**
311
+ * The keyboard took (or gave back) the bottom of the surface. A picture that
312
+ * fits what is left is centred in it by the layout; a larger one is moved so
313
+ * the pointer, where a click just put the caret, stays in sight — or, with no
314
+ * pointer yet, so the middle of what was shown stays in the middle.
315
+ */
316
+ function coverBottom(session: WebSession, covered: number): void {
317
+ if (Math.abs(covered - session.covered) < 0.5) return;
318
+ const before = visibleHeight(session);
319
+ session.covered = covered;
320
+ const visible = visibleHeight(session);
321
+ const { view, pointerAt } = session;
322
+ if (pointerAt === null || visible > before) view.originY += (visible - before) / 2;
323
+ if (pointerAt !== null) {
324
+ const y = view.originY + (pointerAt.y + 0.5) * view.scale;
325
+ const margin = Math.min(56, visible / 4);
326
+ if (y > visible - margin) view.originY -= y - (visible - margin);
327
+ }
328
+ layoutPicture(session);
329
+ }
330
+
331
+ function zoomAround(session: WebSession, focusX: number, focusY: number, factor: number): void {
332
+ const fit = fitScale(session);
333
+ const { view } = session;
334
+ const next = Math.min(Math.max(view.scale * factor, fit), Math.max(fit, MAX_SCALE));
335
+ const applied = next / view.scale;
336
+ view.originX = focusX - (focusX - view.originX) * applied;
337
+ view.originY = focusY - (focusY - view.originY) * applied;
338
+ view.scale = next;
339
+ view.fitted = next <= fit * 1.001;
340
+ layoutPicture(session);
341
+ }
342
+
343
+ function panBy(session: WebSession, dx: number, dy: number): void {
344
+ session.view.originX += dx;
345
+ session.view.originY += dy;
346
+ layoutPicture(session);
347
+ }
348
+
349
+ /** Page coordinates → the surface's own. */
350
+ function local(session: WebSession, clientX: number, clientY: number): { x: number; y: number } {
351
+ const rect = surfaceRect(session);
352
+ return { x: clientX - rect.left, y: clientY - rect.top };
353
+ }
354
+
355
+ /**
356
+ * Surface coordinates → encoded-surface pixels, or null off the picture. With
357
+ * `clamp`, a point off the picture is held to its edge: a drag may leave it and
358
+ * must still end somewhere.
359
+ */
360
+ function desktopPoint(session: WebSession, x: number, y: number, clamp = false): { x: number; y: number } | null {
361
+ if (session.width === 0 || session.height === 0) return null;
362
+ const { view } = session;
363
+ let px = Math.floor((x - view.originX) / view.scale);
364
+ let py = Math.floor((y - view.originY) / view.scale);
365
+ if (clamp) {
366
+ px = Math.min(Math.max(px, 0), session.width - 1);
367
+ py = Math.min(Math.max(py, 0), session.height - 1);
368
+ }
369
+ if (px < 0 || py < 0 || px >= session.width || py >= session.height) return null;
370
+ return { x: px, y: py };
371
+ }
372
+
373
+ function cancelLongPress(session: WebSession): void {
374
+ if (session.longPress !== null) clearTimeout(session.longPress);
375
+ session.longPress = null;
376
+ }
377
+
378
+ function pointer(session: WebSession, phase: 'move' | 'down' | 'up' | 'cancel', at: { x: number; y: number }, button?: number): void {
379
+ control(session, { kind: 'pointer', phase, x: at.x, y: at.y, ...(button === undefined ? {} : { button }), seq: seq(session) });
380
+ // A mouse brings its own cursor; a finger leaves nothing on the glass to
381
+ // show where the desktop's pointer went, so the mark does.
382
+ if (session.mouse || phase === 'cancel') return;
383
+ session.pointerAt = { x: at.x, y: at.y };
384
+ placePointer(session);
385
+ }
386
+
387
+ function click(session: WebSession, at: { x: number; y: number }, button: number): void {
388
+ pointer(session, 'down', at, button);
389
+ pointer(session, 'up', at, button);
390
+ }
391
+
392
+ /**
393
+ * Touch gestures, the ones mature remote-desktop viewers settled on — the same
394
+ * as the native view's:
395
+ *
396
+ * - tap: click where the finger lands; two taps: a double click on one spot;
397
+ * - press and hold: a right click on release, or drag after it to hold the
398
+ * left button (select text, move a window);
399
+ * - one finger: move around a zoomed-in desktop; on the whole desktop,
400
+ * move its pointer, which follows the finger without pressing a button;
401
+ * - two fingers: scroll the desktop under them, or pinch to zoom (and move)
402
+ * the picture; a quick two-finger tap is a right click.
403
+ *
404
+ * A mouse keeps a mouse's meaning: its buttons press, its wheel scrolls, and a
405
+ * pinch on a trackpad (the browser's ctrl-wheel) zooms the picture.
406
+ */
407
+ function attachGestures(session: WebSession): () => void {
408
+ const { video, keyboard } = session;
409
+ const surface = session.surface ?? video;
410
+
411
+ const longPress = (): void => {
412
+ session.longPress = null;
413
+ if (!session.inputEnabled || session.gesture !== 'pending') return;
414
+ const at = desktopPoint(session, session.downX, session.downY);
415
+ if (at === null) return;
416
+ session.gesture = 'armed';
417
+ session.lastTap = null;
418
+ pointer(session, 'move', at);
419
+ globalThis.navigator?.vibrate?.(10);
420
+ };
421
+
422
+ const endDrag = (x: number, y: number): void => {
423
+ const at = desktopPoint(session, x, y, true);
424
+ if (at !== null) pointer(session, 'up', at, 1);
425
+ else control(session, { kind: 'pointer', phase: 'cancel', x: 0, y: 0, seq: seq(session) });
426
+ };
427
+
428
+ const hoverTo = (x: number, y: number): void => {
429
+ const at = desktopPoint(session, x, y, true);
430
+ if (at !== null) pointer(session, 'move', at);
431
+ };
432
+
433
+ const dragTo = (x: number, y: number): void => {
434
+ session.lastX = x;
435
+ session.lastY = y;
436
+ const at = desktopPoint(session, x, y, true);
437
+ if (at === null) return;
438
+ pointer(session, 'move', at, 1);
439
+ session.dragX = at.x;
440
+ session.dragY = at.y;
441
+ };
442
+
443
+ const tap = (x: number, y: number): void => {
444
+ const now = Date.now();
445
+ const last = session.lastTap;
446
+ const repeat = last !== null && now - last.at < DOUBLE_TAP_MS && Math.hypot(x - last.x, y - last.y) < DOUBLE_TAP_SLOP;
447
+ // A second tap goes to the first tap's point, so the desktop counts a
448
+ // double click rather than two clicks a few pixels apart.
449
+ const at = repeat ? last.point : desktopPoint(session, x, y);
450
+ if (at === null) return;
451
+ click(session, at, 1);
452
+ session.lastTap = { at: now, x, y, point: at };
453
+ };
454
+
455
+ const flushWheel = (force: boolean): void => {
456
+ if (!force && Math.abs(session.wheelX) < MIN_WHEEL_STEP && Math.abs(session.wheelY) < MIN_WHEEL_STEP) return;
457
+ if (session.wheelX === 0 && session.wheelY === 0) return;
458
+ const round = (value: number) => Math.round(Math.min(Math.max(value, -10), 10) * 1000) / 1000;
459
+ control(session, { kind: 'wheel', dx: round(session.wheelX), dy: round(session.wheelY), seq: seq(session) });
460
+ session.wheelX = 0;
461
+ session.wheelY = 0;
462
+ };
463
+
464
+ const pair = (): [{ x: number; y: number }, { x: number; y: number }] => {
465
+ const [a, b] = [...session.touches.values()];
466
+ return [a!, b!];
467
+ };
468
+
469
+ const twoFingers = (): void => {
470
+ const [a, b] = pair();
471
+ const span = Math.hypot(a.x - b.x, a.y - b.y);
472
+ const fx = (a.x + b.x) / 2;
473
+ const fy = (a.y + b.y) / 2;
474
+ if (session.gesture === 'two') {
475
+ // Fingers that spread or close are zooming; fingers that travel
476
+ // together are scrolling. Decided once, so a scroll never turns
477
+ // into a zoom halfway.
478
+ if (Math.abs(span - session.startSpan) > TOUCH_SLOP * 2) {
479
+ session.gesture = 'pinch';
480
+ } else if (Math.hypot(fx - session.startFocusX, fy - session.startFocusY) > TOUCH_SLOP) {
481
+ session.gesture = 'scroll';
482
+ // The desktop scrolls whatever is under its pointer.
483
+ const at = desktopPoint(session, fx, fy);
484
+ if (at !== null) pointer(session, 'move', at);
485
+ }
486
+ } else if (session.gesture === 'pinch') {
487
+ if (session.lastSpan > 0 && span > 0) zoomAround(session, fx, fy, span / session.lastSpan);
488
+ panBy(session, fx - session.lastFocusX, fy - session.lastFocusY);
489
+ } else if (session.gesture === 'scroll') {
490
+ // Content follows the fingers: moving them up scrolls the page down.
491
+ session.wheelX -= (fx - session.lastFocusX) / session.view.scale / PIXELS_PER_DETENT;
492
+ session.wheelY -= (fy - session.lastFocusY) / session.view.scale / PIXELS_PER_DETENT;
493
+ flushWheel(false);
494
+ }
495
+ session.lastSpan = span;
496
+ session.lastFocusX = fx;
497
+ session.lastFocusY = fy;
498
+ };
499
+
500
+ const useMouse = (): void => {
501
+ session.mouse = true;
502
+ if (session.pointerAt === null) return;
503
+ session.pointerAt = null;
504
+ placePointer(session);
505
+ };
506
+
507
+ const pointerDown = (event: PointerEvent): void => {
508
+ if (!session.inputEnabled) return;
509
+ // A press on the desktop is the desktop's: it must not take focus from
510
+ // the remote keyboard, or the phone's keyboard closes under a tap that
511
+ // only meant to put the caret somewhere.
512
+ event.preventDefault();
513
+ try {
514
+ (surface as Element).setPointerCapture(event.pointerId);
515
+ } catch {
516
+ // A synthetic event has no real pointer behind it.
517
+ }
518
+ const { x, y } = local(session, event.clientX, event.clientY);
519
+ if (event.pointerType === 'mouse') useMouse();
520
+ else session.mouse = false;
521
+ if (event.pointerType === 'mouse') {
522
+ const at = desktopPoint(session, x, y);
523
+ if (at === null) return;
524
+ if (event.button === 2) {
525
+ pointer(session, 'down', at, 3);
526
+ return;
527
+ }
528
+ if (event.button !== 0) return;
529
+ session.gesture = 'mouse';
530
+ pointer(session, 'down', at, 1);
531
+ session.dragX = at.x;
532
+ session.dragY = at.y;
533
+ return;
534
+ }
535
+ session.touches.set(event.pointerId, { x, y });
536
+ if (session.touches.size === 1) {
537
+ session.downOnPicture = desktopPoint(session, x, y) !== null;
538
+ session.gesture = session.view.fitted && !session.downOnPicture ? 'letterbox' : 'pending';
539
+ session.downX = x;
540
+ session.downY = y;
541
+ session.lastX = x;
542
+ session.lastY = y;
543
+ cancelLongPress(session);
544
+ if (session.gesture === 'pending') session.longPress = setTimeout(longPress, LONG_PRESS_MS);
545
+ else session.lastTap = null;
546
+ return;
547
+ }
548
+ cancelLongPress(session);
549
+ session.lastTap = null;
550
+ if (session.gesture === 'drag') endDrag(session.lastX, session.lastY);
551
+ if (session.touches.size === 2 && (['pending', 'pan', 'hover', 'armed', 'drag'].includes(session.gesture)
552
+ || (session.gesture === 'letterbox' && desktopPoint(session, x, y) !== null))) {
553
+ const [a, b] = pair();
554
+ session.gesture = 'two';
555
+ session.twoStart = Date.now();
556
+ session.startSpan = Math.hypot(a.x - b.x, a.y - b.y);
557
+ session.lastSpan = session.startSpan;
558
+ session.startFocusX = (a.x + b.x) / 2;
559
+ session.startFocusY = (a.y + b.y) / 2;
560
+ session.lastFocusX = session.startFocusX;
561
+ session.lastFocusY = session.startFocusY;
562
+ session.wheelX = 0;
563
+ session.wheelY = 0;
564
+ return;
565
+ }
566
+ session.gesture = 'spent';
567
+ };
568
+
569
+ const pointerMove = (event: PointerEvent): void => {
570
+ if (!session.inputEnabled) return;
571
+ const { x, y } = local(session, event.clientX, event.clientY);
572
+ if (event.pointerType === 'mouse') {
573
+ useMouse();
574
+ if (session.gesture !== 'mouse') return;
575
+ const at = desktopPoint(session, x, y, true);
576
+ if (at === null) return;
577
+ pointer(session, 'move', at, 1);
578
+ session.dragX = at.x;
579
+ session.dragY = at.y;
580
+ return;
581
+ }
582
+ if (!session.touches.has(event.pointerId)) return;
583
+ session.touches.set(event.pointerId, { x, y });
584
+ switch (session.gesture) {
585
+ case 'pending':
586
+ if (Math.hypot(x - session.downX, y - session.downY) > TOUCH_SLOP) {
587
+ cancelLongPress(session);
588
+ session.lastTap = null;
589
+ // The whole desktop has nowhere to move to, so the finger
590
+ // moves the desktop's pointer instead, without a button.
591
+ session.gesture = session.view.fitted
592
+ ? (session.downOnPicture ? 'hover' : 'letterbox')
593
+ : 'pan';
594
+ session.lastX = x;
595
+ session.lastY = y;
596
+ if (session.gesture === 'hover') hoverTo(x, y);
597
+ }
598
+ return;
599
+ case 'hover':
600
+ hoverTo(x, y);
601
+ return;
602
+ case 'pan':
603
+ panBy(session, x - session.lastX, y - session.lastY);
604
+ session.lastX = x;
605
+ session.lastY = y;
606
+ return;
607
+ case 'armed':
608
+ if (Math.hypot(x - session.downX, y - session.downY) > TOUCH_SLOP) {
609
+ const start = desktopPoint(session, session.downX, session.downY, true);
610
+ if (start === null) return;
611
+ pointer(session, 'down', start, 1);
612
+ session.gesture = 'drag';
613
+ dragTo(x, y);
614
+ }
615
+ return;
616
+ case 'drag':
617
+ dragTo(x, y);
618
+ return;
619
+ case 'two':
620
+ case 'pinch':
621
+ case 'scroll':
622
+ if (session.touches.size >= 2) twoFingers();
623
+ return;
624
+ default:
625
+ return;
626
+ }
627
+ };
628
+
629
+ const pointerUp = (event: PointerEvent): void => {
630
+ if (!session.inputEnabled) return;
631
+ const { x, y } = local(session, event.clientX, event.clientY);
632
+ if (event.pointerType === 'mouse') {
633
+ if (event.button === 2) {
634
+ const at = desktopPoint(session, x, y, true);
635
+ if (at !== null) pointer(session, 'up', at, 3);
636
+ return;
637
+ }
638
+ if (session.gesture !== 'mouse') return;
639
+ session.gesture = 'none';
640
+ endDrag(x, y);
641
+ return;
642
+ }
643
+ if (!session.touches.delete(event.pointerId)) return;
644
+ cancelLongPress(session);
645
+ const cancelled = event.type === 'pointercancel';
646
+ if (session.touches.size > 0) {
647
+ if (session.gesture === 'two' && !cancelled && Date.now() - session.twoStart < TWO_FINGER_TAP_MS) {
648
+ // Two fingers that landed and lifted without moving: a right
649
+ // click, on the picture only — the letterbox is not its edge.
650
+ const at = desktopPoint(session, session.startFocusX, session.startFocusY);
651
+ if (at !== null) click(session, at, 3);
652
+ }
653
+ if (session.gesture === 'scroll') flushWheel(true);
654
+ // What the remaining finger does next is not a new gesture.
655
+ session.gesture = 'spent';
656
+ return;
657
+ }
658
+ const gesture = session.gesture;
659
+ session.gesture = 'none';
660
+ if (cancelled) {
661
+ if (gesture === 'drag') control(session, { kind: 'pointer', phase: 'cancel', x: 0, y: 0, seq: seq(session) });
662
+ return;
663
+ }
664
+ if (gesture === 'pending') tap(x, y);
665
+ else if (gesture === 'hover') hoverTo(x, y);
666
+ else if (gesture === 'armed') {
667
+ const at = desktopPoint(session, session.downX, session.downY);
668
+ if (at !== null) click(session, at, 3);
669
+ } else if (gesture === 'drag') endDrag(x, y);
670
+ };
671
+
672
+ const wheel = (event: WheelEvent): void => {
673
+ if (!session.inputEnabled) return;
674
+ event.preventDefault();
675
+ const { x, y } = local(session, event.clientX, event.clientY);
676
+ if (event.ctrlKey) {
677
+ // A trackpad pinch arrives as a ctrl-wheel: zoom the picture.
678
+ zoomAround(session, x, y, Math.exp(-event.deltaY / 200));
679
+ return;
680
+ }
681
+ // Pixels, lines or pages, to detents: a notch is about 100 pixels.
682
+ const unit = event.deltaMode === 1 ? 1 / 3 : event.deltaMode === 2 ? 3 : 1 / 100;
683
+ session.wheelX += event.deltaX * unit;
684
+ session.wheelY += event.deltaY * unit;
685
+ flushWheel(false);
686
+ };
687
+
688
+ // The picture keeps its place when the surface changes size.
689
+ const Observer = (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
690
+ const resize = Observer === undefined ? null : new Observer(() => layoutPicture(session));
691
+ resize?.observe(surface as Element);
692
+
693
+ // A phone browser lays its keyboard over the page rather than resizing it,
694
+ // so the covered part is what the visual viewport no longer shows.
695
+ const viewport = (globalThis as { visualViewport?: VisualViewport }).visualViewport;
696
+ let phase = 0;
697
+ const followKeyboard = (): void => {
698
+ const rect = surfaceRect(session);
699
+ const shown = viewport === undefined ? rect.top + rect.height : viewport.offsetTop + viewport.height;
700
+ const overlap = Math.max(0, rect.top + rect.height - shown);
701
+ coverBottom(session, overlap > 0 ? overlap + session.clearance * phase : 0);
702
+ };
703
+ session.followKeyboard = followKeyboard;
704
+ const stopFollowing = observeWebKeyboardMotion((motion) => {
705
+ phase = motion.phase;
706
+ followKeyboard();
707
+ });
708
+
709
+ // What the phone types goes to the desktop, or, while a sticky modifier
710
+ // waits for its key, to the session, which sends it as that key's chord.
711
+ const typeText = (text: string): void => {
712
+ if (!session.inputEnabled) return;
713
+ if (session.captured) emit(session.id, 'keyboard', { text });
714
+ else control(session, { kind: 'text', text, seq: seq(session) });
715
+ };
716
+ const tapKey = (name: string): void => {
717
+ if (!session.inputEnabled) return;
718
+ if (session.captured) {
719
+ emit(session.id, 'keyboard', { key: name });
720
+ return;
721
+ }
722
+ control(session, { kind: 'key', name, down: true, seq: seq(session) });
723
+ control(session, { kind: 'key', name, down: false, seq: seq(session) });
724
+ };
725
+
726
+ const compositionStart = (): void => {
727
+ if (!session.inputEnabled) return;
728
+ session.composing = true;
729
+ session.composition = '';
730
+ session.compositionSent = '';
731
+ };
732
+ const compositionUpdate = (event: CompositionEvent): void => {
733
+ if (!session.inputEnabled) return;
734
+ session.composition = event.data;
735
+ if (!session.captured) return;
736
+ // A chord is one key, not the end of a word: it goes as it is typed.
737
+ const typed = unsentComposition(session, event.data);
738
+ session.compositionSent = event.data;
739
+ if (typed !== '') typeText(typed);
740
+ };
741
+ const compositionEnd = (event: CompositionEvent): void => {
742
+ session.composing = false;
743
+ if (!session.inputEnabled) return;
744
+ const typed = unsentComposition(session, event.data);
745
+ session.composition = '';
746
+ session.compositionSent = '';
747
+ if (typed !== '') typeText(typed);
748
+ keyboard.value = '';
749
+ };
750
+ const beforeInput = (event: InputEvent): void => {
751
+ if (!session.inputEnabled) return;
752
+ if (session.composing || event.isComposing) return;
753
+ if (event.inputType === 'insertText' && event.data != null) {
754
+ event.preventDefault();
755
+ typeText(event.data);
756
+ } else if (event.inputType === 'deleteContentBackward') {
757
+ event.preventDefault();
758
+ tapKey('Backspace');
759
+ } else if (event.inputType === 'insertLineBreak') {
760
+ event.preventDefault();
761
+ tapKey('Enter');
762
+ }
763
+ };
764
+ const keyEvent = (event: KeyboardEvent, down: boolean): void => {
765
+ if (!session.inputEnabled || session.composing) return;
766
+ const name = NAMED_KEYS[event.key];
767
+ if (name !== undefined) {
768
+ event.preventDefault();
769
+ // A plain key pressed while a sticky modifier waits is the
770
+ // session's to chord. Only the press is held back: a release still
771
+ // goes, so a key that was down before the capture cannot stick.
772
+ if (session.captured && down && heldModifiers(event).length === 0 && !MODIFIER_NAMES.has(name)) {
773
+ tapKey(name);
774
+ return;
775
+ }
776
+ // A modifier the user is holding belongs to this key too, or a
777
+ // chorded arrow/tab after Ctrl+C would arrive as a plain key once
778
+ // the chord's own key-up released the shared modifier.
779
+ control(session, { kind: 'key', name, modifiers: heldModifiers(event), down, seq: seq(session) });
780
+ return;
781
+ }
782
+ // A chorded letter never reaches `beforeinput`: the browser turns Ctrl+C
783
+ // into a copy command on the hidden textarea. Alt is deliberately not a
784
+ // chord here — it composes a character with the layout (macOS Option,
785
+ // Windows AltGr), and that has to stay on the text path.
786
+ const modifiers = heldModifiers(event);
787
+ const physical = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(event.code);
788
+ const character = (physical?.[1] ?? physical?.[2])?.toLowerCase();
789
+ if (character === undefined) return;
790
+ if ((event.ctrlKey || event.metaKey) && (down || session.chordsDown.has(event.code))) {
791
+ event.preventDefault();
792
+ control(session, { kind: 'key', character, modifiers, down, seq: seq(session) });
793
+ if (down) session.chordsDown.add(event.code);
794
+ else session.chordsDown.delete(event.code);
795
+ return;
796
+ }
797
+ if (!down && session.chordsDown.delete(event.code)) {
798
+ event.preventDefault();
799
+ control(session, { kind: 'key', character, modifiers: [], down: false, seq: seq(session) });
800
+ }
801
+ };
802
+ const keyDown = (event: KeyboardEvent): void => keyEvent(event, true);
803
+ const keyUp = (event: KeyboardEvent): void => keyEvent(event, false);
804
+
805
+ // The browser's own menu for the video would cover the desktop's.
806
+ const contextMenu = (event: Event): void => event.preventDefault();
807
+
808
+ surface.addEventListener('pointerdown', pointerDown as EventListener);
809
+ surface.addEventListener('pointermove', pointerMove as EventListener);
810
+ surface.addEventListener('pointerup', pointerUp as EventListener);
811
+ surface.addEventListener('pointercancel', pointerUp as EventListener);
812
+ surface.addEventListener('wheel', wheel as EventListener, { passive: false });
813
+ surface.addEventListener('contextmenu', contextMenu);
814
+ keyboard.addEventListener('compositionstart', compositionStart);
815
+ keyboard.addEventListener('compositionupdate', compositionUpdate);
816
+ keyboard.addEventListener('compositionend', compositionEnd);
817
+ keyboard.addEventListener('beforeinput', beforeInput);
818
+ keyboard.addEventListener('keydown', keyDown);
819
+ keyboard.addEventListener('keyup', keyUp);
820
+
821
+ return () => {
822
+ cancelLongPress(session);
823
+ resize?.disconnect();
824
+ stopFollowing();
825
+ session.followKeyboard = null;
826
+ surface.removeEventListener('pointerdown', pointerDown as EventListener);
827
+ surface.removeEventListener('pointermove', pointerMove as EventListener);
828
+ surface.removeEventListener('pointerup', pointerUp as EventListener);
829
+ surface.removeEventListener('pointercancel', pointerUp as EventListener);
830
+ surface.removeEventListener('wheel', wheel as EventListener);
831
+ surface.removeEventListener('contextmenu', contextMenu);
832
+ keyboard.removeEventListener('compositionstart', compositionStart);
833
+ keyboard.removeEventListener('compositionupdate', compositionUpdate);
834
+ keyboard.removeEventListener('compositionend', compositionEnd);
835
+ keyboard.removeEventListener('beforeinput', beforeInput);
836
+ keyboard.removeEventListener('keydown', keyDown);
837
+ keyboard.removeEventListener('keyup', keyUp);
838
+ };
839
+ }
840
+
841
+ /** Sequence numbers are per session and monotonic; the engine refuses replays. */
842
+ function seq(session: WebSession): number {
843
+ const next = (sequence.get(session.id) ?? 0) + 1;
844
+ sequence.set(session.id, next);
845
+ return next;
846
+ }
847
+
848
+ const sequence = new Map<string, number>();
849
+
850
+ /** A browser is always able to show a desktop: it has its own WebRTC stack. */
851
+ export const desktopAvailable = true;
852
+
853
+ export const nativeDesklink: NativeDesklinkModule = {
854
+ isAvailable: () => true,
855
+
856
+ createSession(iceServersJson: string): string | null {
857
+ counter += 1;
858
+ const id = `web-${counter}`;
859
+ const { video, keyboard, mark } = createSessionElements();
860
+ const servers = JSON.parse(iceServersJson === '' ? '[]' : iceServersJson) as Array<{
861
+ urls: string[];
862
+ username?: string;
863
+ credential?: string;
864
+ }>;
865
+ const peer = new RTCPeerConnection({ iceServers: servers });
866
+ const session: WebSession = {
867
+ id,
868
+ peer,
869
+ channel: null,
870
+ pendingMove: null,
871
+ moveFrame: null,
872
+ video,
873
+ keyboard,
874
+ mark,
875
+ pointerAt: null,
876
+ mouse: false,
877
+ surface: null,
878
+ width: 0,
879
+ height: 0,
880
+ view: { scale: 1, originX: 0, originY: 0, fitted: true },
881
+ covered: 0,
882
+ clearance: 0,
883
+ followKeyboard: null,
884
+ gesture: 'none',
885
+ touches: new Map(),
886
+ downX: 0,
887
+ downY: 0,
888
+ downOnPicture: false,
889
+ lastX: 0,
890
+ lastY: 0,
891
+ dragX: 0,
892
+ dragY: 0,
893
+ twoStart: 0,
894
+ startSpan: 0,
895
+ lastSpan: 0,
896
+ startFocusX: 0,
897
+ startFocusY: 0,
898
+ lastFocusX: 0,
899
+ lastFocusY: 0,
900
+ wheelX: 0,
901
+ wheelY: 0,
902
+ lastTap: null,
903
+ longPress: null,
904
+ captured: false,
905
+ inputEnabled: false,
906
+ composition: '',
907
+ compositionSent: '',
908
+ composing: false,
909
+ chordsDown: new Set<string>(),
910
+ remoteDescriptionSet: false,
911
+ pendingCandidates: [],
912
+ detach: null,
913
+ };
914
+ sessions.set(id, session);
915
+
916
+ peer.onicecandidate = (event) => {
917
+ if (event.candidate === null || sessions.get(id) !== session) return;
918
+ emit(id, 'candidate', {
919
+ candidate: event.candidate.candidate,
920
+ sdpMid: event.candidate.sdpMid,
921
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
922
+ });
923
+ };
924
+ peer.ontrack = (event) => {
925
+ if (sessions.get(id) !== session) return;
926
+ video.srcObject = event.streams[0] ?? null;
927
+ emit(id, 'track', {});
928
+ // Readiness is a rendered frame, not a track: a decoder can accept a
929
+ // track and produce nothing.
930
+ const presented = (): void => {
931
+ if (sessions.get(id) !== session) return;
932
+ video.style.opacity = '1';
933
+ emit(id, 'presented', {});
934
+ };
935
+ if (typeof video.requestVideoFrameCallback === 'function') {
936
+ video.requestVideoFrameCallback(presented);
937
+ } else {
938
+ video.addEventListener('loadeddata', presented, { once: true });
939
+ }
940
+ };
941
+ peer.ondatachannel = (event) => {
942
+ if (sessions.get(id) !== session) return;
943
+ session.channel = event.channel;
944
+ event.channel.bufferedAmountLowThreshold = MOVE_BUFFER_LIMIT;
945
+ event.channel.onbufferedamountlow = () => scheduleMove(session);
946
+ event.channel.onmessage = (message) => {
947
+ emit(id, 'control', { message: String(message.data) });
948
+ };
949
+ event.channel.onclose = () => {
950
+ session.pendingMove = null;
951
+ if (session.moveFrame !== null) cancelAnimationFrame(session.moveFrame);
952
+ session.moveFrame = null;
953
+ emit(id, 'closed', {});
954
+ };
955
+ };
956
+ peer.onconnectionstatechange = () => {
957
+ if (sessions.get(id) !== session) return;
958
+ if (peer.connectionState === 'failed') {
959
+ emit(id, 'failure', { code: 'transport', message: 'the connection to the desktop was lost' });
960
+ }
961
+ };
962
+ return id;
963
+ },
964
+
965
+ setRemoteDescription(id: string, type: string, sdp: string): boolean {
966
+ const session = sessions.get(id);
967
+ if (session === undefined) return false;
968
+ if (type !== 'offer') {
969
+ emit(id, 'failure', { code: 'transport', message: 'the engine must send an offer' });
970
+ return false;
971
+ }
972
+ void (async () => {
973
+ try {
974
+ await session.peer.setRemoteDescription({ type: 'offer', sdp });
975
+ session.remoteDescriptionSet = true;
976
+ for (const candidate of session.pendingCandidates.splice(0)) {
977
+ await session.peer.addIceCandidate(candidate).catch(() => undefined);
978
+ }
979
+ const answer = await session.peer.createAnswer();
980
+ await session.peer.setLocalDescription(answer);
981
+ if (sessions.get(id) !== session) return;
982
+ emit(id, 'answer', { sdp: answer.sdp ?? '' });
983
+ } catch (error) {
984
+ emit(id, 'failure', {
985
+ code: 'transport',
986
+ message: error instanceof Error ? error.message : 'the desktop refused our answer',
987
+ });
988
+ }
989
+ })();
990
+ return true;
991
+ },
992
+
993
+ addRemoteCandidate(id, candidate, sdpMid, sdpMLineIndex): boolean {
994
+ const session = sessions.get(id);
995
+ if (session === undefined) return false;
996
+ const init: RTCIceCandidateInit = {
997
+ candidate,
998
+ sdpMid: sdpMid ?? undefined,
999
+ sdpMLineIndex: sdpMLineIndex ?? undefined,
1000
+ };
1001
+ // The engine announces each candidate exactly once, so a candidate that
1002
+ // arrives before the offer has to be held until the offer is set.
1003
+ if (!session.remoteDescriptionSet) {
1004
+ session.pendingCandidates.push(init);
1005
+ return true;
1006
+ }
1007
+ void session.peer.addIceCandidate(init).catch(() => undefined);
1008
+ return true;
1009
+ },
1010
+
1011
+ setInputEnabled(id: string, enabled: boolean): boolean {
1012
+ const session = sessions.get(id);
1013
+ if (session === undefined) return false;
1014
+ session.inputEnabled = enabled;
1015
+ if (!enabled) {
1016
+ session.pendingMove = null;
1017
+ if (session.moveFrame !== null) cancelAnimationFrame(session.moveFrame);
1018
+ session.moveFrame = null;
1019
+ control(session, { kind: 'release_all', seq: seq(session) });
1020
+ session.keyboard.blur();
1021
+ session.keyboard.value = '';
1022
+ session.composition = '';
1023
+ session.compositionSent = '';
1024
+ session.composing = false;
1025
+ session.chordsDown.clear();
1026
+ cancelLongPress(session);
1027
+ session.gesture = 'none';
1028
+ session.touches.clear();
1029
+ session.lastTap = null;
1030
+ session.wheelX = 0;
1031
+ session.wheelY = 0;
1032
+ }
1033
+ return true;
1034
+ },
1035
+
1036
+ sendControl(id: string, message: string): boolean {
1037
+ const session = sessions.get(id);
1038
+ if (session === undefined) return false;
1039
+ // The session owns the one sequence every control message draws from.
1040
+ control(session, { ...(JSON.parse(message) as Record<string, unknown>), seq: seq(session) });
1041
+ return true;
1042
+ },
1043
+
1044
+ setSurfaceSize(id: string, width: number, height: number): boolean {
1045
+ const session = sessions.get(id);
1046
+ if (session === undefined) return false;
1047
+ if (session.width === width && session.height === height) return true;
1048
+ // A resize invalidates a held drag: releasing it at coordinates the user
1049
+ // never pointed at is worse than letting go.
1050
+ if (session.gesture === 'drag' || session.gesture === 'mouse') {
1051
+ control(session, { kind: 'pointer', phase: 'cancel', x: 0, y: 0, seq: seq(session) });
1052
+ }
1053
+ cancelLongPress(session);
1054
+ session.gesture = 'none';
1055
+ session.touches.clear();
1056
+ session.width = width;
1057
+ session.height = height;
1058
+ session.view.fitted = true;
1059
+ // A point on the old geometry says nothing about the new one.
1060
+ session.pointerAt = null;
1061
+ layoutPicture(session);
1062
+ return true;
1063
+ },
1064
+
1065
+ // A page cannot hold the screen's orientation outside full screen; the
1066
+ // browser follows the phone.
1067
+ setOrientation: () => false,
1068
+
1069
+ fitToView(id: string): boolean {
1070
+ const session = sessions.get(id);
1071
+ if (session === undefined) return false;
1072
+ session.view.fitted = true;
1073
+ layoutPicture(session);
1074
+ return true;
1075
+ },
1076
+
1077
+ showKeyboard(id: string): boolean {
1078
+ const session = sessions.get(id);
1079
+ if (session?.inputEnabled) session.keyboard.focus();
1080
+ return true;
1081
+ },
1082
+
1083
+ hideKeyboard(id: string): boolean {
1084
+ sessions.get(id)?.keyboard.blur();
1085
+ return true;
1086
+ },
1087
+
1088
+ captureKeyboard(id: string, captured: boolean): boolean {
1089
+ const session = sessions.get(id);
1090
+ if (session === undefined) return false;
1091
+ if (captured && !session.captured) {
1092
+ // A word still being composed was typed before the modifier was
1093
+ // armed: it goes first, as text, and only what follows is chorded.
1094
+ const pending = unsentComposition(session, session.composition);
1095
+ if (pending !== '') control(session, { kind: 'text', text: pending, seq: seq(session) });
1096
+ session.compositionSent = session.composition;
1097
+ }
1098
+ session.captured = captured;
1099
+ return true;
1100
+ },
1101
+
1102
+ closeSession(id: string): boolean {
1103
+ const session = sessions.get(id);
1104
+ if (session === undefined) return true;
1105
+ sessions.delete(id);
1106
+ sequence.delete(id);
1107
+ session.detach?.();
1108
+ session.pendingMove = null;
1109
+ if (session.moveFrame !== null) cancelAnimationFrame(session.moveFrame);
1110
+ session.moveFrame = null;
1111
+ session.channel?.close();
1112
+ session.peer.close();
1113
+ session.video.srcObject = null;
1114
+ session.video.remove();
1115
+ session.keyboard.remove();
1116
+ session.mark.remove();
1117
+ return true;
1118
+ },
1119
+
1120
+ addListener(_name: 'onSessionEvent', handler: (event: NativeSessionEvent) => void) {
1121
+ listeners.add(handler);
1122
+ return { remove: () => listeners.delete(handler) };
1123
+ },
1124
+ };
1125
+
1126
+ /**
1127
+ * Mount a session's surface into the page.
1128
+ *
1129
+ * Attaching to a second container moves it: the session owns exactly one
1130
+ * surface, and removing the old one is what keeps a re-render from leaving two
1131
+ * live videos behind.
1132
+ */
1133
+ export function attachSurface(id: string, container: HTMLElement | null): void {
1134
+ const session = sessions.get(id);
1135
+ if (session === undefined) return;
1136
+ session.detach?.();
1137
+ session.detach = null;
1138
+ if (session.surface !== null) session.surface.remove();
1139
+ session.surface = null;
1140
+ if (container === null) return;
1141
+ // The mount point is already positioned (absolutely, by the view) and is
1142
+ // what gives the surface its size; making it relative would collapse it
1143
+ // to nothing, since everything inside it is absolutely placed.
1144
+ container.style.overflow = 'hidden';
1145
+ // Pinches and pans are the desktop's, not the page's.
1146
+ container.style.touchAction = 'none';
1147
+ container.appendChild(session.video);
1148
+ container.appendChild(session.mark);
1149
+ container.appendChild(session.keyboard);
1150
+ session.surface = container;
1151
+ session.detach = attachGestures(session);
1152
+ layoutPicture(session);
1153
+ }
1154
+
1155
+ /**
1156
+ * How much room the app keeps above the keyboard for its own controls; while
1157
+ * the desktop's keyboard is up, the picture sits above that too.
1158
+ */
1159
+ export function setKeyboardClearance(id: string, clearance: number): void {
1160
+ const session = sessions.get(id);
1161
+ if (session === undefined || session.clearance === clearance) return;
1162
+ session.clearance = clearance;
1163
+ session.followKeyboard?.();
1164
+ }