@omercnet/paseo-shared-browser 1.1.0-next.210.1 → 1.1.0-next.213.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.
@@ -45,6 +45,7 @@ import {
45
45
  sendBrowserInputRpc,
46
46
  toViewportPoint,
47
47
  } from "../shared/browser";
48
+ import { FrameLifecycle } from "./frame-lifecycle";
48
49
 
49
50
  const SPACE = {
50
51
  xxs: 2,
@@ -989,14 +990,15 @@ export function SharedBrowserPanel({
989
990
  const mountedRef = useRef(false);
990
991
  const activeViewerTokenRef = useRef<string | null>(null);
991
992
  const stateRef = useRef<BrowserState | null>(null);
992
- const frameRef = useRef<BrowserFrame | null>(null);
993
+ const lifecycleRef = useRef(new FrameLifecycle());
994
+ const lifecycle = lifecycleRef.current;
993
995
  const lastPointRef = useRef<{ x: number; y: number } | null>(null);
994
996
  const dragStartRef = useRef<{ x: number; y: number } | null>(null);
995
- const mutationEpochRef = useRef(0);
996
997
  const captureInFlightRef = useRef(false);
997
998
 
998
999
  const [state, setState] = useState<BrowserState | null>(null);
999
1000
  const [frame, setFrame] = useState<BrowserFrame | null>(null);
1001
+ const [inputBusy, setInputBusy] = useState(false);
1000
1002
  const [controlToken, setControlToken] = useState<string | null>(null);
1001
1003
  const [operationError, setOperationError] = useState<string | null>(null);
1002
1004
  const [imageError, setImageError] = useState(false);
@@ -1024,7 +1026,7 @@ export function SharedBrowserPanel({
1024
1026
  const acceptState = useCallback((next: BrowserState) => {
1025
1027
  const previous = stateRef.current;
1026
1028
  if (previous && !isBrowserStateCurrent(previous, next)) return false;
1027
- const currentFrame = frameRef.current;
1029
+ const currentFrame = lifecycle.frame;
1028
1030
  if (previous && didBrowserRuntimeRestart(previous, next)) {
1029
1031
  setRuntimeNotice(
1030
1032
  "Browser restarted. The preserved viewer connection now targets the new runtime.",
@@ -1032,7 +1034,7 @@ export function SharedBrowserPanel({
1032
1034
  setControlToken(null);
1033
1035
  }
1034
1036
  if (currentFrame && !isFrameCurrent(currentFrame, next)) {
1035
- frameRef.current = null;
1037
+ lifecycle.drop();
1036
1038
  setFrame(null);
1037
1039
  lastPointRef.current = null;
1038
1040
  }
@@ -1074,9 +1076,10 @@ export function SharedBrowserPanel({
1074
1076
 
1075
1077
  useEffect(() => {
1076
1078
  stateRef.current = null;
1077
- frameRef.current = null;
1079
+ lifecycle.reset();
1078
1080
  setState(null);
1079
1081
  setFrame(null);
1082
+ setInputBusy(false);
1080
1083
  setControlToken(null);
1081
1084
  setOperationError(null);
1082
1085
  setRuntimeNotice(null);
@@ -1086,8 +1089,8 @@ export function SharedBrowserPanel({
1086
1089
  queryKey: ["shared-browser", "capture", viewerToken],
1087
1090
  queryFn: async () => {
1088
1091
  if (!viewerToken) throw new Error("The browser viewer is not attached.");
1089
- const mutationEpoch = mutationEpochRef.current;
1090
- const knownFrame = frameRef.current;
1092
+ const mutationEpoch = lifecycle.epoch;
1093
+ const knownFrame = lifecycle.frame;
1091
1094
  captureInFlightRef.current = true;
1092
1095
  try {
1093
1096
  const result = await captureBrowser({
@@ -1113,17 +1116,14 @@ export function SharedBrowserPanel({
1113
1116
 
1114
1117
  useEffect(() => {
1115
1118
  const result = captureQuery.data;
1116
- if (
1117
- !result ||
1118
- result.mutationEpoch !== mutationEpochRef.current ||
1119
- !acceptState(result.state)
1120
- ) {
1119
+ if (!result || !lifecycle.isCurrent(result.mutationEpoch) || !acceptState(result.state)) {
1121
1120
  return;
1122
1121
  }
1123
1122
  if (result.frame && isFrameCurrent(result.frame, result.state)) {
1124
- frameRef.current = result.frame;
1123
+ lifecycle.accept(result.mutationEpoch, result.frame);
1125
1124
  setImageError(false);
1126
- setFrame(result.frame);
1125
+ setFrame(lifecycle.visible);
1126
+ setInputBusy(lifecycle.busy);
1127
1127
  }
1128
1128
  }, [acceptState, captureQuery.data]);
1129
1129
 
@@ -1212,6 +1212,9 @@ export function SharedBrowserPanel({
1212
1212
  onSuccess: (result) => mutationSucceeded(result.state),
1213
1213
  onError: mutationFailed,
1214
1214
  });
1215
+ const settleInput = () => {
1216
+ lifecycle.settle();
1217
+ };
1215
1218
  const inputMutation = useMutation({
1216
1219
  mutationFn: sendBrowserInput,
1217
1220
  retry: false,
@@ -1220,9 +1223,13 @@ export function SharedBrowserPanel({
1220
1223
  const sentText = variables.event.text;
1221
1224
  setTypeDraft((current) => (current === sentText ? "" : current));
1222
1225
  }
1226
+ settleInput();
1223
1227
  mutationSucceeded(result.state);
1224
1228
  },
1225
- onError: mutationFailed,
1229
+ onError: (error) => {
1230
+ settleInput();
1231
+ mutationFailed(error);
1232
+ },
1226
1233
  });
1227
1234
 
1228
1235
  const anyMutationPending =
@@ -1234,7 +1241,8 @@ export function SharedBrowserPanel({
1234
1241
  inputMutation.isPending;
1235
1242
  const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1236
1243
  const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1237
- const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
1244
+ const canSendInput =
1245
+ canControl && Boolean(currentFrame) && !inputMutation.isPending && !inputBusy;
1238
1246
 
1239
1247
  const frameUri = useMemo(
1240
1248
  () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
@@ -1284,7 +1292,7 @@ export function SharedBrowserPanel({
1284
1292
  const inputContext = useCallback(() => {
1285
1293
  const context = controlContext();
1286
1294
  const current = stateRef.current;
1287
- const targetFrame = frameRef.current;
1295
+ const targetFrame = lifecycle.frame;
1288
1296
  if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
1289
1297
  return {
1290
1298
  ...context,
@@ -1317,12 +1325,13 @@ export function SharedBrowserPanel({
1317
1325
 
1318
1326
  const sendEvent = useCallback(
1319
1327
  (event: BrowserInputEvent) => {
1328
+ if (lifecycle.busy) return;
1320
1329
  const context = requireInputContext();
1321
- if (!context || inputMutation.isPending) return;
1322
- mutationEpochRef.current += 1;
1330
+ if (!context || !lifecycle.begin()) return;
1331
+ setInputBusy(true);
1323
1332
  inputMutation.mutate({ ...context, event });
1324
1333
  },
1325
- [inputMutation, requireInputContext],
1334
+ [inputMutation, lifecycle, requireInputContext],
1326
1335
  );
1327
1336
 
1328
1337
  const pointFromEvent = useCallback(
@@ -1402,7 +1411,7 @@ export function SharedBrowserPanel({
1402
1411
  const takeControl = useCallback(
1403
1412
  (takeover: boolean) => {
1404
1413
  if (!viewerToken || acquireMutation.isPending) return;
1405
- mutationEpochRef.current += 1;
1414
+ lifecycle.bump();
1406
1415
  acquireMutation.mutate({ viewerToken, takeover });
1407
1416
  },
1408
1417
  [acquireMutation, viewerToken],
@@ -1417,7 +1426,7 @@ export function SharedBrowserPanel({
1417
1426
  ) {
1418
1427
  return;
1419
1428
  }
1420
- mutationEpochRef.current += 1;
1429
+ lifecycle.bump();
1421
1430
  releaseMutation.mutate({ viewerToken: viewer, controlToken });
1422
1431
  }, [controlToken, releaseMutation]);
1423
1432
 
@@ -1431,11 +1440,11 @@ export function SharedBrowserPanel({
1431
1440
  setOperationError("Enter an address to navigate.");
1432
1441
  return;
1433
1442
  }
1434
- mutationEpochRef.current += 1;
1443
+ lifecycle.bump();
1435
1444
  navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1436
1445
  return;
1437
1446
  }
1438
- mutationEpochRef.current += 1;
1447
+ lifecycle.bump();
1439
1448
  navigateMutation.mutate({ ...context, action: { kind: action } });
1440
1449
  },
1441
1450
  [navigateMutation, requireControlContext],
@@ -1459,7 +1468,7 @@ export function SharedBrowserPanel({
1459
1468
  );
1460
1469
  return;
1461
1470
  }
1462
- mutationEpochRef.current += 1;
1471
+ lifecycle.bump();
1463
1472
  resizeMutation.mutate({ ...context, viewport: { width, height } });
1464
1473
  }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1465
1474
 
@@ -1467,7 +1476,7 @@ export function SharedBrowserPanel({
1467
1476
  (presetId: DevicePresetId) => {
1468
1477
  const context = requireControlContext();
1469
1478
  if (!context || deviceMutation.isPending) return;
1470
- mutationEpochRef.current += 1;
1479
+ lifecycle.bump();
1471
1480
  setDevicePickerOpen(false);
1472
1481
  deviceMutation.mutate({ ...context, presetId });
1473
1482
  },
@@ -1498,7 +1507,7 @@ export function SharedBrowserPanel({
1498
1507
 
1499
1508
  const reconnect = useCallback(() => {
1500
1509
  if (attachQuery.isFetching) return;
1501
- mutationEpochRef.current += 1;
1510
+ lifecycle.bump();
1502
1511
  setReconnecting(true);
1503
1512
  setControlToken(null);
1504
1513
  setOperationError(null);
@@ -1703,7 +1712,7 @@ export function SharedBrowserPanel({
1703
1712
  }
1704
1713
  accessibilityRole="image"
1705
1714
  onError={() => {
1706
- frameRef.current = null;
1715
+ lifecycle.frame = null;
1707
1716
  setImageError(true);
1708
1717
  void captureQuery.refetch({ cancelRefetch: false });
1709
1718
  }}
@@ -0,0 +1,65 @@
1
+ import type { BrowserFrame } from "../shared/browser";
2
+
3
+ /**
4
+ * Tracks which captured frame input may target. After an input is submitted
5
+ * and again when it settles, the actionable frame is dropped and the epoch
6
+ * advances, so captures that began earlier are ignored. Input stays blocked
7
+ * until a capture taken after settlement is accepted. The last accepted image
8
+ * stays visible meanwhile.
9
+ */
10
+ export class FrameLifecycle {
11
+ epoch = 0;
12
+ /** Frame input may target; null while a fresh capture is awaited. */
13
+ frame: BrowserFrame | null = null;
14
+ /** Last accepted frame, kept on screen while input is blocked. */
15
+ visible: BrowserFrame | null = null;
16
+ private gate: "idle" | "pending" | "refreshing" = "idle";
17
+
18
+ get busy(): boolean {
19
+ return this.gate !== "idle";
20
+ }
21
+
22
+ bump(): void {
23
+ this.epoch += 1;
24
+ }
25
+
26
+ isCurrent(captureEpoch: number): boolean {
27
+ return captureEpoch === this.epoch;
28
+ }
29
+
30
+ /** Claims the input slot; false if an input is already in flight or awaiting a fresh frame. */
31
+ begin(): boolean {
32
+ if (this.busy) return false;
33
+ this.gate = "pending";
34
+ this.frame = null;
35
+ this.bump();
36
+ return true;
37
+ }
38
+
39
+ /** Called when an input succeeds or fails. Never replays the action. */
40
+ settle(): void {
41
+ this.frame = null;
42
+ this.bump();
43
+ this.gate = "refreshing";
44
+ }
45
+
46
+ /** Accepts a capture started at `captureEpoch`; false if it predates the latest boundary. */
47
+ accept(captureEpoch: number, frame: BrowserFrame): boolean {
48
+ if (!this.isCurrent(captureEpoch)) return false;
49
+ this.frame = frame;
50
+ this.visible = frame;
51
+ if (this.gate === "refreshing") this.gate = "idle";
52
+ return true;
53
+ }
54
+
55
+ /** Forgets all frames, e.g. when the session no longer matches them. */
56
+ drop(): void {
57
+ this.frame = null;
58
+ this.visible = null;
59
+ }
60
+
61
+ reset(): void {
62
+ this.drop();
63
+ this.gate = "idle";
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.1.0-next.210.1",
3
+ "version": "1.1.0-next.213.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
@@ -43,8 +43,8 @@
43
43
  "check:write": "biome check --write .",
44
44
  "typecheck": "tsc --noEmit",
45
45
  "prepare:runtime": "node scripts/prepare-runtime.mjs",
46
- "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
47
- "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
46
+ "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts",
47
+ "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts",
48
48
  "test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
49
49
  },
50
50
  "dependencies": {
@@ -183,6 +183,26 @@ function findString(value: unknown, keys: readonly string[]): string | null {
183
183
  return null;
184
184
  }
185
185
 
186
+ export function jpegDimensions(base64: string): { width: number; height: number } | null {
187
+ const bytes = Buffer.from(base64, "base64");
188
+ let offset = 2;
189
+ while (bytes[0] === 0xff && bytes[1] === 0xd8 && offset + 9 <= bytes.length) {
190
+ if (bytes[offset] !== 0xff) return null;
191
+ const marker = bytes[offset + 1];
192
+ // SOFn carries the frame size; DHT (c4), JPG (c8) and DAC (cc) share the range but do not.
193
+ if (
194
+ marker !== undefined &&
195
+ marker >= 0xc0 &&
196
+ marker <= 0xcf &&
197
+ ![0xc4, 0xc8, 0xcc].includes(marker)
198
+ )
199
+ return { width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
200
+ if (marker === 0xda) return null;
201
+ offset += 2 + bytes.readUInt16BE(offset + 2);
202
+ }
203
+ return null;
204
+ }
205
+
186
206
  export class AgentBrowserRuntime {
187
207
  readonly binaryPath: string;
188
208
  readonly executablePath: string;
@@ -318,7 +338,7 @@ export class AgentBrowserRuntime {
318
338
  await this.requireConnection().send("Target.activateTarget", { targetId }, { mutation: true });
319
339
  this.page = await attachToTarget(this.requireConnection(), targetId);
320
340
  this.targetId = targetId;
321
- await this.bindPageEvents(this.page);
341
+ await this.preparePage(this.page);
322
342
  if (this.screencastActive) await this.startScreencastSession(this.page);
323
343
  }
324
344
 
@@ -425,34 +445,7 @@ export class AgentBrowserRuntime {
425
445
  async emulate(device: DeviceEmulation): Promise<void> {
426
446
  this.assertViewport(device);
427
447
  await this.withInvalidatedScreencast(async (page) => {
428
- await page.send(
429
- "Emulation.setDeviceMetricsOverride",
430
- {
431
- width: device.width,
432
- height: device.height,
433
- deviceScaleFactor: device.deviceScaleFactor,
434
- mobile: device.mobile,
435
- screenWidth: device.screenWidth ?? device.width,
436
- screenHeight: device.screenHeight ?? device.height,
437
- screenOrientation: {
438
- type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
439
- angle: device.width > device.height ? 90 : 0,
440
- },
441
- },
442
- { mutation: true },
443
- );
444
- await page.send(
445
- "Emulation.setTouchEmulationEnabled",
446
- { enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
447
- { mutation: true },
448
- );
449
- if (device.userAgent) {
450
- await page.send(
451
- "Emulation.setUserAgentOverride",
452
- { userAgent: device.userAgent, platform: device.platform ?? "" },
453
- { mutation: true },
454
- );
455
- }
448
+ await this.applyEmulation(page, device);
456
449
  this.viewport = { ...device };
457
450
  });
458
451
  }
@@ -483,7 +476,17 @@ export class AgentBrowserRuntime {
483
476
  throw new RangeError("maxBytes must be positive");
484
477
  const streamed = this.screencastFrame ?? (await this.waitForFrame(waitMs));
485
478
  if (streamed && streamed.byteLength <= maxBytes) return streamed;
486
- const page = await this.requirePage();
479
+ let frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
480
+ if (!this.sizeMismatch(frame)) return frame;
481
+ // The browser stopped honouring the cached viewport. Re-apply it once; never loop.
482
+ await this.emulate(this.requireViewport());
483
+ frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
484
+ const mismatch = this.sizeMismatch(frame);
485
+ if (mismatch) throw new Error(`Browser returned a ${mismatch} after viewport resync`);
486
+ return frame;
487
+ }
488
+
489
+ private async screenshot(page: CdpSession, maxBytes: number, quality: number) {
487
490
  for (const candidate of [quality, 50, 35, 20, 10, 1]) {
488
491
  const boundedQuality = Math.max(1, Math.min(100, Math.round(candidate)));
489
492
  const result = await page.send<{ data: string }>("Page.captureScreenshot", {
@@ -502,12 +505,25 @@ export class AgentBrowserRuntime {
502
505
  height: viewport.height,
503
506
  transport: "screenshot",
504
507
  capturedAt: new Date().toISOString(),
505
- };
508
+ } satisfies RuntimeFrame;
506
509
  }
507
510
  }
508
511
  throw new Error(`JPEG screenshot exceeds ${maxBytes} bytes at minimum quality`);
509
512
  }
510
513
 
514
+ private sizeMismatch(frame: RuntimeFrame): string | null {
515
+ const { width, height, deviceScaleFactor: scale } = this.requireViewport();
516
+ const size = jpegDimensions(frame.dataBase64);
517
+ // Chromium rounds fractional device pixels, so allow one pixel of slack.
518
+ if (
519
+ size &&
520
+ Math.abs(size.width - width * scale) <= 1 &&
521
+ Math.abs(size.height - height * scale) <= 1
522
+ )
523
+ return null;
524
+ return `${size ? `${size.width}x${size.height}` : "unreadable"} JPEG for a ${width}x${height} viewport at ${scale}x`;
525
+ }
526
+
511
527
  async mouseMove(x: number, y: number): Promise<void> {
512
528
  this.assertPoint(x, y);
513
529
  await (await this.requirePage()).send(
@@ -786,13 +802,46 @@ export class AgentBrowserRuntime {
786
802
  return this.viewport;
787
803
  }
788
804
 
789
- private async bindPageEvents(page: CdpSession): Promise<void> {
805
+ private async preparePage(page: CdpSession): Promise<void> {
790
806
  page.on("Page.screencastFrame", this.onScreencastFrame);
791
807
  page.on("event", (event: CdpEvent) => {
792
808
  if (event.method === "Inspector.targetCrashed") this.invalidateScreencastFrame();
793
809
  });
794
810
  await Promise.all([page.send("Page.enable"), page.send("Runtime.enable")]);
795
811
  await this.navigationHistory(page);
812
+ // Emulation is scoped to the CDP session, so every fresh attach starts without it.
813
+ if (this.viewport) await this.applyEmulation(page, this.viewport);
814
+ }
815
+
816
+ private async applyEmulation(page: CdpSession, device: DeviceEmulation): Promise<void> {
817
+ await page.send(
818
+ "Emulation.setDeviceMetricsOverride",
819
+ {
820
+ width: device.width,
821
+ height: device.height,
822
+ deviceScaleFactor: device.deviceScaleFactor,
823
+ mobile: device.mobile,
824
+ screenWidth: device.screenWidth ?? device.width,
825
+ screenHeight: device.screenHeight ?? device.height,
826
+ screenOrientation: {
827
+ type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
828
+ angle: device.width > device.height ? 90 : 0,
829
+ },
830
+ },
831
+ { mutation: true },
832
+ );
833
+ await page.send(
834
+ "Emulation.setTouchEmulationEnabled",
835
+ { enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
836
+ { mutation: true },
837
+ );
838
+ if (device.userAgent) {
839
+ await page.send(
840
+ "Emulation.setUserAgentOverride",
841
+ { userAgent: device.userAgent, platform: device.platform ?? "" },
842
+ { mutation: true },
843
+ );
844
+ }
796
845
  }
797
846
 
798
847
  private async startScreencastSession(page: CdpSession): Promise<void> {
@@ -837,7 +886,7 @@ export class AgentBrowserRuntime {
837
886
  { mutation: true },
838
887
  );
839
888
  const replacement = await attachToTarget(connection, target.targetId);
840
- await this.bindPageEvents(replacement);
889
+ await this.preparePage(replacement);
841
890
  if (this.page === previous) {
842
891
  this.page = replacement;
843
892
  this.targetId = target.targetId;