opensteer 0.8.18 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -5
  2. package/dist/chunk-4LP7QP2O.js +4336 -0
  3. package/dist/chunk-4LP7QP2O.js.map +1 -0
  4. package/dist/{chunk-GQ3PGLYQ.js → chunk-6PGXWW3X.js} +5142 -9634
  5. package/dist/chunk-6PGXWW3X.js.map +1 -0
  6. package/dist/chunk-BMPUL66S.js +1170 -0
  7. package/dist/chunk-BMPUL66S.js.map +1 -0
  8. package/dist/{chunk-T6TG4WO2.js → chunk-L4FWHBQJ.js} +4 -3
  9. package/dist/chunk-L4FWHBQJ.js.map +1 -0
  10. package/dist/chunk-Z53HNZ7Z.js +1800 -0
  11. package/dist/chunk-Z53HNZ7Z.js.map +1 -0
  12. package/dist/cli/bin.cjs +3533 -499
  13. package/dist/cli/bin.cjs.map +1 -1
  14. package/dist/cli/bin.js +130 -11
  15. package/dist/cli/bin.js.map +1 -1
  16. package/dist/index.cjs +1382 -487
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +20 -27
  19. package/dist/index.d.ts +20 -27
  20. package/dist/index.js +4 -2
  21. package/dist/local-view/public/assets/app.css +770 -0
  22. package/dist/local-view/public/assets/app.js +2053 -0
  23. package/dist/local-view/public/index.html +235 -0
  24. package/dist/local-view/serve-entry.cjs +7436 -0
  25. package/dist/local-view/serve-entry.cjs.map +1 -0
  26. package/dist/local-view/serve-entry.d.cts +1 -0
  27. package/dist/local-view/serve-entry.d.ts +1 -0
  28. package/dist/local-view/serve-entry.js +23 -0
  29. package/dist/local-view/serve-entry.js.map +1 -0
  30. package/dist/opensteer-KZCRP425.js +6 -0
  31. package/dist/{opensteer-XMCWYUH3.js.map → opensteer-KZCRP425.js.map} +1 -1
  32. package/dist/session-control-VGBFOH3Y.js +39 -0
  33. package/dist/session-control-VGBFOH3Y.js.map +1 -0
  34. package/package.json +8 -8
  35. package/skills/README.md +12 -6
  36. package/skills/opensteer/SKILL.md +275 -217
  37. package/skills/recorder/SKILL.md +1 -1
  38. package/dist/chunk-GQ3PGLYQ.js.map +0 -1
  39. package/dist/chunk-T6TG4WO2.js.map +0 -1
  40. package/dist/opensteer-XMCWYUH3.js +0 -4
