@push.rocks/smartbrowser 2.0.10 → 3.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.
@@ -0,0 +1,1388 @@
1
+ import type {
2
+ ILiveBrowserFrame,
3
+ ILiveBrowserInputBase,
4
+ ILiveBrowserKeyInput,
5
+ ILiveBrowserModifierState,
6
+ ILiveBrowserMouseInput,
7
+ ILiveBrowserState,
8
+ ILiveBrowserViewport,
9
+ TLiveBrowserEvent,
10
+ } from '@push.rocks/smartpuppeteer';
11
+
12
+ import type {
13
+ ILiveBrowserCanvasError,
14
+ ILiveBrowserCanvasRendererOptions,
15
+ TLiveBrowserCanvasErrorCode,
16
+ } from './interfaces.livebrowsercanvas.js';
17
+
18
+ const maxInputQueueLength = 128;
19
+ const maxPendingAcknowledgements = 16;
20
+ const maxInputReleaseAttempts = 2;
21
+ const coordinateEdgeInset = 0.001;
22
+ const defaultOperationTimeoutMs = 10000;
23
+
24
+ class LiveBrowserOperationTimeoutError extends Error {}
25
+
26
+ interface IFrameWork {
27
+ frame: ILiveBrowserFrame;
28
+ runEpoch: number;
29
+ acknowledgementAttempted: boolean;
30
+ }
31
+
32
+ interface IDisplayedFrame extends ILiveBrowserInputBase {
33
+ sequence: number;
34
+ viewport: ILiveBrowserViewport;
35
+ }
36
+
37
+ type TInputCommandKind = 'key' | 'mouse' | 'mouseMove' | 'text' | 'wheel';
38
+
39
+ interface IInputCommand {
40
+ kind: TInputCommandKind;
41
+ runEpoch: number;
42
+ execute: () => Promise<void>;
43
+ onAttempt?: () => void;
44
+ onSuccess?: () => void;
45
+ resolve: () => void;
46
+ reject: (errorArg: unknown) => void;
47
+ }
48
+
49
+ interface IResizeFence {
50
+ minimumViewportRevision: number;
51
+ target: ILiveBrowserViewport;
52
+ }
53
+
54
+ const viewportEquals = (
55
+ firstArg: ILiveBrowserViewport,
56
+ secondArg: ILiveBrowserViewport,
57
+ ): boolean => (
58
+ firstArg.width === secondArg.width
59
+ && firstArg.height === secondArg.height
60
+ && firstArg.deviceScaleFactor === secondArg.deviceScaleFactor
61
+ );
62
+
63
+ const createModifiers = (
64
+ eventArg: MouseEvent | KeyboardEvent,
65
+ ): ILiveBrowserModifierState => ({
66
+ alt: eventArg.altKey,
67
+ control: eventArg.ctrlKey,
68
+ meta: eventArg.metaKey,
69
+ shift: eventArg.shiftKey,
70
+ });
71
+
72
+ const createFrameAcknowledgementRequest = (frameArg: ILiveBrowserFrame) => ({
73
+ tabId: frameArg.tabId,
74
+ sequence: frameArg.sequence,
75
+ generation: frameArg.generation,
76
+ viewportRevision: frameArg.viewportRevision,
77
+ });
78
+
79
+ const createInputIdentity = (displayedFrameArg: IDisplayedFrame): ILiveBrowserInputBase => ({
80
+ tabId: displayedFrameArg.tabId,
81
+ generation: displayedFrameArg.generation,
82
+ viewportRevision: displayedFrameArg.viewportRevision,
83
+ });
84
+
85
+ const getErrorMessage = (errorArg: unknown): string => (
86
+ errorArg instanceof Error ? errorArg.message : String(errorArg)
87
+ );
88
+
89
+ /**
90
+ * Renders transport-neutral live browser frames and maps DOM input back to the
91
+ * currently displayed SmartPuppeteer frame identity.
92
+ */
93
+ export class LiveBrowserCanvasRenderer {
94
+ private readonly canvas: HTMLCanvasElement;
95
+ private readonly canvasContext: CanvasRenderingContext2D;
96
+ private readonly client: ILiveBrowserCanvasRendererOptions['client'];
97
+ private readonly focusTarget: HTMLElement;
98
+ private readonly resizeTarget?: Element;
99
+ private readonly getDeviceScaleFactor: () => number;
100
+ private readonly operationTimeoutMs: number;
101
+ private readonly frameDecodeTimeoutMs: number;
102
+ private readonly onError?: ILiveBrowserCanvasRendererOptions['onError'];
103
+ private readonly onFrameRendered?: ILiveBrowserCanvasRendererOptions['onFrameRendered'];
104
+ private readonly initialCanvasWidth: number;
105
+ private readonly initialCanvasHeight: number;
106
+
107
+ private lifecycleTail: Promise<void> = Promise.resolve();
108
+ private runEpoch = 0;
109
+ private running = false;
110
+ private stopping = false;
111
+ private stopRequested = false;
112
+ private terminallyStopped = false;
113
+ private acceptingFrames = false;
114
+ private state?: ILiveBrowserState;
115
+ private unsubscribe?: () => void;
116
+ private listenerController?: AbortController;
117
+ private resizeObserver?: ResizeObserver;
118
+ private focusWasAdjusted = false;
119
+ private focusHadTabIndex = false;
120
+ private focusTabIndexValue: string | null = null;
121
+
122
+ private displayedFrame?: IDisplayedFrame;
123
+ private highestFrameSequence = -1;
124
+ private queuedFrame?: IFrameWork;
125
+ private frameProcessingPromise?: Promise<void>;
126
+ private acknowledgementPromises = new Set<Promise<void>>();
127
+
128
+ private inputBlocked = true;
129
+ private inputCommands: IInputCommand[] = [];
130
+ private inputProcessing = false;
131
+ private inputIdleResolvers: Array<() => void> = [];
132
+ private inputResetTail: Promise<void> = Promise.resolve();
133
+ private inputResetPromise?: Promise<void>;
134
+ private inputResetCount = 0;
135
+ private inputOverflowRecoveryPending = false;
136
+ private pressedKeys = new Map<string, ILiveBrowserKeyInput>();
137
+ private pressedMouseButtons = new Map<string, ILiveBrowserMouseInput>();
138
+ private capturedPointerIds = new Set<number>();
139
+
140
+ private pendingViewport?: ILiveBrowserViewport;
141
+ private inFlightViewport?: ILiveBrowserViewport;
142
+ private viewportProcessing = false;
143
+ private viewportProcessingPromise?: Promise<void>;
144
+ private resizeFence?: IResizeFence;
145
+
146
+ constructor(optionsArg: ILiveBrowserCanvasRendererOptions) {
147
+ this.canvas = optionsArg.canvas;
148
+ this.client = optionsArg.client;
149
+ this.focusTarget = optionsArg.focusTarget ?? this.canvas;
150
+ this.resizeTarget = optionsArg.resizeTarget;
151
+ this.getDeviceScaleFactor = optionsArg.getDeviceScaleFactor
152
+ ?? (() => window.devicePixelRatio || 1);
153
+ this.operationTimeoutMs = this.validateTimeout(
154
+ optionsArg.operationTimeoutMs ?? defaultOperationTimeoutMs,
155
+ 'operationTimeoutMs',
156
+ );
157
+ this.frameDecodeTimeoutMs = this.validateTimeout(
158
+ optionsArg.frameDecodeTimeoutMs ?? this.operationTimeoutMs,
159
+ 'frameDecodeTimeoutMs',
160
+ );
161
+ this.onError = optionsArg.onError;
162
+ this.onFrameRendered = optionsArg.onFrameRendered;
163
+ this.initialCanvasWidth = this.canvas.width;
164
+ this.initialCanvasHeight = this.canvas.height;
165
+
166
+ const canvasContext = this.canvas.getContext('2d');
167
+ if (!canvasContext) {
168
+ throw new Error('LiveBrowserCanvasRenderer requires a 2D canvas context');
169
+ }
170
+ this.canvasContext = canvasContext;
171
+ }
172
+
173
+ public get isRunning(): boolean {
174
+ return this.running;
175
+ }
176
+
177
+ public start(): Promise<void> {
178
+ return this.enqueueLifecycle(async () => {
179
+ if (this.terminallyStopped) {
180
+ throw new Error(
181
+ 'Live browser renderer cannot restart after a terminal operation failure; create a new renderer and client',
182
+ );
183
+ }
184
+ if (this.running) {
185
+ return;
186
+ }
187
+
188
+ const runEpoch = ++this.runEpoch;
189
+ this.running = true;
190
+ this.stopping = false;
191
+ this.stopRequested = false;
192
+ this.acceptingFrames = true;
193
+ this.inputBlocked = true;
194
+ this.highestFrameSequence = -1;
195
+
196
+ try {
197
+ this.makeFocusTargetFocusable();
198
+ this.installDomListeners(runEpoch);
199
+ this.unsubscribe = this.client.onEvent((eventArg) => {
200
+ this.handleEvent(eventArg, runEpoch);
201
+ });
202
+ this.applyState(this.client.getState(), runEpoch);
203
+ this.installResizeObserver(runEpoch);
204
+ } catch (error) {
205
+ await this.stopRun(runEpoch);
206
+ throw error;
207
+ }
208
+ });
209
+ }
210
+
211
+ public stop(): Promise<void> {
212
+ return this.enqueueLifecycle(async () => {
213
+ if (!this.running) {
214
+ return;
215
+ }
216
+ await this.stopRun(this.runEpoch);
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Sends arbitrary committed text, including text produced by an external IME.
222
+ */
223
+ public async insertText(textArg: string): Promise<void> {
224
+ const displayedFrame = this.requireInputFrame();
225
+ const inputIdentity = createInputIdentity(displayedFrame);
226
+ await this.enqueueInputCommand({
227
+ kind: 'text',
228
+ runEpoch: this.runEpoch,
229
+ execute: async () => {
230
+ await this.client.insertText({
231
+ ...inputIdentity,
232
+ text: textArg,
233
+ });
234
+ },
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Reads the configured stable resize target and requests the corresponding
240
+ * remote CSS viewport. Intrinsic canvas dimensions are never used here.
241
+ */
242
+ public syncViewport(): void {
243
+ if (!this.running || this.stopping || this.stopRequested || !this.resizeTarget) {
244
+ return;
245
+ }
246
+ const width = Math.floor(this.resizeTarget.clientWidth);
247
+ const height = Math.floor(this.resizeTarget.clientHeight);
248
+ this.queueViewport(width, height, this.runEpoch);
249
+ }
250
+
251
+ private enqueueLifecycle(operationArg: () => Promise<void>): Promise<void> {
252
+ const operation = this.lifecycleTail.then(operationArg, operationArg);
253
+ this.lifecycleTail = operation.then(() => undefined, () => undefined);
254
+ return operation;
255
+ }
256
+
257
+ private async stopRun(runEpochArg: number): Promise<void> {
258
+ this.stopping = true;
259
+ this.acceptingFrames = false;
260
+ this.inputBlocked = true;
261
+ this.pendingViewport = undefined;
262
+ this.releasePointerCaptures();
263
+
264
+ try {
265
+ this.resizeObserver?.disconnect();
266
+ this.resizeObserver = undefined;
267
+ this.listenerController?.abort();
268
+ this.listenerController = undefined;
269
+ const unsubscribe = this.unsubscribe;
270
+ this.unsubscribe = undefined;
271
+ if (unsubscribe) {
272
+ try {
273
+ unsubscribe();
274
+ } catch (error) {
275
+ this.reportError({
276
+ code: 'renderer_cleanup_failed',
277
+ message: `Could not unsubscribe the live browser renderer: ${getErrorMessage(error)}`,
278
+ cause: error,
279
+ }, runEpochArg);
280
+ }
281
+ }
282
+
283
+ if (this.queuedFrame) {
284
+ const queuedFrame = this.queuedFrame;
285
+ this.queuedFrame = undefined;
286
+ await this.acknowledgeFrame(queuedFrame);
287
+ }
288
+
289
+ await this.viewportProcessingPromise;
290
+ await this.inputResetTail;
291
+ await this.waitForInputIdle();
292
+ await this.releasePressedInput(runEpochArg);
293
+
294
+ this.running = false;
295
+ await this.frameProcessingPromise;
296
+ await Promise.all([...this.acknowledgementPromises]);
297
+ } finally {
298
+ this.running = false;
299
+ this.acceptingFrames = false;
300
+ this.state = undefined;
301
+ this.displayedFrame = undefined;
302
+ this.resizeFence = undefined;
303
+ this.pendingViewport = undefined;
304
+ this.inFlightViewport = undefined;
305
+ this.viewportProcessing = false;
306
+ this.inputResetPromise = undefined;
307
+ this.inputBlocked = true;
308
+ this.pressedKeys.clear();
309
+ this.pressedMouseButtons.clear();
310
+ this.releasePointerCaptures();
311
+ const abandonedCommands = this.inputCommands.splice(0);
312
+ for (const command of abandonedCommands) {
313
+ command.reject(new Error('Live browser renderer stopped before dispatching input'));
314
+ }
315
+ const inputIdleResolvers = this.inputIdleResolvers;
316
+ this.inputIdleResolvers = [];
317
+ for (const resolver of inputIdleResolvers) {
318
+ resolver();
319
+ }
320
+ this.canvas.width = this.initialCanvasWidth;
321
+ this.canvas.height = this.initialCanvasHeight;
322
+ this.restoreFocusTarget();
323
+ this.stopRequested = false;
324
+ this.stopping = false;
325
+ }
326
+ }
327
+
328
+ private installDomListeners(runEpochArg: number): void {
329
+ const listenerController = new AbortController();
330
+ const signal = listenerController.signal;
331
+ this.listenerController = listenerController;
332
+
333
+ this.canvas.addEventListener('pointerdown', (eventArg) => {
334
+ this.handlePointerDown(eventArg, runEpochArg);
335
+ }, { signal });
336
+ this.canvas.addEventListener('pointermove', (eventArg) => {
337
+ this.handlePointerMove(eventArg, runEpochArg);
338
+ }, { signal });
339
+ this.canvas.addEventListener('pointerup', (eventArg) => {
340
+ this.handlePointerUp(eventArg, runEpochArg);
341
+ }, { signal });
342
+ this.canvas.addEventListener('pointercancel', (eventArg) => {
343
+ this.releasePointerCapture(eventArg.pointerId);
344
+ void this.scheduleInputReset(false, runEpochArg);
345
+ }, { signal });
346
+ this.canvas.addEventListener('lostpointercapture', (eventArg) => {
347
+ this.capturedPointerIds.delete(eventArg.pointerId);
348
+ if (this.pressedMouseButtons.size > 0) {
349
+ void this.scheduleInputReset(false, runEpochArg);
350
+ }
351
+ }, { signal });
352
+ this.canvas.addEventListener('contextmenu', (eventArg) => {
353
+ eventArg.preventDefault();
354
+ }, { signal });
355
+ this.canvas.addEventListener('wheel', (eventArg) => {
356
+ this.handleWheel(eventArg, runEpochArg);
357
+ }, { passive: false, signal });
358
+
359
+ this.focusTarget.addEventListener('keydown', (eventArg) => {
360
+ this.handleKey(eventArg, 'down', runEpochArg);
361
+ }, { signal });
362
+ this.focusTarget.addEventListener('keyup', (eventArg) => {
363
+ this.handleKey(eventArg, 'up', runEpochArg);
364
+ }, { signal });
365
+ this.focusTarget.addEventListener('compositionend', (eventArg) => {
366
+ if (!this.inputBlocked && eventArg.data) {
367
+ void this.insertText(eventArg.data).catch(() => undefined);
368
+ }
369
+ }, { signal });
370
+
371
+ window.addEventListener('blur', () => {
372
+ void this.scheduleInputReset(false, runEpochArg);
373
+ }, { signal });
374
+ window.addEventListener('resize', () => {
375
+ this.syncViewport();
376
+ }, { signal });
377
+ document.addEventListener('visibilitychange', () => {
378
+ if (document.visibilityState === 'hidden') {
379
+ void this.scheduleInputReset(false, runEpochArg);
380
+ }
381
+ }, { signal });
382
+ }
383
+
384
+ private installResizeObserver(runEpochArg: number): void {
385
+ if (!this.resizeTarget) {
386
+ return;
387
+ }
388
+ this.resizeObserver = new ResizeObserver((entriesArg) => {
389
+ const matchingEntry = entriesArg.find((entryArg) => entryArg.target === this.resizeTarget);
390
+ if (!matchingEntry) {
391
+ return;
392
+ }
393
+ this.queueViewport(
394
+ Math.floor(matchingEntry.contentRect.width),
395
+ Math.floor(matchingEntry.contentRect.height),
396
+ runEpochArg,
397
+ );
398
+ });
399
+ this.resizeObserver.observe(this.resizeTarget);
400
+ this.syncViewport();
401
+ }
402
+
403
+ private handleEvent(eventArg: TLiveBrowserEvent, runEpochArg: number): void {
404
+ if (eventArg.type === 'frame') {
405
+ this.queueFrame(eventArg.frame, runEpochArg);
406
+ return;
407
+ }
408
+ if (!this.running || runEpochArg !== this.runEpoch) {
409
+ return;
410
+ }
411
+ if (eventArg.type === 'state') {
412
+ this.applyState(eventArg.state, runEpochArg);
413
+ return;
414
+ }
415
+ this.reportError({
416
+ code: 'remote_browser_error',
417
+ message: `${eventArg.error.code}: ${eventArg.error.message}`,
418
+ cause: eventArg.error,
419
+ }, runEpochArg);
420
+ }
421
+
422
+ private applyState(stateArg: ILiveBrowserState, runEpochArg: number): void {
423
+ if (!this.running || runEpochArg !== this.runEpoch) {
424
+ return;
425
+ }
426
+ this.state = stateArg;
427
+
428
+ if (this.displayedFrame && this.isStateAheadOfDisplayedFrame(stateArg, this.displayedFrame)) {
429
+ this.displayedFrame = undefined;
430
+ this.clearCanvas();
431
+ void this.scheduleInputReset(false, runEpochArg);
432
+ return;
433
+ }
434
+ this.updateInputAvailability();
435
+ }
436
+
437
+ private isStateAheadOfDisplayedFrame(
438
+ stateArg: ILiveBrowserState,
439
+ displayedFrameArg: IDisplayedFrame,
440
+ ): boolean {
441
+ if (stateArg.status !== 'running' || stateArg.activeTabId !== displayedFrameArg.tabId) {
442
+ return true;
443
+ }
444
+ const activeTab = stateArg.tabs.find((tabArg) => tabArg.id === displayedFrameArg.tabId);
445
+ if (!activeTab || activeTab.status !== 'open') {
446
+ return true;
447
+ }
448
+ return stateArg.viewportRevision > displayedFrameArg.viewportRevision
449
+ || activeTab.generation > displayedFrameArg.generation;
450
+ }
451
+
452
+ private queueFrame(frameArg: ILiveBrowserFrame, runEpochArg: number): void {
453
+ const frameWork: IFrameWork = {
454
+ frame: frameArg,
455
+ runEpoch: runEpochArg,
456
+ acknowledgementAttempted: false,
457
+ };
458
+
459
+ if (!this.running || !this.acceptingFrames || runEpochArg !== this.runEpoch) {
460
+ void this.acknowledgeFrame(frameWork);
461
+ return;
462
+ }
463
+
464
+ this.highestFrameSequence = Math.max(this.highestFrameSequence, frameArg.sequence);
465
+ if (this.queuedFrame) {
466
+ const supersededFrame = this.queuedFrame;
467
+ this.queuedFrame = undefined;
468
+ void this.acknowledgeFrame(supersededFrame);
469
+ }
470
+ this.queuedFrame = frameWork;
471
+
472
+ this.ensureFrameProcessor();
473
+ }
474
+
475
+ private ensureFrameProcessor(): void {
476
+ if (this.frameProcessingPromise || !this.queuedFrame) {
477
+ return;
478
+ }
479
+ const processingPromise = this.processFrameQueue().finally(() => {
480
+ if (this.frameProcessingPromise === processingPromise) {
481
+ this.frameProcessingPromise = undefined;
482
+ if (this.queuedFrame) {
483
+ this.ensureFrameProcessor();
484
+ }
485
+ }
486
+ });
487
+ this.frameProcessingPromise = processingPromise;
488
+ }
489
+
490
+ private async processFrameQueue(): Promise<void> {
491
+ while (this.queuedFrame) {
492
+ const frameWork = this.queuedFrame;
493
+ this.queuedFrame = undefined;
494
+ await this.processFrame(frameWork);
495
+ }
496
+ }
497
+
498
+ private async processFrame(frameWorkArg: IFrameWork): Promise<void> {
499
+ const { frame, runEpoch } = frameWorkArg;
500
+ let imageBitmap: ImageBitmap | undefined;
501
+ try {
502
+ if (!this.canRenderFrame(frame, runEpoch)) {
503
+ return;
504
+ }
505
+
506
+ const frameBytes = new Uint8Array(frame.data.byteLength);
507
+ frameBytes.set(frame.data);
508
+ imageBitmap = await this.decodeFrame(new Blob([frameBytes.buffer], {
509
+ type: frame.mimeType,
510
+ }));
511
+
512
+ if (!this.canRenderFrame(frame, runEpoch) || frame.sequence !== this.highestFrameSequence) {
513
+ return;
514
+ }
515
+ if (imageBitmap.width !== frame.width || imageBitmap.height !== frame.height) {
516
+ throw new Error(
517
+ `decoded frame dimensions ${imageBitmap.width}x${imageBitmap.height} do not match ${frame.width}x${frame.height}`,
518
+ );
519
+ }
520
+
521
+ this.canvas.width = frame.width;
522
+ this.canvas.height = frame.height;
523
+ this.canvasContext.clearRect(0, 0, frame.width, frame.height);
524
+ this.canvasContext.drawImage(imageBitmap, 0, 0, frame.width, frame.height);
525
+ this.displayedFrame = {
526
+ tabId: frame.tabId,
527
+ sequence: frame.sequence,
528
+ generation: frame.generation,
529
+ viewportRevision: frame.viewportRevision,
530
+ viewport: { ...frame.viewport },
531
+ };
532
+
533
+ if (
534
+ this.resizeFence
535
+ && !this.viewportProcessing
536
+ && frame.viewportRevision >= this.resizeFence.minimumViewportRevision
537
+ && viewportEquals(frame.viewport, this.resizeFence.target)
538
+ ) {
539
+ this.resizeFence = undefined;
540
+ }
541
+ this.updateInputAvailability();
542
+ this.callFrameRendered(frame, runEpoch);
543
+ } catch (error) {
544
+ this.reportError({
545
+ code: 'frame_render_failed',
546
+ message: `Could not render live browser frame: ${getErrorMessage(error)}`,
547
+ cause: error,
548
+ frame,
549
+ }, runEpoch);
550
+ if (error instanceof LiveBrowserOperationTimeoutError) {
551
+ this.requestStop(runEpoch);
552
+ }
553
+ } finally {
554
+ imageBitmap?.close();
555
+ await this.acknowledgeFrame(frameWorkArg);
556
+ }
557
+ }
558
+
559
+ private canRenderFrame(frameArg: ILiveBrowserFrame, runEpochArg: number): boolean {
560
+ if (
561
+ !this.running
562
+ || !this.acceptingFrames
563
+ || runEpochArg !== this.runEpoch
564
+ || this.viewportProcessing
565
+ ) {
566
+ return false;
567
+ }
568
+ const state = this.state;
569
+ if (!state || state.status !== 'running' || state.activeTabId !== frameArg.tabId) {
570
+ return false;
571
+ }
572
+ const activeTab = state.tabs.find((tabArg) => tabArg.id === frameArg.tabId);
573
+ if (!activeTab || activeTab.status !== 'open') {
574
+ return false;
575
+ }
576
+ if (
577
+ state.viewportRevision > frameArg.viewportRevision
578
+ || activeTab.generation > frameArg.generation
579
+ ) {
580
+ return false;
581
+ }
582
+ if (this.resizeFence) {
583
+ return frameArg.viewportRevision >= this.resizeFence.minimumViewportRevision
584
+ && viewportEquals(frameArg.viewport, this.resizeFence.target);
585
+ }
586
+ return true;
587
+ }
588
+
589
+ private acknowledgeFrame(frameWorkArg: IFrameWork): Promise<void> {
590
+ if (frameWorkArg.acknowledgementAttempted) {
591
+ return Promise.resolve();
592
+ }
593
+ frameWorkArg.acknowledgementAttempted = true;
594
+
595
+ if (this.acknowledgementPromises.size >= maxPendingAcknowledgements) {
596
+ this.reportError({
597
+ code: 'frame_acknowledgement_failed',
598
+ message: `Live browser frame acknowledgement capacity of ${maxPendingAcknowledgements} was reached`,
599
+ }, frameWorkArg.runEpoch);
600
+ this.requestStop(frameWorkArg.runEpoch);
601
+ return Promise.resolve();
602
+ }
603
+
604
+ const acknowledgement = this.runClientOperation(
605
+ () => this.client.acknowledgeFrame(createFrameAcknowledgementRequest(frameWorkArg.frame)),
606
+ 'frame acknowledgement',
607
+ )
608
+ .then(() => undefined)
609
+ .catch((error) => {
610
+ this.reportError({
611
+ code: 'frame_acknowledgement_failed',
612
+ message: `Could not acknowledge live browser frame: ${getErrorMessage(error)}`,
613
+ cause: error,
614
+ frame: frameWorkArg.frame,
615
+ }, frameWorkArg.runEpoch);
616
+ this.requestStop(frameWorkArg.runEpoch);
617
+ });
618
+ let trackedAcknowledgement: Promise<void>;
619
+ trackedAcknowledgement = acknowledgement.finally(() => {
620
+ this.acknowledgementPromises.delete(trackedAcknowledgement);
621
+ });
622
+ this.acknowledgementPromises.add(trackedAcknowledgement);
623
+ return trackedAcknowledgement;
624
+ }
625
+
626
+ private handlePointerDown(eventArg: PointerEvent, runEpochArg: number): void {
627
+ if (eventArg.pointerType && eventArg.pointerType !== 'mouse') {
628
+ return;
629
+ }
630
+ const button = this.getMouseButton(eventArg.button);
631
+ if (!button) {
632
+ return;
633
+ }
634
+ const displayedFrame = this.getInputFrame();
635
+ if (!displayedFrame || runEpochArg !== this.runEpoch) {
636
+ return;
637
+ }
638
+ eventArg.preventDefault();
639
+ const coordinates = this.mapCoordinates(eventArg, displayedFrame.viewport);
640
+ if (!coordinates) {
641
+ return;
642
+ }
643
+ this.focusTarget.focus({ preventScroll: true });
644
+ try {
645
+ this.canvas.setPointerCapture(eventArg.pointerId);
646
+ this.capturedPointerIds.add(eventArg.pointerId);
647
+ } catch {
648
+ // Pointer capture may already have been released by the browser.
649
+ }
650
+ const input: ILiveBrowserMouseInput = {
651
+ ...createInputIdentity(displayedFrame),
652
+ type: 'down',
653
+ ...coordinates,
654
+ button,
655
+ buttons: eventArg.buttons,
656
+ clickCount: Math.min(3, Math.max(1, eventArg.detail || 1)),
657
+ modifiers: createModifiers(eventArg),
658
+ };
659
+ void this.enqueueInputCommand({
660
+ kind: 'mouse',
661
+ runEpoch: runEpochArg,
662
+ execute: async () => this.client.dispatchMouse(input),
663
+ onAttempt: () => this.pressedMouseButtons.set(button, input),
664
+ }).catch(() => undefined);
665
+ }
666
+
667
+ private handlePointerMove(eventArg: PointerEvent, runEpochArg: number): void {
668
+ if (eventArg.pointerType && eventArg.pointerType !== 'mouse') {
669
+ return;
670
+ }
671
+ const displayedFrame = this.getInputFrame();
672
+ if (!displayedFrame || runEpochArg !== this.runEpoch) {
673
+ return;
674
+ }
675
+ eventArg.preventDefault();
676
+ const coordinates = this.mapCoordinates(eventArg, displayedFrame.viewport);
677
+ if (!coordinates) {
678
+ this.releasePointerCapture(eventArg.pointerId);
679
+ void this.scheduleInputReset(false, runEpochArg);
680
+ return;
681
+ }
682
+ const input: ILiveBrowserMouseInput = {
683
+ ...createInputIdentity(displayedFrame),
684
+ type: 'move',
685
+ ...coordinates,
686
+ button: 'none',
687
+ buttons: eventArg.buttons,
688
+ modifiers: createModifiers(eventArg),
689
+ };
690
+ void this.enqueueInputCommand({
691
+ kind: 'mouseMove',
692
+ runEpoch: runEpochArg,
693
+ execute: async () => this.client.dispatchMouse(input),
694
+ }).catch(() => undefined);
695
+ }
696
+
697
+ private handlePointerUp(eventArg: PointerEvent, runEpochArg: number): void {
698
+ if (eventArg.pointerType && eventArg.pointerType !== 'mouse') {
699
+ return;
700
+ }
701
+ const button = this.getMouseButton(eventArg.button);
702
+ const displayedFrame = this.getInputFrame();
703
+ if (!button || !displayedFrame || runEpochArg !== this.runEpoch) {
704
+ this.releasePointerCapture(eventArg.pointerId);
705
+ return;
706
+ }
707
+ eventArg.preventDefault();
708
+ const coordinates = this.mapCoordinates(eventArg, displayedFrame.viewport);
709
+ if (!coordinates) {
710
+ this.releasePointerCapture(eventArg.pointerId);
711
+ void this.scheduleInputReset(false, runEpochArg);
712
+ return;
713
+ }
714
+ const input: ILiveBrowserMouseInput = {
715
+ ...createInputIdentity(displayedFrame),
716
+ type: 'up',
717
+ ...coordinates,
718
+ button,
719
+ buttons: eventArg.buttons,
720
+ clickCount: Math.min(3, Math.max(1, eventArg.detail || 1)),
721
+ modifiers: createModifiers(eventArg),
722
+ };
723
+ void this.enqueueInputCommand({
724
+ kind: 'mouse',
725
+ runEpoch: runEpochArg,
726
+ execute: async () => this.client.dispatchMouse(input),
727
+ onSuccess: () => this.pressedMouseButtons.delete(button),
728
+ }).catch(() => undefined);
729
+ this.releasePointerCapture(eventArg.pointerId);
730
+ }
731
+
732
+ private handleWheel(eventArg: WheelEvent, runEpochArg: number): void {
733
+ const displayedFrame = this.getInputFrame();
734
+ if (!displayedFrame || runEpochArg !== this.runEpoch) {
735
+ return;
736
+ }
737
+ eventArg.preventDefault();
738
+ const coordinates = this.mapCoordinates(eventArg, displayedFrame.viewport);
739
+ if (!coordinates) {
740
+ return;
741
+ }
742
+ const multiplier = eventArg.deltaMode === WheelEvent.DOM_DELTA_LINE
743
+ ? 16
744
+ : eventArg.deltaMode === WheelEvent.DOM_DELTA_PAGE
745
+ ? displayedFrame.viewport.height
746
+ : 1;
747
+ const clampDelta = (valueArg: number) => Math.max(-1000000, Math.min(1000000, valueArg));
748
+ void this.enqueueInputCommand({
749
+ kind: 'wheel',
750
+ runEpoch: runEpochArg,
751
+ execute: async () => this.client.dispatchWheel({
752
+ ...createInputIdentity(displayedFrame),
753
+ ...coordinates,
754
+ deltaX: clampDelta(eventArg.deltaX * multiplier),
755
+ deltaY: clampDelta(eventArg.deltaY * multiplier),
756
+ modifiers: createModifiers(eventArg),
757
+ }),
758
+ }).catch(() => undefined);
759
+ }
760
+
761
+ private handleKey(
762
+ eventArg: KeyboardEvent,
763
+ typeArg: 'down' | 'up',
764
+ runEpochArg: number,
765
+ ): void {
766
+ const displayedFrame = this.getInputFrame();
767
+ if (
768
+ !displayedFrame
769
+ || runEpochArg !== this.runEpoch
770
+ || eventArg.isComposing
771
+ || eventArg.key === 'Process'
772
+ ) {
773
+ return;
774
+ }
775
+ eventArg.preventDefault();
776
+ const text = typeArg === 'down'
777
+ && eventArg.key.length === 1
778
+ && !eventArg.metaKey
779
+ && (!eventArg.ctrlKey || eventArg.altKey)
780
+ ? eventArg.key
781
+ : undefined;
782
+ const input: ILiveBrowserKeyInput = {
783
+ ...createInputIdentity(displayedFrame),
784
+ type: typeArg,
785
+ key: eventArg.key,
786
+ code: eventArg.code || undefined,
787
+ text,
788
+ windowsVirtualKeyCode: eventArg.keyCode || undefined,
789
+ autoRepeat: eventArg.repeat,
790
+ isKeypad: eventArg.location === KeyboardEvent.DOM_KEY_LOCATION_NUMPAD,
791
+ location: eventArg.location,
792
+ modifiers: createModifiers(eventArg),
793
+ };
794
+ const keyIdentity = eventArg.code || eventArg.key;
795
+ void this.enqueueInputCommand({
796
+ kind: 'key',
797
+ runEpoch: runEpochArg,
798
+ execute: async () => this.client.dispatchKey(input),
799
+ onAttempt: typeArg === 'down'
800
+ ? () => this.pressedKeys.set(keyIdentity, input)
801
+ : undefined,
802
+ onSuccess: () => {
803
+ if (typeArg === 'up') {
804
+ this.pressedKeys.delete(keyIdentity);
805
+ }
806
+ },
807
+ }).catch(() => undefined);
808
+ }
809
+
810
+ private mapCoordinates(
811
+ eventArg: MouseEvent,
812
+ viewportArg: ILiveBrowserViewport,
813
+ ): { x: number; y: number } | undefined {
814
+ const canvasBounds = this.canvas.getBoundingClientRect();
815
+ if (canvasBounds.width <= 0 || canvasBounds.height <= 0) {
816
+ return undefined;
817
+ }
818
+ const x = ((eventArg.clientX - canvasBounds.left) / canvasBounds.width) * viewportArg.width;
819
+ const y = ((eventArg.clientY - canvasBounds.top) / canvasBounds.height) * viewportArg.height;
820
+ return {
821
+ x: Math.max(0, Math.min(viewportArg.width - coordinateEdgeInset, x)),
822
+ y: Math.max(0, Math.min(viewportArg.height - coordinateEdgeInset, y)),
823
+ };
824
+ }
825
+
826
+ private getMouseButton(buttonArg: number): ILiveBrowserMouseInput['button'] | undefined {
827
+ return buttonArg === 0
828
+ ? 'left'
829
+ : buttonArg === 1
830
+ ? 'middle'
831
+ : buttonArg === 2
832
+ ? 'right'
833
+ : buttonArg === 3
834
+ ? 'back'
835
+ : buttonArg === 4
836
+ ? 'forward'
837
+ : undefined;
838
+ }
839
+
840
+ private getInputFrame(): IDisplayedFrame | undefined {
841
+ return this.inputBlocked || !this.isDisplayedFrameCurrent()
842
+ ? undefined
843
+ : this.displayedFrame;
844
+ }
845
+
846
+ private requireInputFrame(): IDisplayedFrame {
847
+ const displayedFrame = this.getInputFrame();
848
+ if (!displayedFrame) {
849
+ throw new Error('Live browser input is unavailable until a current frame is displayed');
850
+ }
851
+ return displayedFrame;
852
+ }
853
+
854
+ private isDisplayedFrameCurrent(): boolean {
855
+ const state = this.state;
856
+ const displayedFrame = this.displayedFrame;
857
+ if (!state || !displayedFrame || state.status !== 'running') {
858
+ return false;
859
+ }
860
+ if (
861
+ state.activeTabId !== displayedFrame.tabId
862
+ || state.viewportRevision > displayedFrame.viewportRevision
863
+ ) {
864
+ return false;
865
+ }
866
+ const activeTab = state.tabs.find((tabArg) => tabArg.id === displayedFrame.tabId);
867
+ return Boolean(
868
+ activeTab
869
+ && activeTab.status === 'open'
870
+ && activeTab.generation <= displayedFrame.generation,
871
+ );
872
+ }
873
+
874
+ private enqueueInputCommand(commandArg: Omit<IInputCommand, 'reject' | 'resolve'>): Promise<void> {
875
+ if (!this.running || this.inputBlocked || commandArg.runEpoch !== this.runEpoch) {
876
+ return Promise.reject(new Error('Live browser input is currently blocked'));
877
+ }
878
+
879
+ return new Promise<void>((resolve, reject) => {
880
+ const command: IInputCommand = {
881
+ ...commandArg,
882
+ resolve,
883
+ reject,
884
+ };
885
+ const lastCommand = this.inputCommands.at(-1);
886
+ if (command.kind === 'mouseMove' && lastCommand?.kind === 'mouseMove') {
887
+ lastCommand.resolve();
888
+ this.inputCommands[this.inputCommands.length - 1] = command;
889
+ } else if (this.inputCommands.length >= maxInputQueueLength) {
890
+ if (command.kind === 'mouseMove') {
891
+ resolve();
892
+ return;
893
+ }
894
+ const error = new Error('Live browser input queue reached its capacity');
895
+ this.reportError({
896
+ code: 'input_queue_capacity_exceeded',
897
+ message: error.message,
898
+ cause: error,
899
+ }, command.runEpoch);
900
+ reject(error);
901
+ this.recoverFromInputOverflow(command.runEpoch);
902
+ return;
903
+ } else {
904
+ this.inputCommands.push(command);
905
+ }
906
+ this.processInputCommands();
907
+ });
908
+ }
909
+
910
+ private processInputCommands(): void {
911
+ if (this.inputProcessing) {
912
+ return;
913
+ }
914
+ this.inputProcessing = true;
915
+ void (async () => {
916
+ try {
917
+ while (this.inputCommands.length > 0) {
918
+ const command = this.inputCommands.shift()!;
919
+ if (command.runEpoch !== this.runEpoch) {
920
+ command.resolve();
921
+ continue;
922
+ }
923
+ try {
924
+ command.onAttempt?.();
925
+ await this.runClientOperation(command.execute, 'input dispatch');
926
+ command.onSuccess?.();
927
+ command.resolve();
928
+ } catch (error) {
929
+ this.reportError({
930
+ code: 'input_dispatch_failed',
931
+ message: `Could not dispatch live browser input: ${getErrorMessage(error)}`,
932
+ cause: error,
933
+ }, command.runEpoch);
934
+ command.reject(error);
935
+ const abandonedCommands = this.inputCommands.splice(0);
936
+ for (const abandonedCommand of abandonedCommands) {
937
+ abandonedCommand.reject(
938
+ new Error('Live browser input was abandoned after a dispatch failure'),
939
+ );
940
+ }
941
+ if (!this.stopping) {
942
+ void this.scheduleInputReset(false, command.runEpoch);
943
+ }
944
+ if (error instanceof LiveBrowserOperationTimeoutError) {
945
+ this.requestStop(command.runEpoch);
946
+ }
947
+ }
948
+ }
949
+ } finally {
950
+ this.inputProcessing = false;
951
+ if (this.inputCommands.length > 0) {
952
+ this.processInputCommands();
953
+ } else {
954
+ const resolvers = this.inputIdleResolvers;
955
+ this.inputIdleResolvers = [];
956
+ for (const resolver of resolvers) {
957
+ resolver();
958
+ }
959
+ }
960
+ }
961
+ })();
962
+ }
963
+
964
+ private waitForInputIdle(): Promise<void> {
965
+ if (!this.inputProcessing && this.inputCommands.length === 0) {
966
+ return Promise.resolve();
967
+ }
968
+ return new Promise<void>((resolve) => {
969
+ this.inputIdleResolvers.push(resolve);
970
+ });
971
+ }
972
+
973
+ private scheduleInputReset(
974
+ clearDisplayArg: boolean,
975
+ runEpochArg: number,
976
+ ): Promise<void> {
977
+ if (runEpochArg !== this.runEpoch || this.stopping) {
978
+ return Promise.resolve();
979
+ }
980
+ this.inputBlocked = true;
981
+ this.releasePointerCaptures();
982
+ if (clearDisplayArg) {
983
+ this.displayedFrame = undefined;
984
+ this.clearCanvas();
985
+ }
986
+ if (this.inputResetPromise) {
987
+ return this.inputResetPromise;
988
+ }
989
+ this.inputResetCount++;
990
+ let resetOperation: Promise<void>;
991
+ resetOperation = this.inputResetTail.then(async () => {
992
+ await this.waitForInputIdle();
993
+ await this.releasePressedInput(runEpochArg);
994
+ }).finally(() => {
995
+ this.inputResetCount--;
996
+ if (this.inputResetPromise === resetOperation) {
997
+ this.inputResetPromise = undefined;
998
+ }
999
+ this.updateInputAvailability();
1000
+ });
1001
+ this.inputResetPromise = resetOperation;
1002
+ this.inputResetTail = resetOperation.then(() => undefined, () => undefined);
1003
+ return resetOperation;
1004
+ }
1005
+
1006
+ private recoverFromInputOverflow(runEpochArg: number): void {
1007
+ if (this.inputOverflowRecoveryPending) {
1008
+ return;
1009
+ }
1010
+ this.inputOverflowRecoveryPending = true;
1011
+ this.inputBlocked = true;
1012
+ void this.scheduleInputReset(false, runEpochArg).finally(() => {
1013
+ this.inputOverflowRecoveryPending = false;
1014
+ });
1015
+ }
1016
+
1017
+ private async releasePressedInput(runEpochArg: number): Promise<void> {
1018
+ for (const [keyIdentity, pressedKey] of [...this.pressedKeys.entries()]) {
1019
+ const released = await this.tryInputRelease(
1020
+ () => this.client.dispatchKey({
1021
+ ...pressedKey,
1022
+ type: 'up',
1023
+ text: undefined,
1024
+ autoRepeat: false,
1025
+ modifiers: {},
1026
+ }),
1027
+ runEpochArg,
1028
+ );
1029
+ if (released && this.pressedKeys.get(keyIdentity) === pressedKey) {
1030
+ this.pressedKeys.delete(keyIdentity);
1031
+ }
1032
+ }
1033
+ for (const [button, pressedButton] of [...this.pressedMouseButtons.entries()]) {
1034
+ const released = await this.tryInputRelease(
1035
+ () => this.client.dispatchMouse({
1036
+ ...pressedButton,
1037
+ type: 'up',
1038
+ buttons: 0,
1039
+ clickCount: 1,
1040
+ modifiers: {},
1041
+ }),
1042
+ runEpochArg,
1043
+ );
1044
+ if (released && this.pressedMouseButtons.get(button) === pressedButton) {
1045
+ this.pressedMouseButtons.delete(button);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ private async tryInputRelease(
1051
+ operationArg: () => Promise<void>,
1052
+ runEpochArg: number,
1053
+ ): Promise<boolean> {
1054
+ let lastError: unknown;
1055
+ for (let attempt = 0; attempt < maxInputReleaseAttempts; attempt++) {
1056
+ try {
1057
+ await this.runClientOperation(
1058
+ operationArg,
1059
+ 'input release',
1060
+ );
1061
+ return true;
1062
+ } catch (error) {
1063
+ lastError = error;
1064
+ }
1065
+ }
1066
+ this.reportInputReleaseError(lastError, runEpochArg);
1067
+ this.requestStop(runEpochArg);
1068
+ return false;
1069
+ }
1070
+
1071
+ private reportInputReleaseError(errorArg: unknown, runEpochArg: number): void {
1072
+ this.reportError({
1073
+ code: 'input_dispatch_failed',
1074
+ message: `Could not release live browser input: ${getErrorMessage(errorArg)}`,
1075
+ cause: errorArg,
1076
+ }, runEpochArg);
1077
+ }
1078
+
1079
+ private queueViewport(widthArg: number, heightArg: number, runEpochArg: number): void {
1080
+ if (
1081
+ !this.running
1082
+ || this.stopping
1083
+ || this.stopRequested
1084
+ || runEpochArg !== this.runEpoch
1085
+ || widthArg <= 0
1086
+ || heightArg <= 0
1087
+ ) {
1088
+ return;
1089
+ }
1090
+ const deviceScaleFactor = this.getDeviceScaleFactor();
1091
+ if (!Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0) {
1092
+ this.reportError({
1093
+ code: 'viewport_update_failed',
1094
+ message: 'Live browser device scale factor must be a positive finite number',
1095
+ }, runEpochArg);
1096
+ return;
1097
+ }
1098
+ const viewport: ILiveBrowserViewport = {
1099
+ width: widthArg,
1100
+ height: heightArg,
1101
+ deviceScaleFactor,
1102
+ };
1103
+ if (this.pendingViewport && viewportEquals(viewport, this.pendingViewport)) {
1104
+ return;
1105
+ }
1106
+ if (this.inFlightViewport && viewportEquals(viewport, this.inFlightViewport)) {
1107
+ this.pendingViewport = undefined;
1108
+ return;
1109
+ }
1110
+ if (
1111
+ !this.inFlightViewport
1112
+ && this.resizeFence
1113
+ && viewportEquals(viewport, this.resizeFence.target)
1114
+ ) {
1115
+ this.pendingViewport = undefined;
1116
+ return;
1117
+ }
1118
+ if (
1119
+ !this.inFlightViewport
1120
+ && !this.resizeFence
1121
+ && this.state
1122
+ && viewportEquals(viewport, this.state.viewport)
1123
+ ) {
1124
+ this.pendingViewport = undefined;
1125
+ this.updateInputAvailability();
1126
+ return;
1127
+ }
1128
+ this.pendingViewport = viewport;
1129
+ this.inputBlocked = true;
1130
+ this.ensureViewportProcessor(runEpochArg);
1131
+ }
1132
+
1133
+ private ensureViewportProcessor(runEpochArg: number): void {
1134
+ if (this.viewportProcessingPromise || !this.pendingViewport) {
1135
+ return;
1136
+ }
1137
+ const processingPromise = this.processViewportQueue(runEpochArg).finally(() => {
1138
+ if (this.viewportProcessingPromise === processingPromise) {
1139
+ this.viewportProcessingPromise = undefined;
1140
+ if (
1141
+ this.pendingViewport
1142
+ && this.running
1143
+ && !this.stopping
1144
+ && runEpochArg === this.runEpoch
1145
+ ) {
1146
+ this.ensureViewportProcessor(runEpochArg);
1147
+ }
1148
+ }
1149
+ });
1150
+ this.viewportProcessingPromise = processingPromise;
1151
+ }
1152
+
1153
+ private async processViewportQueue(runEpochArg: number): Promise<void> {
1154
+ this.viewportProcessing = true;
1155
+ try {
1156
+ while (
1157
+ this.pendingViewport
1158
+ && this.running
1159
+ && !this.stopping
1160
+ && runEpochArg === this.runEpoch
1161
+ ) {
1162
+ const viewport = this.pendingViewport;
1163
+ this.pendingViewport = undefined;
1164
+ this.inFlightViewport = viewport;
1165
+ if (!this.resizeFence && this.state && viewportEquals(viewport, this.state.viewport)) {
1166
+ this.inFlightViewport = undefined;
1167
+ continue;
1168
+ }
1169
+
1170
+ await this.scheduleInputReset(true, runEpochArg);
1171
+ if (!this.running || this.stopping || runEpochArg !== this.runEpoch) {
1172
+ return;
1173
+ }
1174
+ const currentRevision = this.state?.viewportRevision ?? 0;
1175
+ const previousMinimumRevision = this.resizeFence?.minimumViewportRevision ?? currentRevision;
1176
+ try {
1177
+ await this.runClientOperation(
1178
+ () => this.client.setViewport(viewport),
1179
+ 'viewport update',
1180
+ );
1181
+ if (!this.running || this.stopping || runEpochArg !== this.runEpoch) {
1182
+ return;
1183
+ }
1184
+ this.resizeFence = {
1185
+ target: viewport,
1186
+ minimumViewportRevision: Math.max(currentRevision, previousMinimumRevision) + 1,
1187
+ };
1188
+ } catch (error) {
1189
+ if (runEpochArg === this.runEpoch) {
1190
+ this.resizeFence = undefined;
1191
+ this.reportError({
1192
+ code: 'viewport_update_failed',
1193
+ message: `Could not update live browser viewport: ${getErrorMessage(error)}`,
1194
+ cause: error,
1195
+ }, runEpochArg);
1196
+ if (error instanceof LiveBrowserOperationTimeoutError) {
1197
+ this.requestStop(runEpochArg);
1198
+ }
1199
+ }
1200
+ } finally {
1201
+ if (this.inFlightViewport && viewportEquals(this.inFlightViewport, viewport)) {
1202
+ this.inFlightViewport = undefined;
1203
+ }
1204
+ }
1205
+ }
1206
+ } finally {
1207
+ this.viewportProcessing = false;
1208
+ this.updateInputAvailability();
1209
+ }
1210
+ }
1211
+
1212
+ private updateInputAvailability(): void {
1213
+ this.inputBlocked = !(
1214
+ this.running
1215
+ && this.acceptingFrames
1216
+ && !this.viewportProcessing
1217
+ && !this.resizeFence
1218
+ && this.inputResetCount === 0
1219
+ && this.pressedKeys.size === 0
1220
+ && this.pressedMouseButtons.size === 0
1221
+ && this.isDisplayedFrameCurrent()
1222
+ );
1223
+ }
1224
+
1225
+ private makeFocusTargetFocusable(): void {
1226
+ this.focusWasAdjusted = false;
1227
+ if (this.focusTarget.tabIndex >= 0) {
1228
+ return;
1229
+ }
1230
+ this.focusHadTabIndex = this.focusTarget.hasAttribute('tabindex');
1231
+ this.focusTabIndexValue = this.focusTarget.getAttribute('tabindex');
1232
+ this.focusTarget.setAttribute('tabindex', '0');
1233
+ this.focusWasAdjusted = true;
1234
+ }
1235
+
1236
+ private restoreFocusTarget(): void {
1237
+ if (!this.focusWasAdjusted) {
1238
+ return;
1239
+ }
1240
+ if (this.focusHadTabIndex && this.focusTabIndexValue !== null) {
1241
+ this.focusTarget.setAttribute('tabindex', this.focusTabIndexValue);
1242
+ } else {
1243
+ this.focusTarget.removeAttribute('tabindex');
1244
+ }
1245
+ this.focusWasAdjusted = false;
1246
+ }
1247
+
1248
+ private clearCanvas(): void {
1249
+ this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
1250
+ }
1251
+
1252
+ private callFrameRendered(frameArg: ILiveBrowserFrame, runEpochArg: number): void {
1253
+ if (!this.onFrameRendered || runEpochArg !== this.runEpoch || !this.running) {
1254
+ return;
1255
+ }
1256
+ try {
1257
+ this.onFrameRendered(frameArg);
1258
+ } catch (error) {
1259
+ this.reportError({
1260
+ code: 'renderer_callback_failed',
1261
+ message: `Live browser frame callback failed: ${getErrorMessage(error)}`,
1262
+ cause: error,
1263
+ frame: frameArg,
1264
+ }, runEpochArg);
1265
+ }
1266
+ }
1267
+
1268
+ private reportError(errorArg: ILiveBrowserCanvasError, runEpochArg: number): void {
1269
+ if (!this.onError || runEpochArg !== this.runEpoch) {
1270
+ return;
1271
+ }
1272
+ try {
1273
+ this.onError(errorArg);
1274
+ } catch {
1275
+ // Consumer error reporting must not break renderer cleanup.
1276
+ }
1277
+ }
1278
+
1279
+ private requestStop(runEpochArg: number): void {
1280
+ if (
1281
+ runEpochArg !== this.runEpoch
1282
+ || (!this.running && !this.stopping)
1283
+ ) {
1284
+ return;
1285
+ }
1286
+ this.terminallyStopped = true;
1287
+ this.acceptingFrames = false;
1288
+ this.inputBlocked = true;
1289
+ this.pendingViewport = undefined;
1290
+ if (this.stopping || this.stopRequested) {
1291
+ return;
1292
+ }
1293
+ this.stopRequested = true;
1294
+ void this.stop();
1295
+ }
1296
+
1297
+ private validateTimeout(timeoutArg: number, nameArg: string): number {
1298
+ if (!Number.isFinite(timeoutArg) || timeoutArg <= 0) {
1299
+ throw new Error(`${nameArg} must be a positive finite number`);
1300
+ }
1301
+ return timeoutArg;
1302
+ }
1303
+
1304
+ private runClientOperation<T>(
1305
+ operationArg: () => Promise<T>,
1306
+ operationNameArg: string,
1307
+ ): Promise<T> {
1308
+ let operation: Promise<T>;
1309
+ try {
1310
+ operation = operationArg();
1311
+ } catch (error) {
1312
+ return Promise.reject(error);
1313
+ }
1314
+ return this.withTimeout(operation, this.operationTimeoutMs, operationNameArg);
1315
+ }
1316
+
1317
+ private withTimeout<T>(
1318
+ operationArg: Promise<T>,
1319
+ timeoutMsArg: number,
1320
+ operationNameArg: string,
1321
+ ): Promise<T> {
1322
+ return new Promise<T>((resolve, reject) => {
1323
+ let settled = false;
1324
+ const timeout = globalThis.setTimeout(() => {
1325
+ if (!settled) {
1326
+ settled = true;
1327
+ reject(new LiveBrowserOperationTimeoutError(
1328
+ `${operationNameArg} timed out after ${timeoutMsArg}ms`,
1329
+ ));
1330
+ }
1331
+ }, timeoutMsArg);
1332
+ operationArg.then(
1333
+ (valueArg) => {
1334
+ if (!settled) {
1335
+ settled = true;
1336
+ globalThis.clearTimeout(timeout);
1337
+ resolve(valueArg);
1338
+ }
1339
+ },
1340
+ (errorArg) => {
1341
+ if (!settled) {
1342
+ settled = true;
1343
+ globalThis.clearTimeout(timeout);
1344
+ reject(errorArg);
1345
+ }
1346
+ },
1347
+ );
1348
+ });
1349
+ }
1350
+
1351
+ private async decodeFrame(blobArg: Blob): Promise<ImageBitmap> {
1352
+ let decodeOperation: Promise<ImageBitmap>;
1353
+ try {
1354
+ decodeOperation = createImageBitmap(blobArg);
1355
+ } catch (error) {
1356
+ throw error;
1357
+ }
1358
+ try {
1359
+ return await this.withTimeout(
1360
+ decodeOperation,
1361
+ this.frameDecodeTimeoutMs,
1362
+ 'frame decode',
1363
+ );
1364
+ } catch (error) {
1365
+ void decodeOperation.then((lateBitmapArg) => lateBitmapArg.close(), () => undefined);
1366
+ throw error;
1367
+ }
1368
+ }
1369
+
1370
+ private releasePointerCapture(pointerIdArg: number): void {
1371
+ this.capturedPointerIds.delete(pointerIdArg);
1372
+ try {
1373
+ if (this.canvas.hasPointerCapture(pointerIdArg)) {
1374
+ this.canvas.releasePointerCapture(pointerIdArg);
1375
+ }
1376
+ } catch {
1377
+ // Pointer capture may already have been released by the browser.
1378
+ }
1379
+ }
1380
+
1381
+ private releasePointerCaptures(): void {
1382
+ const pointerIds = [...this.capturedPointerIds];
1383
+ this.capturedPointerIds.clear();
1384
+ for (const pointerId of pointerIds) {
1385
+ this.releasePointerCapture(pointerId);
1386
+ }
1387
+ }
1388
+ }