@@ -0,0 +1,2053 @@
1
+ const bootstrap = window.__OPENSTEER_LOCAL_BOOTSTRAP__ ?? {};
2
+ const apiBasePath = bootstrap.apiBasePath ?? "/api";
3
+ const apiToken = bootstrap.token ?? "";
4
+
5
+ const SESSION_REFRESH_MS = 2_500;
6
+ const STREAM_CONFIG_DEBOUNCE_MS = 120;
7
+ const CDP_COMMAND_TIMEOUT_MS = 10_000;
8
+ const RECONNECT_MAX_MS = 10_000;
9
+ const MAX_TIMEOUT_MS = 2_147_483_647;
10
+ const PREFERRED_TARGET_RESOLVE_ATTEMPTS = 4;
11
+ const PREFERRED_TARGET_RESOLVE_DELAY_MS = 50;
12
+ const MOUSE_MOVE_THROTTLE_MS = 33;
13
+ const VIEWPORT_REFRESH_MS = 1_500;
14
+ const FALLBACK_STREAM_ASPECT = 16 / 10;
15
+ const BROWSER_FRAME_BORDER_Y_PX = 2;
16
+
17
+ function apiFetch(pathname, options = {}) {
18
+ const headers = new Headers(options.headers ?? {});
19
+ headers.set("x-opensteer-local-token", apiToken);
20
+ return fetch(pathname, {
21
+ cache: "no-store",
22
+ ...options,
23
+ headers,
24
+ });
25
+ }
26
+
27
+ function middleTrim(value, maxLength) {
28
+ if (typeof value !== "string" || value.length <= maxLength) {
29
+ return value;
30
+ }
31
+ const keep = Math.max(6, Math.floor((maxLength - 1) / 2));
32
+ return `${value.slice(0, keep)}...${value.slice(value.length - keep)}`;
33
+ }
34
+
35
+ function resolveSelectedSessionIdFromHash() {
36
+ const match = window.location.hash.match(/session=([^&]+)/u);
37
+ return match ? decodeURIComponent(match[1]) : null;
38
+ }
39
+
40
+ function setSelectedSessionHash(sessionId) {
41
+ if (!sessionId) {
42
+ history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
43
+ return;
44
+ }
45
+ const hash = `session=${encodeURIComponent(sessionId)}`;
46
+ history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${hash}`);
47
+ }
48
+
49
+ function normalizeBrowserStreamRenderSize(size, devicePixelRatio) {
50
+ if (!size) {
51
+ return null;
52
+ }
53
+ const width = Math.max(100, Math.min(8192, Math.floor(size.width * devicePixelRatio)));
54
+ const height = Math.max(100, Math.min(8192, Math.floor(size.height * devicePixelRatio)));
55
+ return { width, height };
56
+ }
57
+
58
+ function resolveNavigationHistoryEntryId(result, direction) {
59
+ if (!result || typeof result !== "object") {
60
+ return null;
61
+ }
62
+ const currentIndex = Number.isInteger(result.currentIndex) ? result.currentIndex : null;
63
+ const entries = Array.isArray(result.entries) ? result.entries : null;
64
+ if (currentIndex === null || !entries) {
65
+ return null;
66
+ }
67
+ const targetIndex = direction === "back" ? currentIndex - 1 : currentIndex + 1;
68
+ if (targetIndex < 0 || targetIndex >= entries.length) {
69
+ return null;
70
+ }
71
+ const entryId = entries[targetIndex]?.id;
72
+ return Number.isInteger(entryId) ? entryId : null;
73
+ }
74
+
75
+ function pickPageTargetId(targetIds, preferredTargetId, options = {}) {
76
+ const ids = [...targetIds];
77
+ if (ids.length === 0) {
78
+ return null;
79
+ }
80
+
81
+ if (preferredTargetId) {
82
+ const preferred = ids.find((targetId) => targetId === preferredTargetId);
83
+ if (preferred) {
84
+ return preferred;
85
+ }
86
+ if (options.requirePreferred) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ if (options.currentTargetId) {
92
+ const current = ids.find((targetId) => targetId === options.currentTargetId);
93
+ if (current) {
94
+ return current;
95
+ }
96
+ }
97
+
98
+ if (ids.length === 1 || options.allowArbitraryFallback) {
99
+ return ids[0] ?? null;
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ function resolveActiveTabKey(tabs, activeTabIndex) {
106
+ const active = resolveActiveTab(tabs, activeTabIndex);
107
+ if (!active) {
108
+ return null;
109
+ }
110
+ return active.targetId ?? `index:${String(active.index)}`;
111
+ }
112
+
113
+ function resolveActiveTab(tabs, activeTabIndex) {
114
+ const byIndex = activeTabIndex >= 0 ? tabs.find((tab) => tab.index === activeTabIndex) : null;
115
+ return byIndex ?? tabs.find((tab) => tab.active) ?? tabs[0] ?? null;
116
+ }
117
+
118
+ function wait(ms) {
119
+ return new Promise((resolve) => {
120
+ window.setTimeout(resolve, ms);
121
+ });
122
+ }
123
+
124
+ class CdpPageSessionController {
125
+ constructor(sendRawCommand) {
126
+ this.sendRawCommand = sendRawCommand;
127
+ this.preferredTargetResolveAttempts = PREFERRED_TARGET_RESOLVE_ATTEMPTS;
128
+ this.preferredTargetResolveDelayMs = PREFERRED_TARGET_RESOLVE_DELAY_MS;
129
+ this.pageTargets = new Map();
130
+ this.attachedSessionIdByTarget = new Map();
131
+ this.targetIdByAttachedSession = new Map();
132
+ this.currentTargetId = null;
133
+ this.preferredTargetId = null;
134
+ this.generation = 0;
135
+ this.inFlightEnsure = null;
136
+ }
137
+
138
+ reset() {
139
+ this.generation += 1;
140
+ this.pageTargets.clear();
141
+ this.attachedSessionIdByTarget.clear();
142
+ this.targetIdByAttachedSession.clear();
143
+ this.currentTargetId = null;
144
+ this.preferredTargetId = null;
145
+ this.inFlightEnsure = null;
146
+ }
147
+
148
+ setPreferredPageTarget(targetId) {
149
+ this.preferredTargetId = typeof targetId === "string" && targetId.length > 0 ? targetId : null;
150
+ if (this.preferredTargetId) {
151
+ this.currentTargetId = this.preferredTargetId;
152
+ }
153
+ }
154
+
155
+ replacePageTargets(targets) {
156
+ const currentTargetId = this.currentTargetId;
157
+ const preferredTargetId = this.preferredTargetId;
158
+
159
+ this.pageTargets.clear();
160
+ for (const [targetId, targetInfo] of targets) {
161
+ this.pageTargets.set(targetId, targetInfo);
162
+ }
163
+
164
+ for (const [targetId, sessionId] of [...this.attachedSessionIdByTarget]) {
165
+ if (this.pageTargets.has(targetId)) {
166
+ continue;
167
+ }
168
+ this.attachedSessionIdByTarget.delete(targetId);
169
+ this.targetIdByAttachedSession.delete(sessionId);
170
+ }
171
+
172
+ if (currentTargetId && !this.pageTargets.has(currentTargetId)) {
173
+ this.currentTargetId = null;
174
+ }
175
+ if (preferredTargetId && !this.pageTargets.has(preferredTargetId)) {
176
+ this.preferredTargetId = null;
177
+ }
178
+ }
179
+
180
+ upsertPageTarget(targetInfo) {
181
+ const targetId = normalizeTargetId(targetInfo?.targetId);
182
+ if (!targetId || targetInfo?.type !== "page") {
183
+ return;
184
+ }
185
+ this.pageTargets.set(targetId, targetInfo);
186
+ }
187
+
188
+ removePageTarget(targetId) {
189
+ const normalizedTargetId = normalizeTargetId(targetId);
190
+ if (!normalizedTargetId) {
191
+ return;
192
+ }
193
+
194
+ this.pageTargets.delete(normalizedTargetId);
195
+ const attachedSessionId = this.attachedSessionIdByTarget.get(normalizedTargetId) ?? null;
196
+ if (attachedSessionId) {
197
+ this.attachedSessionIdByTarget.delete(normalizedTargetId);
198
+ this.targetIdByAttachedSession.delete(attachedSessionId);
199
+ }
200
+ if (this.currentTargetId === normalizedTargetId) {
201
+ this.currentTargetId = null;
202
+ }
203
+ if (this.preferredTargetId === normalizedTargetId) {
204
+ this.preferredTargetId = null;
205
+ }
206
+ }
207
+
208
+ handleAttachedToTarget(args) {
209
+ const sessionId = normalizeTargetId(args?.sessionId);
210
+ const targetId = normalizeTargetId(args?.targetInfo?.targetId);
211
+ if (!sessionId || !targetId || args?.targetInfo?.type !== "page") {
212
+ return;
213
+ }
214
+
215
+ this.pageTargets.set(targetId, args.targetInfo);
216
+ this.attachedSessionIdByTarget.set(targetId, sessionId);
217
+ this.targetIdByAttachedSession.set(sessionId, targetId);
218
+ if (
219
+ this.currentTargetId === null ||
220
+ this.currentTargetId === targetId ||
221
+ this.preferredTargetId === targetId
222
+ ) {
223
+ this.currentTargetId = targetId;
224
+ }
225
+ }
226
+
227
+ handleDetachedFromTarget(sessionId) {
228
+ const normalizedSessionId = normalizeTargetId(sessionId);
229
+ if (!normalizedSessionId) {
230
+ return;
231
+ }
232
+ const targetId = this.targetIdByAttachedSession.get(normalizedSessionId) ?? null;
233
+ this.targetIdByAttachedSession.delete(normalizedSessionId);
234
+ if (!targetId) {
235
+ return;
236
+ }
237
+ const attachedSessionId = this.attachedSessionIdByTarget.get(targetId);
238
+ if (attachedSessionId === normalizedSessionId) {
239
+ this.attachedSessionIdByTarget.delete(targetId);
240
+ }
241
+ }
242
+
243
+ async refreshPageTargets() {
244
+ const result = await this.sendRawCommand("Target.getTargets");
245
+ const targets = new Map();
246
+ const targetInfos = Array.isArray(result?.targetInfos) ? result.targetInfos : [];
247
+ for (const targetInfo of targetInfos) {
248
+ const targetId = normalizeTargetId(targetInfo?.targetId);
249
+ if (!targetId || targetInfo?.type !== "page") {
250
+ continue;
251
+ }
252
+ targets.set(targetId, targetInfo);
253
+ }
254
+ this.replacePageTargets(targets);
255
+ return new Map(this.pageTargets);
256
+ }
257
+
258
+ async ensurePageSession() {
259
+ const readySessionId = this.readReadySessionId();
260
+ if (readySessionId) {
261
+ return readySessionId;
262
+ }
263
+ if (this.inFlightEnsure) {
264
+ return this.inFlightEnsure;
265
+ }
266
+
267
+ const generation = this.generation;
268
+ const promise = this.resolvePageSession(generation);
269
+ this.inFlightEnsure = promise;
270
+ try {
271
+ return await promise;
272
+ } finally {
273
+ if (this.inFlightEnsure === promise) {
274
+ this.inFlightEnsure = null;
275
+ }
276
+ }
277
+ }
278
+
279
+ readReadySessionId() {
280
+ const targetId = this.resolveCurrentTargetId();
281
+ if (!targetId) {
282
+ return null;
283
+ }
284
+ const sessionId = this.attachedSessionIdByTarget.get(targetId) ?? null;
285
+ if (sessionId) {
286
+ this.currentTargetId = targetId;
287
+ }
288
+ return sessionId;
289
+ }
290
+
291
+ resolveCurrentTargetId() {
292
+ return pickPageTargetId(this.pageTargets.keys(), this.preferredTargetId, {
293
+ requirePreferred: Boolean(this.preferredTargetId),
294
+ currentTargetId: this.currentTargetId,
295
+ });
296
+ }
297
+
298
+ async resolvePageSession(generation) {
299
+ const targetId = await this.resolveTargetId(generation);
300
+ this.assertGeneration(generation);
301
+ this.currentTargetId = targetId;
302
+
303
+ const existingSessionId = this.attachedSessionIdByTarget.get(targetId) ?? null;
304
+ if (existingSessionId) {
305
+ return existingSessionId;
306
+ }
307
+
308
+ const result = await this.sendRawCommand("Target.attachToTarget", {
309
+ targetId,
310
+ flatten: true,
311
+ });
312
+ this.assertGeneration(generation);
313
+
314
+ const sessionId = normalizeTargetId(result?.sessionId);
315
+ if (!sessionId) {
316
+ throw new Error("Failed to attach CDP page target.");
317
+ }
318
+
319
+ this.attachedSessionIdByTarget.set(targetId, sessionId);
320
+ this.targetIdByAttachedSession.set(sessionId, targetId);
321
+ this.currentTargetId = targetId;
322
+ return sessionId;
323
+ }
324
+
325
+ async resolveTargetId(generation) {
326
+ const preferredTargetId = this.preferredTargetId;
327
+
328
+ if (preferredTargetId) {
329
+ let targets =
330
+ this.pageTargets.size > 0 ? new Map(this.pageTargets) : await this.refreshPageTargets();
331
+
332
+ for (let attempt = 0; attempt < this.preferredTargetResolveAttempts; attempt += 1) {
333
+ this.assertGeneration(generation);
334
+
335
+ const targetId = pickPageTargetId(targets.keys(), preferredTargetId, {
336
+ requirePreferred: true,
337
+ currentTargetId: this.currentTargetId,
338
+ });
339
+ if (targetId) {
340
+ return targetId;
341
+ }
342
+
343
+ if (attempt === this.preferredTargetResolveAttempts - 1) {
344
+ break;
345
+ }
346
+
347
+ await wait(this.preferredTargetResolveDelayMs);
348
+ this.assertGeneration(generation);
349
+ targets = await this.refreshPageTargets();
350
+ }
351
+
352
+ throw new Error("Preferred page target is unavailable for CDP command.");
353
+ }
354
+
355
+ let targetId = this.resolveCurrentTargetId();
356
+ if (!targetId) {
357
+ await this.refreshPageTargets();
358
+ this.assertGeneration(generation);
359
+ targetId = this.resolveCurrentTargetId();
360
+ }
361
+ if (!targetId) {
362
+ throw new Error("No unambiguous active page target is available for CDP command.");
363
+ }
364
+ return targetId;
365
+ }
366
+
367
+ assertGeneration(generation) {
368
+ if (generation !== this.generation) {
369
+ throw new Error("CDP session state was reset.");
370
+ }
371
+ }
372
+ }
373
+
374
+ class LocalCdpConnection {
375
+ constructor(onUpdate) {
376
+ this.onUpdate = onUpdate;
377
+ this.accessUrl = null;
378
+ this.state = "idle";
379
+ this.ws = null;
380
+ this.pending = new Map();
381
+ this.nextCommandId = 1;
382
+ this.reconnectTimer = null;
383
+ this.refreshTimer = null;
384
+ this.reconnectAttempt = 0;
385
+ this.closed = false;
386
+ this.pageSessionController = new CdpPageSessionController((method, params, sessionId) =>
387
+ this.sendRawCommand(method, params, sessionId),
388
+ );
389
+ }
390
+
391
+ setAccessUrl(accessUrl) {
392
+ if (this.accessUrl === accessUrl) {
393
+ return;
394
+ }
395
+ this.accessUrl = accessUrl;
396
+ this.restart();
397
+ }
398
+
399
+ setPreferredPageTarget(targetId) {
400
+ this.pageSessionController.setPreferredPageTarget(targetId);
401
+ }
402
+
403
+ async sendCommand(method, params) {
404
+ if (method.startsWith("Input.") || method.startsWith("Page.")) {
405
+ const sessionId = await this.pageSessionController.ensurePageSession();
406
+ return this.sendRawCommand(method, params, sessionId);
407
+ }
408
+ return this.sendRawCommand(method, params);
409
+ }
410
+
411
+ async sendRawCommand(method, params = {}, sessionId) {
412
+ return new Promise((resolve, reject) => {
413
+ const ws = this.ws;
414
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
415
+ reject(new Error("CDP connection is not ready."));
416
+ return;
417
+ }
418
+
419
+ const id = this.nextCommandId++;
420
+ const timer = window.setTimeout(() => {
421
+ const pending = this.pending.get(id);
422
+ if (!pending) {
423
+ return;
424
+ }
425
+ this.pending.delete(id);
426
+ pending.reject(new Error(`CDP command ${method} timed out.`));
427
+ }, CDP_COMMAND_TIMEOUT_MS);
428
+
429
+ this.pending.set(id, { resolve, reject, timer });
430
+ ws.send(
431
+ JSON.stringify({
432
+ id,
433
+ method,
434
+ params,
435
+ ...(sessionId ? { sessionId } : {}),
436
+ }),
437
+ );
438
+ });
439
+ }
440
+
441
+ async navigateHistory(direction) {
442
+ const sessionId = await this.pageSessionController.ensurePageSession();
443
+ const history = await this.sendRawCommand("Page.getNavigationHistory", {}, sessionId);
444
+ const entryId = resolveNavigationHistoryEntryId(history, direction);
445
+ if (entryId === null) {
446
+ return false;
447
+ }
448
+ await this.sendRawCommand("Page.navigateToHistoryEntry", { entryId }, sessionId);
449
+ return true;
450
+ }
451
+
452
+ close() {
453
+ this.closed = true;
454
+ this.clearTimers();
455
+ this.closeSocket();
456
+ this.clearPending("CDP connection closed.");
457
+ this.pageSessionController.reset();
458
+ this.setState("idle");
459
+ }
460
+
461
+ restart() {
462
+ this.clearTimers();
463
+ this.closeSocket();
464
+ this.clearPending("CDP connection reset.");
465
+ this.pageSessionController.reset();
466
+
467
+ if (!this.accessUrl) {
468
+ this.setState("idle");
469
+ return;
470
+ }
471
+
472
+ this.closed = false;
473
+ void this.connect();
474
+ }
475
+
476
+ async connect() {
477
+ if (!this.accessUrl || this.closed) {
478
+ return;
479
+ }
480
+
481
+ this.closeSocket();
482
+ this.clearPending("CDP connection reset.");
483
+ this.pageSessionController.reset();
484
+ this.setState(this.reconnectAttempt === 0 ? "connecting" : "reconnecting");
485
+
486
+ let response;
487
+ try {
488
+ response = await apiFetch(this.accessUrl);
489
+ } catch {
490
+ this.failAndReconnect();
491
+ return;
492
+ }
493
+
494
+ if (!response.ok) {
495
+ this.failAndReconnect();
496
+ return;
497
+ }
498
+
499
+ const payload = await response.json();
500
+ const cdpGrant = payload?.grants?.cdp;
501
+ if (!cdpGrant || cdpGrant.transport !== "ws") {
502
+ this.failAndReconnect();
503
+ return;
504
+ }
505
+
506
+ this.scheduleGrantRefresh(cdpGrant.expiresAt);
507
+
508
+ const ws = new WebSocket(`${cdpGrant.url}?token=${encodeURIComponent(cdpGrant.token)}`);
509
+ this.ws = ws;
510
+
511
+ ws.onopen = () => {
512
+ this.reconnectAttempt = 0;
513
+ this.setState("connected");
514
+ void this.sendRawCommand("Target.setDiscoverTargets", { discover: true }).catch(
515
+ () => undefined,
516
+ );
517
+ void this.pageSessionController.refreshPageTargets().catch(() => undefined);
518
+ };
519
+
520
+ ws.onmessage = (event) => {
521
+ if (typeof event.data !== "string") {
522
+ return;
523
+ }
524
+ let message;
525
+ try {
526
+ message = JSON.parse(event.data);
527
+ } catch {
528
+ return;
529
+ }
530
+
531
+ if (Number.isInteger(message?.id)) {
532
+ const pending = this.pending.get(message.id);
533
+ if (!pending) {
534
+ return;
535
+ }
536
+ this.pending.delete(message.id);
537
+ window.clearTimeout(pending.timer);
538
+ if (message.error) {
539
+ pending.reject(message.error);
540
+ } else {
541
+ pending.resolve(message.result);
542
+ }
543
+ return;
544
+ }
545
+
546
+ const method = message?.method;
547
+ if (typeof method !== "string") {
548
+ return;
549
+ }
550
+
551
+ const params = message?.params ?? {};
552
+ if (method === "Target.targetCreated" || method === "Target.targetInfoChanged") {
553
+ this.pageSessionController.upsertPageTarget(params.targetInfo);
554
+ return;
555
+ }
556
+ if (method === "Target.targetDestroyed") {
557
+ this.pageSessionController.removePageTarget(params.targetId);
558
+ return;
559
+ }
560
+ if (method === "Target.attachedToTarget") {
561
+ this.pageSessionController.handleAttachedToTarget({
562
+ sessionId: params.sessionId,
563
+ targetInfo: params.targetInfo,
564
+ });
565
+ return;
566
+ }
567
+ if (method === "Target.detachedFromTarget") {
568
+ this.pageSessionController.handleDetachedFromTarget(params.sessionId);
569
+ }
570
+ };
571
+
572
+ ws.onerror = () => {
573
+ this.setState("error");
574
+ };
575
+
576
+ ws.onclose = () => {
577
+ this.ws = null;
578
+ if (this.closed) {
579
+ this.setState("idle");
580
+ return;
581
+ }
582
+ this.failAndReconnect();
583
+ };
584
+ }
585
+
586
+ clearPending(message) {
587
+ for (const pending of this.pending.values()) {
588
+ window.clearTimeout(pending.timer);
589
+ pending.reject(new Error(message));
590
+ }
591
+ this.pending.clear();
592
+ }
593
+
594
+ closeSocket() {
595
+ if (!this.ws) {
596
+ return;
597
+ }
598
+ this.ws.onopen = null;
599
+ this.ws.onmessage = null;
600
+ this.ws.onerror = null;
601
+ this.ws.onclose = null;
602
+ this.ws.close();
603
+ this.ws = null;
604
+ }
605
+
606
+ scheduleReconnect() {
607
+ if (this.closed || !this.accessUrl) {
608
+ return;
609
+ }
610
+ const delayMs = Math.min(1000 * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
611
+ this.reconnectAttempt += 1;
612
+ this.setState("reconnecting");
613
+ this.reconnectTimer = window.setTimeout(() => {
614
+ this.reconnectTimer = null;
615
+ void this.connect();
616
+ }, delayMs);
617
+ }
618
+
619
+ scheduleGrantRefresh(expiresAt) {
620
+ if (!Number.isFinite(expiresAt)) {
621
+ return;
622
+ }
623
+ const refreshInMs = expiresAt - Date.now() - 5000;
624
+ if (!Number.isFinite(refreshInMs) || refreshInMs > MAX_TIMEOUT_MS) {
625
+ return;
626
+ }
627
+ this.refreshTimer = window.setTimeout(
628
+ () => {
629
+ if (!this.accessUrl || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
630
+ return;
631
+ }
632
+ void apiFetch(this.accessUrl).catch(() => undefined);
633
+ },
634
+ Math.max(1000, refreshInMs),
635
+ );
636
+ }
637
+
638
+ clearTimers() {
639
+ if (this.reconnectTimer !== null) {
640
+ window.clearTimeout(this.reconnectTimer);
641
+ this.reconnectTimer = null;
642
+ }
643
+ if (this.refreshTimer !== null) {
644
+ window.clearTimeout(this.refreshTimer);
645
+ this.refreshTimer = null;
646
+ }
647
+ }
648
+
649
+ failAndReconnect() {
650
+ this.clearTimers();
651
+ this.closeSocket();
652
+ this.clearPending("CDP connection closed.");
653
+ this.pageSessionController.reset();
654
+ this.setState("error");
655
+ this.scheduleReconnect();
656
+ }
657
+
658
+ setState(state) {
659
+ if (this.state === state) {
660
+ return;
661
+ }
662
+ this.state = state;
663
+ this.onUpdate();
664
+ }
665
+ }
666
+
667
+ class LocalBrowserStream {
668
+ constructor(onUpdate) {
669
+ this.onUpdate = onUpdate;
670
+ this.accessUrl = null;
671
+ this.state = "waiting";
672
+ this.viewport = null;
673
+ this.tabs = [];
674
+ this.activeTabIndex = -1;
675
+ this.activeTabKey = null;
676
+ this.frameUrl = null;
677
+ this.ws = null;
678
+ this.reconnectTimer = null;
679
+ this.reconnectAttempt = 0;
680
+ this.requestedRenderSize = null;
681
+ this.streamConfigTimer = null;
682
+ this.lastSentRenderSizeKey = null;
683
+ this.closed = false;
684
+ }
685
+
686
+ setAccessUrl(accessUrl) {
687
+ if (this.accessUrl === accessUrl) {
688
+ return;
689
+ }
690
+ this.accessUrl = accessUrl;
691
+ this.restart();
692
+ }
693
+
694
+ setRequestedRenderSize(size) {
695
+ this.requestedRenderSize = size;
696
+ this.lastSentRenderSizeKey = null;
697
+ if (!size) {
698
+ return;
699
+ }
700
+ this.scheduleStreamConfig(STREAM_CONFIG_DEBOUNCE_MS);
701
+ }
702
+
703
+ close() {
704
+ this.closed = true;
705
+ this.clearTimers();
706
+ this.closeSocket();
707
+ this.resetFrame();
708
+ this.viewport = null;
709
+ this.tabs = [];
710
+ this.activeTabIndex = -1;
711
+ this.activeTabKey = null;
712
+ this.setState("waiting");
713
+ }
714
+
715
+ restart() {
716
+ this.clearTimers();
717
+ this.closeSocket();
718
+ this.resetFrame();
719
+ this.viewport = null;
720
+ this.tabs = [];
721
+ this.activeTabIndex = -1;
722
+ this.activeTabKey = null;
723
+ if (!this.accessUrl) {
724
+ this.setState("waiting");
725
+ this.onUpdate();
726
+ return;
727
+ }
728
+
729
+ this.closed = false;
730
+ void this.connect();
731
+ }
732
+
733
+ async connect() {
734
+ if (!this.accessUrl || this.closed) {
735
+ return;
736
+ }
737
+ this.setState(this.reconnectAttempt === 0 ? "connecting" : "reconnecting");
738
+
739
+ let response;
740
+ try {
741
+ response = await apiFetch(this.accessUrl);
742
+ } catch {
743
+ this.failAndReconnect();
744
+ return;
745
+ }
746
+
747
+ if (!response.ok) {
748
+ this.failAndReconnect();
749
+ return;
750
+ }
751
+
752
+ const payload = await response.json();
753
+ const viewGrant = payload?.grants?.view;
754
+ if (!viewGrant || viewGrant.transport !== "ws") {
755
+ this.failAndReconnect();
756
+ return;
757
+ }
758
+
759
+ const ws = new WebSocket(`${viewGrant.url}?token=${encodeURIComponent(viewGrant.token)}`);
760
+ ws.binaryType = "arraybuffer";
761
+ this.ws = ws;
762
+
763
+ ws.onopen = () => {
764
+ this.reconnectAttempt = 0;
765
+ this.flushStreamConfig();
766
+ };
767
+
768
+ ws.onmessage = (event) => {
769
+ if (typeof event.data === "string") {
770
+ let message;
771
+ try {
772
+ message = JSON.parse(event.data);
773
+ } catch {
774
+ return;
775
+ }
776
+ this.handleControlMessage(message);
777
+ return;
778
+ }
779
+
780
+ const blob =
781
+ event.data instanceof Blob ? event.data : new Blob([event.data], { type: "image/jpeg" });
782
+ const objectUrl = URL.createObjectURL(blob);
783
+ this.resetFrame();
784
+ this.frameUrl = objectUrl;
785
+ this.setState("live");
786
+ this.onUpdate();
787
+ };
788
+
789
+ ws.onerror = () => {
790
+ this.setState("error");
791
+ this.onUpdate();
792
+ };
793
+
794
+ ws.onclose = () => {
795
+ this.ws = null;
796
+ if (this.closed) {
797
+ this.setState("waiting");
798
+ this.onUpdate();
799
+ return;
800
+ }
801
+ this.failAndReconnect();
802
+ };
803
+ }
804
+
805
+ handleControlMessage(message) {
806
+ if (!message || typeof message !== "object") {
807
+ return;
808
+ }
809
+ if (message.type === "hello") {
810
+ this.viewport =
811
+ Number.isFinite(message.viewport?.width) && Number.isFinite(message.viewport?.height)
812
+ ? {
813
+ width: message.viewport.width,
814
+ height: message.viewport.height,
815
+ }
816
+ : null;
817
+ this.onUpdate();
818
+ return;
819
+ }
820
+ if (message.type === "tabs") {
821
+ const tabs = Array.isArray(message.tabs) ? message.tabs : [];
822
+ const activeTabIndex = Number.isInteger(message.activeTabIndex) ? message.activeTabIndex : -1;
823
+ const nextActiveTabKey = resolveActiveTabKey(tabs, activeTabIndex);
824
+ if (this.activeTabKey !== null && this.activeTabKey !== nextActiveTabKey) {
825
+ this.resetFrame();
826
+ }
827
+ this.activeTabKey = nextActiveTabKey;
828
+ this.tabs = tabs;
829
+ this.activeTabIndex = activeTabIndex;
830
+ this.onUpdate();
831
+ return;
832
+ }
833
+ if (message.type === "status") {
834
+ if (message.status === "live") {
835
+ this.setState("live");
836
+ }
837
+ this.onUpdate();
838
+ return;
839
+ }
840
+ if (message.type === "error") {
841
+ this.setState("error");
842
+ this.onUpdate();
843
+ }
844
+ }
845
+
846
+ scheduleReconnect() {
847
+ if (this.closed || !this.accessUrl) {
848
+ return;
849
+ }
850
+ const delayMs = Math.min(1000 * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
851
+ this.reconnectAttempt += 1;
852
+ this.setState("reconnecting");
853
+ this.reconnectTimer = window.setTimeout(() => {
854
+ this.reconnectTimer = null;
855
+ void this.connect();
856
+ }, delayMs);
857
+ }
858
+
859
+ failAndReconnect() {
860
+ this.clearTimers();
861
+ this.closeSocket();
862
+ this.setState("error");
863
+ this.scheduleReconnect();
864
+ this.onUpdate();
865
+ }
866
+
867
+ scheduleStreamConfig(delayMs) {
868
+ if (this.streamConfigTimer !== null) {
869
+ window.clearTimeout(this.streamConfigTimer);
870
+ this.streamConfigTimer = null;
871
+ }
872
+ this.streamConfigTimer = window.setTimeout(() => {
873
+ this.streamConfigTimer = null;
874
+ this.flushStreamConfig();
875
+ }, delayMs);
876
+ }
877
+
878
+ flushStreamConfig() {
879
+ const ws = this.ws;
880
+ if (!ws || ws.readyState !== WebSocket.OPEN || !this.requestedRenderSize) {
881
+ return;
882
+ }
883
+ const size = normalizeBrowserStreamRenderSize(
884
+ this.requestedRenderSize,
885
+ window.devicePixelRatio || 1,
886
+ );
887
+ if (!size) {
888
+ return;
889
+ }
890
+ const nextKey = `${size.width}x${size.height}`;
891
+ if (nextKey === this.lastSentRenderSizeKey) {
892
+ return;
893
+ }
894
+ this.lastSentRenderSizeKey = nextKey;
895
+ ws.send(
896
+ JSON.stringify({
897
+ type: "stream-config",
898
+ renderWidth: size.width,
899
+ renderHeight: size.height,
900
+ }),
901
+ );
902
+ }
903
+
904
+ clearTimers() {
905
+ if (this.reconnectTimer !== null) {
906
+ window.clearTimeout(this.reconnectTimer);
907
+ this.reconnectTimer = null;
908
+ }
909
+ if (this.streamConfigTimer !== null) {
910
+ window.clearTimeout(this.streamConfigTimer);
911
+ this.streamConfigTimer = null;
912
+ }
913
+ }
914
+
915
+ closeSocket() {
916
+ if (!this.ws) {
917
+ return;
918
+ }
919
+ this.ws.onopen = null;
920
+ this.ws.onmessage = null;
921
+ this.ws.onerror = null;
922
+ this.ws.onclose = null;
923
+ this.ws.close();
924
+ this.ws = null;
925
+ }
926
+
927
+ resetFrame() {
928
+ if (this.frameUrl && this.frameUrl.startsWith("blob:")) {
929
+ URL.revokeObjectURL(this.frameUrl);
930
+ }
931
+ this.frameUrl = null;
932
+ }
933
+
934
+ setState(state) {
935
+ this.state = state;
936
+ }
937
+ }
938
+
939
+ class LocalViewApp {
940
+ constructor() {
941
+ this.sessions = [];
942
+ this.selectedSessionId = null;
943
+ this.addressEditing = false;
944
+ this.closingSessionId = null;
945
+ this.stoppingService = false;
946
+ this.serviceStopped = false;
947
+ this.refreshTimer = null;
948
+ this.viewportRefreshTimer = null;
949
+ this.inputCommandQueue = Promise.resolve();
950
+ this.inputViewport = null;
951
+ this.explicitPreferredTargetId = null;
952
+ this.lastInputViewportTargetId = null;
953
+ this.lastMouseMoveAt = 0;
954
+ this.isMouseDragging = false;
955
+ this.activeMouseButton = null;
956
+ this.layoutFrame = null;
957
+ this.lastBrowserFrameWidth = null;
958
+ this.lastStreamAspect = null;
959
+
960
+ this.viewerAreaEl = document.querySelector(".viewer-area");
961
+ this.browserFrameEl = document.querySelector(".browser-frame");
962
+ this.browserChromeEl = document.querySelector(".browser-chrome");
963
+ this.browserViewportEl = document.querySelector(".browser-viewport");
964
+ this.sessionListEl = document.getElementById("session-list");
965
+ this.tabStripEl = document.getElementById("tab-strip");
966
+ this.statusDotEl = document.getElementById("status-dot");
967
+ this.statusLabelEl = document.getElementById("status-label");
968
+ this.statusTextEl = document.getElementById("status-text");
969
+ this.viewerSurfaceEl = document.getElementById("viewer-surface");
970
+ this.viewerImageEl = document.getElementById("viewer-image");
971
+ this.viewerEmptyEl = document.getElementById("viewer-empty");
972
+ this.viewerEmptyTextEl = document.getElementById("viewer-empty-text");
973
+ this.addressFormEl = document.getElementById("address-form");
974
+ this.addressInputEl = document.getElementById("address-input");
975
+ this.backButtonEl = document.getElementById("back-button");
976
+ this.forwardButtonEl = document.getElementById("forward-button");
977
+ this.reloadButtonEl = document.getElementById("reload-button");
978
+ this.newTabButtonEl = document.getElementById("new-tab-button");
979
+ this.closeBrowserButtonEl = document.getElementById("close-browser-button");
980
+ this.stopViewButtonEl = document.getElementById("stop-view-button");
981
+
982
+ this.stream = new LocalBrowserStream(() => this.render());
983
+ this.cdp = new LocalCdpConnection(() => this.render());
984
+
985
+ this.bindUi();
986
+ this.render();
987
+ }
988
+
989
+ start() {
990
+ void this.refreshSessions();
991
+ this.refreshTimer = window.setInterval(() => {
992
+ void this.refreshSessions();
993
+ }, SESSION_REFRESH_MS);
994
+ this.viewportRefreshTimer = window.setInterval(() => {
995
+ void this.refreshInputViewport();
996
+ }, VIEWPORT_REFRESH_MS);
997
+ }
998
+
999
+ stopBackgroundRefresh() {
1000
+ if (this.refreshTimer !== null) {
1001
+ window.clearInterval(this.refreshTimer);
1002
+ this.refreshTimer = null;
1003
+ }
1004
+ if (this.viewportRefreshTimer !== null) {
1005
+ window.clearInterval(this.viewportRefreshTimer);
1006
+ this.viewportRefreshTimer = null;
1007
+ }
1008
+ }
1009
+
1010
+ bindUi() {
1011
+ window.addEventListener("hashchange", () => {
1012
+ const hashSessionId = resolveSelectedSessionIdFromHash();
1013
+ if (hashSessionId && this.sessions.some((session) => session.sessionId === hashSessionId)) {
1014
+ this.selectSession(hashSessionId);
1015
+ }
1016
+ });
1017
+
1018
+ this.sessionListEl.addEventListener("click", (event) => {
1019
+ const button = event.target.closest("button[data-session-id]");
1020
+ if (!button) {
1021
+ return;
1022
+ }
1023
+ this.selectSession(button.dataset.sessionId ?? null);
1024
+ });
1025
+
1026
+ this.tabStripEl.addEventListener("click", (event) => {
1027
+ const closeButton = event.target.closest("button[data-close-target-id]");
1028
+ if (closeButton) {
1029
+ event.stopPropagation();
1030
+ const targetId = closeButton.dataset.closeTargetId;
1031
+ if (targetId) {
1032
+ if (this.explicitPreferredTargetId === targetId) {
1033
+ this.explicitPreferredTargetId = null;
1034
+ }
1035
+ void this.cdp.sendRawCommand("Target.closeTarget", { targetId }).catch(() => undefined);
1036
+ }
1037
+ return;
1038
+ }
1039
+
1040
+ const tabButton = event.target.closest("button[data-target-id]");
1041
+ if (!tabButton) {
1042
+ return;
1043
+ }
1044
+ const targetId = tabButton.dataset.targetId;
1045
+ if (!targetId) {
1046
+ return;
1047
+ }
1048
+ this.explicitPreferredTargetId = targetId;
1049
+ this.cdp.setPreferredPageTarget(targetId);
1050
+ this.render();
1051
+ void this.cdp.sendRawCommand("Target.activateTarget", { targetId }).catch(() => undefined);
1052
+ });
1053
+
1054
+ this.addressInputEl.addEventListener("focus", () => {
1055
+ this.addressEditing = true;
1056
+ });
1057
+ this.addressInputEl.addEventListener("blur", () => {
1058
+ this.addressEditing = false;
1059
+ this.renderAddress();
1060
+ });
1061
+ this.addressFormEl.addEventListener("submit", (event) => {
1062
+ event.preventDefault();
1063
+ const value = this.normalizeSubmittedUrl(this.addressInputEl.value);
1064
+ if (!value) {
1065
+ return;
1066
+ }
1067
+ void this.cdp.sendCommand("Page.navigate", { url: value }).catch(() => undefined);
1068
+ this.viewerSurfaceEl.focus();
1069
+ });
1070
+
1071
+ this.backButtonEl.addEventListener("click", () => {
1072
+ void this.cdp.navigateHistory("back").catch(() => undefined);
1073
+ });
1074
+ this.forwardButtonEl.addEventListener("click", () => {
1075
+ void this.cdp.navigateHistory("forward").catch(() => undefined);
1076
+ });
1077
+ this.reloadButtonEl.addEventListener("click", () => {
1078
+ void this.cdp.sendCommand("Page.reload", { ignoreCache: false }).catch(() => undefined);
1079
+ });
1080
+ this.newTabButtonEl.addEventListener("click", () => {
1081
+ void this.cdp
1082
+ .sendCommand("Target.createTarget", { url: "about:blank" })
1083
+ .then((result) => {
1084
+ const targetId = normalizeTargetId(result?.targetId);
1085
+ if (!targetId) {
1086
+ return undefined;
1087
+ }
1088
+ this.explicitPreferredTargetId = targetId;
1089
+ this.cdp.setPreferredPageTarget(targetId);
1090
+ this.render();
1091
+ return this.cdp.sendCommand("Target.activateTarget", { targetId });
1092
+ })
1093
+ .catch(() => undefined);
1094
+ });
1095
+ this.closeBrowserButtonEl.addEventListener("click", () => {
1096
+ void this.closeSelectedBrowser();
1097
+ });
1098
+ this.stopViewButtonEl.addEventListener("click", () => {
1099
+ void this.stopLocalViewService();
1100
+ });
1101
+
1102
+ this.viewerSurfaceEl.addEventListener("contextmenu", (event) => {
1103
+ event.preventDefault();
1104
+ });
1105
+ this.viewerSurfaceEl.addEventListener("mousedown", (event) => {
1106
+ this.viewerSurfaceEl.focus();
1107
+ const point = this.eventToViewportPoint(event);
1108
+ if (!point) {
1109
+ return;
1110
+ }
1111
+ event.preventDefault();
1112
+ this.isMouseDragging = true;
1113
+ this.activeMouseButton = event.button;
1114
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1115
+ type: "mousePressed",
1116
+ x: point.x,
1117
+ y: point.y,
1118
+ button: mouseButtonName(event.button),
1119
+ buttons: mouseButtonMask(event.button),
1120
+ clickCount: readMouseClickCount("mousePressed", event.detail),
1121
+ modifiers: resolveModifiers(event),
1122
+ });
1123
+ });
1124
+ this.viewerSurfaceEl.addEventListener("mouseup", (event) => {
1125
+ const point = this.eventToViewportPoint(event);
1126
+ if (!point) {
1127
+ return;
1128
+ }
1129
+ event.preventDefault();
1130
+ this.isMouseDragging = false;
1131
+ this.activeMouseButton = null;
1132
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1133
+ type: "mouseReleased",
1134
+ x: point.x,
1135
+ y: point.y,
1136
+ button: mouseButtonName(event.button),
1137
+ buttons: 0,
1138
+ clickCount: readMouseClickCount("mouseReleased", event.detail),
1139
+ modifiers: resolveModifiers(event),
1140
+ });
1141
+ });
1142
+ this.viewerSurfaceEl.addEventListener("mousemove", (event) => {
1143
+ const now = Date.now();
1144
+ if (now - this.lastMouseMoveAt < MOUSE_MOVE_THROTTLE_MS) {
1145
+ return;
1146
+ }
1147
+ this.lastMouseMoveAt = now;
1148
+
1149
+ const point = this.eventToViewportPoint(event);
1150
+ if (!point) {
1151
+ return;
1152
+ }
1153
+ const trackedButton = this.activeMouseButton;
1154
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1155
+ type: "mouseMoved",
1156
+ x: point.x,
1157
+ y: point.y,
1158
+ button: trackedButton === null ? "none" : mouseButtonName(trackedButton),
1159
+ buttons: trackedButton === null ? event.buttons : mouseButtonMask(trackedButton),
1160
+ clickCount: 0,
1161
+ modifiers: resolveModifiers(event),
1162
+ });
1163
+ });
1164
+ this.viewerSurfaceEl.addEventListener(
1165
+ "wheel",
1166
+ (event) => {
1167
+ const point = this.eventToViewportPoint(event);
1168
+ if (!point) {
1169
+ return;
1170
+ }
1171
+ event.preventDefault();
1172
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1173
+ type: "mouseWheel",
1174
+ x: point.x,
1175
+ y: point.y,
1176
+ deltaX: event.deltaX,
1177
+ deltaY: event.deltaY,
1178
+ modifiers: resolveModifiers(event),
1179
+ });
1180
+ },
1181
+ { passive: false },
1182
+ );
1183
+
1184
+ this.viewerSurfaceEl.addEventListener("keydown", (event) => {
1185
+ const payload = createCdpKeyDownPayload(event);
1186
+ if (!payload) {
1187
+ return;
1188
+ }
1189
+ event.preventDefault();
1190
+ void this.dispatchPointerCommand("Input.dispatchKeyEvent", payload);
1191
+ });
1192
+
1193
+ this.viewerSurfaceEl.addEventListener("keyup", (event) => {
1194
+ const payload = createCdpKeyPayload(event, "keyUp");
1195
+ if (!payload) {
1196
+ return;
1197
+ }
1198
+ event.preventDefault();
1199
+ void this.dispatchPointerCommand("Input.dispatchKeyEvent", payload);
1200
+ });
1201
+
1202
+ this.viewerSurfaceEl.addEventListener("paste", (event) => {
1203
+ const text = event.clipboardData?.getData("text/plain");
1204
+ if (!text) {
1205
+ return;
1206
+ }
1207
+ event.preventDefault();
1208
+ void this.dispatchPointerCommand("Input.insertText", { text });
1209
+ });
1210
+
1211
+ this.viewerImageEl.addEventListener("load", () => {
1212
+ this.scheduleBrowserFrameLayout();
1213
+ void this.refreshInputViewport();
1214
+ });
1215
+
1216
+ window.addEventListener("mousemove", (event) => {
1217
+ if (!this.isMouseDragging || this.cdp.state !== "connected") {
1218
+ return;
1219
+ }
1220
+
1221
+ const now = Date.now();
1222
+ if (now - this.lastMouseMoveAt < MOUSE_MOVE_THROTTLE_MS) {
1223
+ return;
1224
+ }
1225
+ this.lastMouseMoveAt = now;
1226
+
1227
+ const insidePoint = this.eventToViewportPoint(event);
1228
+ if (insidePoint) {
1229
+ return;
1230
+ }
1231
+
1232
+ const point = this.eventToViewportPoint(event, { clampOutside: true });
1233
+ if (!point || this.activeMouseButton === null) {
1234
+ return;
1235
+ }
1236
+
1237
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1238
+ type: "mouseMoved",
1239
+ x: point.x,
1240
+ y: point.y,
1241
+ button: mouseButtonName(this.activeMouseButton),
1242
+ buttons: mouseButtonMask(this.activeMouseButton),
1243
+ clickCount: 0,
1244
+ modifiers: resolveModifiers(event),
1245
+ });
1246
+ });
1247
+
1248
+ window.addEventListener("mouseup", (event) => {
1249
+ if (!this.isMouseDragging) {
1250
+ return;
1251
+ }
1252
+
1253
+ const releasedButton = this.activeMouseButton;
1254
+ this.isMouseDragging = false;
1255
+ this.activeMouseButton = null;
1256
+
1257
+ if (this.cdp.state !== "connected" || releasedButton === null) {
1258
+ return;
1259
+ }
1260
+
1261
+ const point = this.eventToViewportPoint(event, { clampOutside: true });
1262
+ if (!point) {
1263
+ return;
1264
+ }
1265
+
1266
+ void this.dispatchPointerCommand("Input.dispatchMouseEvent", {
1267
+ type: "mouseReleased",
1268
+ x: point.x,
1269
+ y: point.y,
1270
+ button: mouseButtonName(releasedButton),
1271
+ buttons: 0,
1272
+ clickCount: readMouseClickCount("mouseReleased", event.detail),
1273
+ modifiers: resolveModifiers(event),
1274
+ });
1275
+ });
1276
+
1277
+ const resizeObserver = new ResizeObserver(() => {
1278
+ this.scheduleBrowserFrameLayout();
1279
+ });
1280
+ if (this.viewerAreaEl) {
1281
+ resizeObserver.observe(this.viewerAreaEl);
1282
+ }
1283
+ if (this.browserChromeEl) {
1284
+ resizeObserver.observe(this.browserChromeEl);
1285
+ }
1286
+ if (this.browserViewportEl) {
1287
+ resizeObserver.observe(this.browserViewportEl);
1288
+ }
1289
+ window.addEventListener("resize", () => {
1290
+ this.scheduleBrowserFrameLayout();
1291
+ });
1292
+ }
1293
+
1294
+ async refreshSessions() {
1295
+ if (this.serviceStopped) {
1296
+ return;
1297
+ }
1298
+
1299
+ let response;
1300
+ try {
1301
+ response = await apiFetch(`${apiBasePath}/sessions`);
1302
+ } catch {
1303
+ return;
1304
+ }
1305
+ if (!response.ok) {
1306
+ return;
1307
+ }
1308
+
1309
+ const payload = await response.json();
1310
+ this.sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
1311
+ if (
1312
+ this.closingSessionId &&
1313
+ !this.sessions.some((session) => session.sessionId === this.closingSessionId)
1314
+ ) {
1315
+ this.closingSessionId = null;
1316
+ }
1317
+
1318
+ const hashSessionId = resolveSelectedSessionIdFromHash();
1319
+ const activeSessionId = this.resolveActiveSessionId(hashSessionId);
1320
+ this.selectSession(activeSessionId, {
1321
+ force: hashSessionId !== activeSessionId,
1322
+ updateHash: hashSessionId !== activeSessionId,
1323
+ });
1324
+ }
1325
+
1326
+ resolveActiveSessionId(hashSessionId) {
1327
+ if (hashSessionId && this.sessions.some((session) => session.sessionId === hashSessionId)) {
1328
+ return hashSessionId;
1329
+ }
1330
+
1331
+ if (
1332
+ this.selectedSessionId &&
1333
+ this.sessions.some((session) => session.sessionId === this.selectedSessionId)
1334
+ ) {
1335
+ return this.selectedSessionId;
1336
+ }
1337
+
1338
+ return this.sessions[0]?.sessionId ?? null;
1339
+ }
1340
+
1341
+ selectSession(sessionId, options = {}) {
1342
+ if (this.selectedSessionId === sessionId && !options.force) {
1343
+ this.render();
1344
+ return;
1345
+ }
1346
+
1347
+ this.selectedSessionId = sessionId;
1348
+ if (options.updateHash !== false) {
1349
+ setSelectedSessionHash(sessionId);
1350
+ }
1351
+
1352
+ const accessUrl = sessionId
1353
+ ? `${apiBasePath}/sessions/${encodeURIComponent(sessionId)}/access`
1354
+ : null;
1355
+ this.stream.setAccessUrl(accessUrl);
1356
+ this.cdp.setAccessUrl(accessUrl);
1357
+ this.render();
1358
+ }
1359
+
1360
+ render() {
1361
+ const selectedSession =
1362
+ this.sessions.find((session) => session.sessionId === this.selectedSessionId) ?? null;
1363
+ const activeTab =
1364
+ (this.stream.activeTabIndex >= 0
1365
+ ? this.stream.tabs.find((tab) => tab.index === this.stream.activeTabIndex)
1366
+ : null) ??
1367
+ this.stream.tabs.find((tab) => tab.active) ??
1368
+ this.stream.tabs[0] ??
1369
+ null;
1370
+ if (this.explicitPreferredTargetId !== null) {
1371
+ if (activeTab?.targetId === this.explicitPreferredTargetId) {
1372
+ this.explicitPreferredTargetId = null;
1373
+ }
1374
+ }
1375
+ const preferredTargetId = this.explicitPreferredTargetId ?? activeTab?.targetId ?? null;
1376
+
1377
+ this.cdp.setPreferredPageTarget(preferredTargetId);
1378
+ this.scheduleBrowserFrameLayout();
1379
+
1380
+ if (this.lastInputViewportTargetId !== preferredTargetId) {
1381
+ this.lastInputViewportTargetId = preferredTargetId;
1382
+ this.inputViewport = this.stream.viewport;
1383
+ void this.refreshInputViewport();
1384
+ }
1385
+
1386
+ this.renderSessions();
1387
+ this.renderTabs(activeTab, preferredTargetId);
1388
+ this.renderAddress(activeTab);
1389
+ this.renderCloseBrowserButton(selectedSession);
1390
+ this.renderStopViewButton();
1391
+
1392
+ this.viewerImageEl.src = this.stream.frameUrl ?? "";
1393
+ this.viewerImageEl.hidden = !this.stream.frameUrl;
1394
+ this.viewerEmptyEl.hidden = Boolean(this.stream.frameUrl);
1395
+ this.viewerEmptyTextEl.textContent = this.serviceStopped
1396
+ ? "Service stopped. Run `opensteer view` to restart."
1397
+ : selectedSession
1398
+ ? this.closingSessionId === selectedSession.sessionId
1399
+ ? "Closing browser..."
1400
+ : this.stream.state === "connecting" || this.stream.state === "reconnecting"
1401
+ ? "Connecting to browser\u2026"
1402
+ : "Waiting for frames\u2026"
1403
+ : "No live browser selected";
1404
+
1405
+ this.statusDotEl.className = "chrome-status-dot";
1406
+ if (this.serviceStopped) {
1407
+ this.statusDotEl.classList.add("is-idle");
1408
+ this.statusLabelEl.textContent = "Stopped";
1409
+ } else if (selectedSession && this.closingSessionId === selectedSession.sessionId) {
1410
+ this.statusDotEl.classList.add("is-connecting");
1411
+ this.statusLabelEl.textContent = "Closing";
1412
+ } else if (this.stream.state === "live") {
1413
+ this.statusDotEl.classList.add("is-live");
1414
+ this.statusLabelEl.textContent = "Live";
1415
+ } else if (this.stream.state === "connecting" || this.stream.state === "reconnecting") {
1416
+ this.statusDotEl.classList.add("is-connecting");
1417
+ this.statusLabelEl.textContent =
1418
+ this.stream.state === "connecting" ? "Connecting" : "Reconnecting";
1419
+ } else if (this.stream.state === "error") {
1420
+ this.statusDotEl.classList.add("is-error");
1421
+ this.statusLabelEl.textContent = "Error";
1422
+ } else if (selectedSession) {
1423
+ this.statusLabelEl.textContent = "Waiting";
1424
+ } else {
1425
+ this.statusDotEl.classList.add("is-idle");
1426
+ this.statusLabelEl.textContent = "";
1427
+ }
1428
+
1429
+ if (this.serviceStopped) {
1430
+ this.statusTextEl.textContent = "Service stopped. Run `opensteer view` to restart.";
1431
+ return;
1432
+ }
1433
+
1434
+ const sessionSummary =
1435
+ selectedSession === null
1436
+ ? "No session selected"
1437
+ : `${selectedSession.label} / ${selectedSession.engine}`;
1438
+ this.statusTextEl.textContent = `${sessionSummary} / stream ${this.stream.state} / cdp ${this.cdp.state}`;
1439
+ }
1440
+
1441
+ renderCloseBrowserButton(selectedSession) {
1442
+ const isClosing =
1443
+ selectedSession !== null && this.closingSessionId === selectedSession.sessionId;
1444
+ const canClose = selectedSession !== null && selectedSession.ownership === "owned";
1445
+ this.closeBrowserButtonEl.disabled = !canClose || isClosing;
1446
+ this.closeBrowserButtonEl.textContent = isClosing ? "Closing..." : "Close Browser";
1447
+ this.closeBrowserButtonEl.title =
1448
+ selectedSession && selectedSession.ownership !== "owned"
1449
+ ? "Only Opensteer-owned local browsers can be closed here."
1450
+ : "";
1451
+ }
1452
+
1453
+ renderStopViewButton() {
1454
+ this.stopViewButtonEl.disabled = this.stoppingService || this.serviceStopped;
1455
+ this.stopViewButtonEl.textContent = this.serviceStopped
1456
+ ? "Service Stopped"
1457
+ : this.stoppingService
1458
+ ? "Turning Off..."
1459
+ : "Turn View Off";
1460
+ }
1461
+
1462
+ renderSessions() {
1463
+ this.sessionListEl.textContent = "";
1464
+ if (this.sessions.length === 0) {
1465
+ const empty = document.createElement("div");
1466
+ empty.className = "session-list-empty";
1467
+ empty.innerHTML =
1468
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>' +
1469
+ `<span>${this.serviceStopped ? "Service stopped" : "No active sessions"}</span>`;
1470
+ this.sessionListEl.append(empty);
1471
+ return;
1472
+ }
1473
+
1474
+ for (const session of this.sessions) {
1475
+ const button = document.createElement("button");
1476
+ button.type = "button";
1477
+ button.className = "session-item";
1478
+ button.dataset.sessionId = session.sessionId;
1479
+ button.dataset.active = String(session.sessionId === this.selectedSessionId);
1480
+ button.setAttribute("data-testid", `session-item-${session.sessionId}`);
1481
+
1482
+ const row1 = document.createElement("div");
1483
+ row1.className = "session-row";
1484
+
1485
+ const label = document.createElement("span");
1486
+ label.className = "session-title";
1487
+ label.textContent = session.label;
1488
+
1489
+ const dot = document.createElement("span");
1490
+ dot.className = "session-dot";
1491
+
1492
+ row1.append(label, dot);
1493
+
1494
+ const row2 = document.createElement("div");
1495
+ row2.className = "session-row";
1496
+
1497
+ const badge = document.createElement("span");
1498
+ badge.className = "session-engine";
1499
+ badge.textContent = session.browserName ?? session.engine;
1500
+
1501
+ const meta = document.createElement("span");
1502
+ meta.className = "session-info";
1503
+ meta.textContent = [session.workspace, `pid ${session.pid}`].filter(Boolean).join(" \u00b7 ");
1504
+
1505
+ row2.append(badge, meta);
1506
+
1507
+ const pathEl = document.createElement("div");
1508
+ pathEl.className = "session-path";
1509
+ const segments = (session.rootPath || "").split("/").filter(Boolean);
1510
+ const dirName = segments.length > 0 ? segments[segments.length - 1] : "";
1511
+ pathEl.textContent = dirName ? `\u2192 ${dirName}` : "";
1512
+
1513
+ button.append(row1, row2);
1514
+ if (dirName) {
1515
+ button.append(pathEl);
1516
+ }
1517
+ this.sessionListEl.append(button);
1518
+ }
1519
+ }
1520
+
1521
+ renderTabs(activeTab, preferredTargetId = null) {
1522
+ this.tabStripEl.textContent = "";
1523
+ for (const tab of this.stream.tabs) {
1524
+ const chip = document.createElement("div");
1525
+ chip.className = "chrome-tab-chip";
1526
+ chip.dataset.active = String(
1527
+ preferredTargetId
1528
+ ? tab.targetId === preferredTargetId
1529
+ : activeTab
1530
+ ? activeTab.index === tab.index
1531
+ : tab.active,
1532
+ );
1533
+
1534
+ const button = document.createElement("button");
1535
+ button.type = "button";
1536
+ button.className = "chrome-tab tab-button";
1537
+ button.dataset.active = chip.dataset.active;
1538
+ if (tab.targetId) {
1539
+ button.dataset.targetId = tab.targetId;
1540
+ }
1541
+
1542
+ const title = document.createElement("span");
1543
+ title.className = "chrome-tab-title";
1544
+ title.textContent = tab.title || tab.url || "Untitled";
1545
+
1546
+ button.append(title);
1547
+ chip.append(button);
1548
+
1549
+ if (tab.targetId) {
1550
+ const closeButton = document.createElement("button");
1551
+ closeButton.type = "button";
1552
+ closeButton.className = "chrome-tab-close";
1553
+ closeButton.dataset.closeTargetId = tab.targetId;
1554
+ closeButton.setAttribute("aria-label", "Close tab");
1555
+ closeButton.textContent = "\u00d7";
1556
+ chip.append(closeButton);
1557
+ }
1558
+
1559
+ this.tabStripEl.append(chip);
1560
+ }
1561
+ }
1562
+
1563
+ renderAddress(activeTab) {
1564
+ if (this.addressEditing) {
1565
+ return;
1566
+ }
1567
+ this.addressInputEl.value = activeTab?.url ?? "";
1568
+ }
1569
+
1570
+ getActiveTab() {
1571
+ return (
1572
+ (this.stream.activeTabIndex >= 0
1573
+ ? this.stream.tabs.find((tab) => tab.index === this.stream.activeTabIndex)
1574
+ : null) ??
1575
+ this.stream.tabs.find((tab) => tab.active) ??
1576
+ this.stream.tabs[0] ??
1577
+ null
1578
+ );
1579
+ }
1580
+
1581
+ activateActiveTab() {
1582
+ const targetId = this.getActiveTab()?.targetId;
1583
+ if (!targetId) {
1584
+ return;
1585
+ }
1586
+ this.cdp.setPreferredPageTarget(targetId);
1587
+ void this.cdp.sendRawCommand("Target.activateTarget", { targetId }).catch(() => undefined);
1588
+ }
1589
+
1590
+ async closeSelectedBrowser() {
1591
+ const selectedSession =
1592
+ this.sessions.find((session) => session.sessionId === this.selectedSessionId) ?? null;
1593
+ if (!selectedSession || selectedSession.ownership !== "owned") {
1594
+ return;
1595
+ }
1596
+ if (this.closingSessionId !== null) {
1597
+ return;
1598
+ }
1599
+ if (!window.confirm(`Close browser "${selectedSession.label}"?`)) {
1600
+ return;
1601
+ }
1602
+
1603
+ const { sessionId } = selectedSession;
1604
+ this.closingSessionId = sessionId;
1605
+ this.render();
1606
+
1607
+ let response;
1608
+ try {
1609
+ response = await apiFetch(`${apiBasePath}/sessions/${encodeURIComponent(sessionId)}/close`, {
1610
+ method: "POST",
1611
+ });
1612
+ } catch {
1613
+ if (this.closingSessionId === sessionId) {
1614
+ this.closingSessionId = null;
1615
+ }
1616
+ this.render();
1617
+ return;
1618
+ }
1619
+
1620
+ if (!response.ok) {
1621
+ if (this.closingSessionId === sessionId) {
1622
+ this.closingSessionId = null;
1623
+ }
1624
+ this.render();
1625
+ return;
1626
+ }
1627
+
1628
+ if (this.selectedSessionId === sessionId) {
1629
+ this.selectedSessionId = null;
1630
+ setSelectedSessionHash(null);
1631
+ this.stream.setAccessUrl(null);
1632
+ this.cdp.setAccessUrl(null);
1633
+ }
1634
+ this.sessions = this.sessions.filter((session) => session.sessionId !== sessionId);
1635
+ if (this.closingSessionId === sessionId) {
1636
+ this.closingSessionId = null;
1637
+ }
1638
+ this.render();
1639
+ await this.refreshSessions();
1640
+ }
1641
+
1642
+ async stopLocalViewService() {
1643
+ if (this.stoppingService || this.serviceStopped) {
1644
+ return;
1645
+ }
1646
+ if (!window.confirm("Turn off local view service? Browser sessions will keep running.")) {
1647
+ return;
1648
+ }
1649
+
1650
+ this.stoppingService = true;
1651
+ this.render();
1652
+
1653
+ let response;
1654
+ try {
1655
+ response = await apiFetch(`${apiBasePath}/service/stop`, {
1656
+ method: "POST",
1657
+ });
1658
+ } catch {
1659
+ this.stoppingService = false;
1660
+ this.render();
1661
+ return;
1662
+ }
1663
+
1664
+ if (!response.ok) {
1665
+ this.stoppingService = false;
1666
+ this.render();
1667
+ return;
1668
+ }
1669
+
1670
+ this.stoppingService = false;
1671
+ this.serviceStopped = true;
1672
+ this.stopBackgroundRefresh();
1673
+ this.stream.close();
1674
+ this.cdp.close();
1675
+ this.sessions = [];
1676
+ this.selectedSessionId = null;
1677
+ this.closingSessionId = null;
1678
+ setSelectedSessionHash(null);
1679
+ this.render();
1680
+ }
1681
+
1682
+ async dispatchPointerCommand(method, payload) {
1683
+ this.inputCommandQueue = this.inputCommandQueue
1684
+ .catch(() => undefined)
1685
+ .then(async () => {
1686
+ await this.cdp.sendCommand(method, payload);
1687
+ })
1688
+ .catch(() => undefined);
1689
+ return this.inputCommandQueue;
1690
+ }
1691
+
1692
+ async refreshInputViewport() {
1693
+ if (this.cdp.state !== "connected") {
1694
+ this.inputViewport = this.stream.viewport;
1695
+ return;
1696
+ }
1697
+
1698
+ try {
1699
+ const metrics = await this.cdp.sendCommand("Page.getLayoutMetrics");
1700
+ const viewport = readViewportFromLayoutMetrics(metrics);
1701
+ if (viewport) {
1702
+ this.inputViewport = viewport;
1703
+ }
1704
+ } catch {}
1705
+ }
1706
+
1707
+ scheduleBrowserFrameLayout() {
1708
+ if (this.layoutFrame !== null) {
1709
+ return;
1710
+ }
1711
+ this.layoutFrame = window.requestAnimationFrame(() => {
1712
+ this.layoutFrame = null;
1713
+ this.updateBrowserFrameLayout();
1714
+ });
1715
+ }
1716
+
1717
+ updateBrowserFrameLayout() {
1718
+ if (!this.viewerAreaEl || !this.browserFrameEl || !this.browserChromeEl) {
1719
+ return;
1720
+ }
1721
+
1722
+ const aspect = this.resolveStreamAspect();
1723
+ if (this.lastStreamAspect !== aspect) {
1724
+ this.lastStreamAspect = aspect;
1725
+ this.browserFrameEl.style.setProperty("--browser-stream-aspect", String(aspect));
1726
+ }
1727
+
1728
+ const areaRect = this.viewerAreaEl.getBoundingClientRect();
1729
+ const areaStyle = window.getComputedStyle(this.viewerAreaEl);
1730
+ const availableWidth =
1731
+ areaRect.width -
1732
+ readCssPixelValue(areaStyle.paddingLeft) -
1733
+ readCssPixelValue(areaStyle.paddingRight);
1734
+ const availableHeight =
1735
+ areaRect.height -
1736
+ readCssPixelValue(areaStyle.paddingTop) -
1737
+ readCssPixelValue(areaStyle.paddingBottom);
1738
+ const chromeHeight = this.browserChromeEl.getBoundingClientRect().height;
1739
+ const availableViewportHeight = availableHeight - chromeHeight - BROWSER_FRAME_BORDER_Y_PX;
1740
+
1741
+ if (availableWidth > 0 && availableViewportHeight > 0) {
1742
+ const width = Math.max(
1743
+ 1,
1744
+ Math.floor(Math.min(availableWidth, availableViewportHeight * aspect)),
1745
+ );
1746
+ if (this.lastBrowserFrameWidth !== width) {
1747
+ this.lastBrowserFrameWidth = width;
1748
+ this.browserFrameEl.style.setProperty("--browser-frame-width", `${String(width)}px`);
1749
+ }
1750
+ } else {
1751
+ this.lastBrowserFrameWidth = null;
1752
+ this.browserFrameEl.style.removeProperty("--browser-frame-width");
1753
+ }
1754
+
1755
+ this.updateRequestedRenderSize();
1756
+ }
1757
+
1758
+ updateRequestedRenderSize() {
1759
+ const targetEl = this.browserViewportEl ?? this.viewerSurfaceEl;
1760
+ const rect = targetEl.getBoundingClientRect();
1761
+ if (rect.width < 100 || rect.height < 100) {
1762
+ return;
1763
+ }
1764
+ this.stream.setRequestedRenderSize({
1765
+ width: rect.width,
1766
+ height: rect.height,
1767
+ });
1768
+ }
1769
+
1770
+ resolveStreamAspect() {
1771
+ if (this.viewerImageEl.naturalWidth > 0 && this.viewerImageEl.naturalHeight > 0) {
1772
+ const imageAspect = this.viewerImageEl.naturalWidth / this.viewerImageEl.naturalHeight;
1773
+ if (Number.isFinite(imageAspect) && imageAspect > 0) {
1774
+ return imageAspect;
1775
+ }
1776
+ }
1777
+
1778
+ const viewport = this.inputViewport ?? this.stream.viewport;
1779
+ if (viewport?.width > 0 && viewport?.height > 0) {
1780
+ const viewportAspect = viewport.width / viewport.height;
1781
+ if (Number.isFinite(viewportAspect) && viewportAspect > 0) {
1782
+ return viewportAspect;
1783
+ }
1784
+ }
1785
+
1786
+ return FALLBACK_STREAM_ASPECT;
1787
+ }
1788
+
1789
+ normalizeSubmittedUrl(value) {
1790
+ const trimmed = value.trim();
1791
+ if (!trimmed) {
1792
+ return null;
1793
+ }
1794
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(trimmed)) {
1795
+ return trimmed;
1796
+ }
1797
+ return `https://${trimmed}`;
1798
+ }
1799
+
1800
+ eventToViewportPoint(event, options = {}) {
1801
+ const viewport = this.inputViewport ?? this.stream.viewport;
1802
+ if (!viewport) {
1803
+ return null;
1804
+ }
1805
+ const imageBounds = this.viewerImageEl.getBoundingClientRect();
1806
+ if (imageBounds.width <= 0 || imageBounds.height <= 0) {
1807
+ return null;
1808
+ }
1809
+
1810
+ const sourceWidth =
1811
+ this.viewerImageEl.naturalWidth > 0 ? this.viewerImageEl.naturalWidth : viewport.width;
1812
+ const sourceHeight =
1813
+ this.viewerImageEl.naturalHeight > 0 ? this.viewerImageEl.naturalHeight : viewport.height;
1814
+ const imageAspect = sourceWidth / sourceHeight;
1815
+ const elementAspect = imageBounds.width / imageBounds.height;
1816
+
1817
+ let renderWidth;
1818
+ let renderHeight;
1819
+ let offsetX;
1820
+ let offsetY;
1821
+
1822
+ if (imageAspect > elementAspect) {
1823
+ renderWidth = imageBounds.width;
1824
+ renderHeight = imageBounds.width / imageAspect;
1825
+ offsetX = 0;
1826
+ offsetY = (imageBounds.height - renderHeight) / 2;
1827
+ } else {
1828
+ renderHeight = imageBounds.height;
1829
+ renderWidth = imageBounds.height * imageAspect;
1830
+ offsetX = (imageBounds.width - renderWidth) / 2;
1831
+ offsetY = 0;
1832
+ }
1833
+
1834
+ const localX = event.clientX - imageBounds.left - offsetX;
1835
+ const localY = event.clientY - imageBounds.top - offsetY;
1836
+ const localXWithin = localX >= 0 && localX <= renderWidth;
1837
+ const localYWithin = localY >= 0 && localY <= renderHeight;
1838
+
1839
+ if ((!localXWithin || !localYWithin) && !options.clampOutside) {
1840
+ return null;
1841
+ }
1842
+
1843
+ const normalizedLocalX = options.clampOutside ? clamp(localX, 0, renderWidth) : localX;
1844
+ const normalizedLocalY = options.clampOutside ? clamp(localY, 0, renderHeight) : localY;
1845
+
1846
+ return {
1847
+ x: clamp(
1848
+ Math.floor((normalizedLocalX / renderWidth) * viewport.width),
1849
+ 0,
1850
+ viewport.width - 1,
1851
+ ),
1852
+ y: clamp(
1853
+ Math.floor((normalizedLocalY / renderHeight) * viewport.height),
1854
+ 0,
1855
+ viewport.height - 1,
1856
+ ),
1857
+ };
1858
+ }
1859
+ }
1860
+
1861
+ function normalizeTargetId(value) {
1862
+ if (typeof value !== "string") {
1863
+ return null;
1864
+ }
1865
+ const normalized = value.trim();
1866
+ return normalized.length > 0 ? normalized : null;
1867
+ }
1868
+
1869
+ function normalizeViewportDimension(value) {
1870
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1871
+ return null;
1872
+ }
1873
+ const normalized = Math.floor(value);
1874
+ if (normalized < 100) {
1875
+ return null;
1876
+ }
1877
+ return Math.min(8192, normalized);
1878
+ }
1879
+
1880
+ function readViewportFromLayoutMetrics(result) {
1881
+ const candidates = [
1882
+ result?.cssVisualViewport,
1883
+ result?.cssLayoutViewport,
1884
+ result?.visualViewport,
1885
+ result?.layoutViewport,
1886
+ ];
1887
+
1888
+ for (const candidate of candidates) {
1889
+ const width = normalizeViewportDimension(candidate?.clientWidth);
1890
+ const height = normalizeViewportDimension(candidate?.clientHeight);
1891
+ if (width !== null && height !== null) {
1892
+ return { width, height };
1893
+ }
1894
+ }
1895
+ return null;
1896
+ }
1897
+
1898
+ function clamp(value, min, max) {
1899
+ return Math.min(Math.max(value, min), max);
1900
+ }
1901
+
1902
+ function readCssPixelValue(value) {
1903
+ const parsed = Number.parseFloat(value);
1904
+ return Number.isFinite(parsed) ? parsed : 0;
1905
+ }
1906
+
1907
+ function mouseButtonName(button) {
1908
+ if (button === 1) {
1909
+ return "middle";
1910
+ }
1911
+ if (button === 2) {
1912
+ return "right";
1913
+ }
1914
+ return "left";
1915
+ }
1916
+
1917
+ function mouseButtonMask(button) {
1918
+ if (button === 1) {
1919
+ return 4;
1920
+ }
1921
+ if (button === 2) {
1922
+ return 2;
1923
+ }
1924
+ return 1;
1925
+ }
1926
+
1927
+ function readMouseClickCount(type, detail) {
1928
+ if (type === "mouseMoved") {
1929
+ return 0;
1930
+ }
1931
+ if (!Number.isFinite(detail) || detail < 1) {
1932
+ return 1;
1933
+ }
1934
+ return Math.floor(detail);
1935
+ }
1936
+
1937
+ function resolveModifiers(event) {
1938
+ let modifiers = 0;
1939
+ if (event.altKey) {
1940
+ modifiers |= 1;
1941
+ }
1942
+ if (event.ctrlKey) {
1943
+ modifiers |= 2;
1944
+ }
1945
+ if (event.metaKey) {
1946
+ modifiers |= 4;
1947
+ }
1948
+ if (event.shiftKey) {
1949
+ modifiers |= 8;
1950
+ }
1951
+ return modifiers;
1952
+ }
1953
+
1954
+ function readEditingCommands(event) {
1955
+ if (event.altKey || event.shiftKey || (!event.metaKey && !event.ctrlKey)) {
1956
+ return undefined;
1957
+ }
1958
+ const command = EDITING_COMMANDS_BY_KEY[event.key.toLowerCase()];
1959
+ return command ? [command] : undefined;
1960
+ }
1961
+
1962
+ function readKeyText(event) {
1963
+ if (event.isComposing) {
1964
+ return undefined;
1965
+ }
1966
+ if (event.key === "Enter") {
1967
+ return "\r";
1968
+ }
1969
+ if (
1970
+ typeof event.key === "string" &&
1971
+ event.key.length === 1 &&
1972
+ !event.altKey &&
1973
+ !event.ctrlKey &&
1974
+ !event.metaKey
1975
+ ) {
1976
+ return event.key;
1977
+ }
1978
+ return undefined;
1979
+ }
1980
+
1981
+ function readVirtualKeyCode(event) {
1982
+ if (typeof event.keyCode === "number" && Number.isFinite(event.keyCode)) {
1983
+ return event.keyCode;
1984
+ }
1985
+ return KEY_CODES[event.key] ?? 0;
1986
+ }
1987
+
1988
+ function createCdpKeyDownPayload(event) {
1989
+ const windowsVirtualKeyCode = readVirtualKeyCode(event);
1990
+ const text = readKeyText({
1991
+ key: event.key,
1992
+ altKey: event.altKey,
1993
+ ctrlKey: event.ctrlKey,
1994
+ metaKey: event.metaKey,
1995
+ shiftKey: event.shiftKey,
1996
+ isComposing: event.isComposing,
1997
+ });
1998
+ const commands = readEditingCommands(event);
1999
+
2000
+ return {
2001
+ type: text ? "keyDown" : "rawKeyDown",
2002
+ key: event.key,
2003
+ code: event.code,
2004
+ windowsVirtualKeyCode,
2005
+ nativeVirtualKeyCode: windowsVirtualKeyCode,
2006
+ isKeypad: event.code.startsWith("Numpad"),
2007
+ autoRepeat: event.repeat,
2008
+ modifiers: resolveModifiers(event),
2009
+ ...(text ? { text, unmodifiedText: text } : {}),
2010
+ ...(commands ? { commands } : {}),
2011
+ };
2012
+ }
2013
+
2014
+ function createCdpKeyPayload(event, type = "keyDown") {
2015
+ const key = event.key;
2016
+ const code = event.code;
2017
+ const windowsVirtualKeyCode = readVirtualKeyCode(event);
2018
+ return {
2019
+ type,
2020
+ key,
2021
+ code,
2022
+ windowsVirtualKeyCode,
2023
+ nativeVirtualKeyCode: windowsVirtualKeyCode,
2024
+ isKeypad: event.code.startsWith("Numpad"),
2025
+ modifiers: resolveModifiers(event),
2026
+ };
2027
+ }
2028
+
2029
+ const EDITING_COMMANDS_BY_KEY = {
2030
+ a: "selectAll",
2031
+ c: "copy",
2032
+ v: "paste",
2033
+ x: "cut",
2034
+ };
2035
+
2036
+ const KEY_CODES = {
2037
+ " ": 32,
2038
+ Enter: 13,
2039
+ Tab: 9,
2040
+ Escape: 27,
2041
+ Backspace: 8,
2042
+ Delete: 46,
2043
+ ArrowLeft: 37,
2044
+ ArrowUp: 38,
2045
+ ArrowRight: 39,
2046
+ ArrowDown: 40,
2047
+ Home: 36,
2048
+ End: 35,
2049
+ PageUp: 33,
2050
+ PageDown: 34,
2051
+ };
2052
+
2053
+ new LocalViewApp().start();