@omercnet/paseo-shared-browser 1.1.0-next.204.1 → 1.1.0-next.211.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.
@@ -29,6 +29,8 @@ import {
29
29
  type BrowserInputEvent,
30
30
  type BrowserState,
31
31
  captureBrowserRpc,
32
+ containedRect,
33
+ createImageSizeLoader,
32
34
  DEVICE_PRESETS,
33
35
  type DevicePresetId,
34
36
  detachBrowserRpc,
@@ -41,7 +43,9 @@ import {
41
43
  releaseControlRpc,
42
44
  resizeBrowserRpc,
43
45
  sendBrowserInputRpc,
46
+ toViewportPoint,
44
47
  } from "../shared/browser";
48
+ import { FrameLifecycle } from "./frame-lifecycle";
45
49
 
46
50
  const SPACE = {
47
51
  xxs: 2,
@@ -88,12 +92,7 @@ interface Size {
88
92
  height: number;
89
93
  }
90
94
 
91
- interface DisplayRect extends Size {
92
- x: number;
93
- y: number;
94
- }
95
-
96
- interface DisplayPoint extends Size {
95
+ interface DisplayPoint {
97
96
  x: number;
98
97
  y: number;
99
98
  }
@@ -115,21 +114,6 @@ const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
115
114
  { key: "Space", label: "Space" },
116
115
  ];
117
116
 
118
- function containedRect(container: Size, image: Size): DisplayRect | null {
119
- if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
120
- return null;
121
- }
122
- const scale = Math.min(container.width / image.width, container.height / image.height);
123
- const width = image.width * scale;
124
- const height = image.height * scale;
125
- return {
126
- x: (container.width - width) / 2,
127
- y: (container.height - height) / 2,
128
- width,
129
- height,
130
- };
131
- }
132
-
133
117
  function errorMessage(error: unknown): string {
134
118
  if (error instanceof Error && error.message.trim()) return error.message;
135
119
  return "The shared browser request failed.";
@@ -1006,14 +990,15 @@ export function SharedBrowserPanel({
1006
990
  const mountedRef = useRef(false);
1007
991
  const activeViewerTokenRef = useRef<string | null>(null);
1008
992
  const stateRef = useRef<BrowserState | null>(null);
1009
- const frameRef = useRef<BrowserFrame | null>(null);
993
+ const lifecycleRef = useRef(new FrameLifecycle());
994
+ const lifecycle = lifecycleRef.current;
1010
995
  const lastPointRef = useRef<{ x: number; y: number } | null>(null);
1011
996
  const dragStartRef = useRef<{ x: number; y: number } | null>(null);
1012
- const mutationEpochRef = useRef(0);
1013
997
  const captureInFlightRef = useRef(false);
1014
998
 
1015
999
  const [state, setState] = useState<BrowserState | null>(null);
1016
1000
  const [frame, setFrame] = useState<BrowserFrame | null>(null);
1001
+ const [inputBusy, setInputBusy] = useState(false);
1017
1002
  const [controlToken, setControlToken] = useState<string | null>(null);
1018
1003
  const [operationError, setOperationError] = useState<string | null>(null);
1019
1004
  const [imageError, setImageError] = useState(false);
@@ -1041,7 +1026,7 @@ export function SharedBrowserPanel({
1041
1026
  const acceptState = useCallback((next: BrowserState) => {
1042
1027
  const previous = stateRef.current;
1043
1028
  if (previous && !isBrowserStateCurrent(previous, next)) return false;
1044
- const currentFrame = frameRef.current;
1029
+ const currentFrame = lifecycle.frame;
1045
1030
  if (previous && didBrowserRuntimeRestart(previous, next)) {
1046
1031
  setRuntimeNotice(
1047
1032
  "Browser restarted. The preserved viewer connection now targets the new runtime.",
@@ -1049,7 +1034,7 @@ export function SharedBrowserPanel({
1049
1034
  setControlToken(null);
1050
1035
  }
1051
1036
  if (currentFrame && !isFrameCurrent(currentFrame, next)) {
1052
- frameRef.current = null;
1037
+ lifecycle.drop();
1053
1038
  setFrame(null);
1054
1039
  lastPointRef.current = null;
1055
1040
  }
@@ -1091,9 +1076,10 @@ export function SharedBrowserPanel({
1091
1076
 
1092
1077
  useEffect(() => {
1093
1078
  stateRef.current = null;
1094
- frameRef.current = null;
1079
+ lifecycle.reset();
1095
1080
  setState(null);
1096
1081
  setFrame(null);
1082
+ setInputBusy(false);
1097
1083
  setControlToken(null);
1098
1084
  setOperationError(null);
1099
1085
  setRuntimeNotice(null);
@@ -1103,8 +1089,8 @@ export function SharedBrowserPanel({
1103
1089
  queryKey: ["shared-browser", "capture", viewerToken],
1104
1090
  queryFn: async () => {
1105
1091
  if (!viewerToken) throw new Error("The browser viewer is not attached.");
1106
- const mutationEpoch = mutationEpochRef.current;
1107
- const knownFrame = frameRef.current;
1092
+ const mutationEpoch = lifecycle.epoch;
1093
+ const knownFrame = lifecycle.frame;
1108
1094
  captureInFlightRef.current = true;
1109
1095
  try {
1110
1096
  const result = await captureBrowser({
@@ -1130,17 +1116,14 @@ export function SharedBrowserPanel({
1130
1116
 
1131
1117
  useEffect(() => {
1132
1118
  const result = captureQuery.data;
1133
- if (
1134
- !result ||
1135
- result.mutationEpoch !== mutationEpochRef.current ||
1136
- !acceptState(result.state)
1137
- ) {
1119
+ if (!result || !lifecycle.isCurrent(result.mutationEpoch) || !acceptState(result.state)) {
1138
1120
  return;
1139
1121
  }
1140
1122
  if (result.frame && isFrameCurrent(result.frame, result.state)) {
1141
- frameRef.current = result.frame;
1123
+ lifecycle.accept(result.mutationEpoch, result.frame);
1142
1124
  setImageError(false);
1143
- setFrame(result.frame);
1125
+ setFrame(lifecycle.visible);
1126
+ setInputBusy(lifecycle.busy);
1144
1127
  }
1145
1128
  }, [acceptState, captureQuery.data]);
1146
1129
 
@@ -1229,6 +1212,9 @@ export function SharedBrowserPanel({
1229
1212
  onSuccess: (result) => mutationSucceeded(result.state),
1230
1213
  onError: mutationFailed,
1231
1214
  });
1215
+ const settleInput = () => {
1216
+ lifecycle.settle();
1217
+ };
1232
1218
  const inputMutation = useMutation({
1233
1219
  mutationFn: sendBrowserInput,
1234
1220
  retry: false,
@@ -1237,9 +1223,13 @@ export function SharedBrowserPanel({
1237
1223
  const sentText = variables.event.text;
1238
1224
  setTypeDraft((current) => (current === sentText ? "" : current));
1239
1225
  }
1226
+ settleInput();
1240
1227
  mutationSucceeded(result.state);
1241
1228
  },
1242
- onError: mutationFailed,
1229
+ onError: (error) => {
1230
+ settleInput();
1231
+ mutationFailed(error);
1232
+ },
1243
1233
  });
1244
1234
 
1245
1235
  const anyMutationPending =
@@ -1251,18 +1241,37 @@ export function SharedBrowserPanel({
1251
1241
  inputMutation.isPending;
1252
1242
  const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1253
1243
  const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1254
- const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
1244
+ const canSendInput =
1245
+ canControl && Boolean(currentFrame) && !inputMutation.isPending && !inputBusy;
1255
1246
 
1247
+ const frameUri = useMemo(
1248
+ () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1249
+ [currentFrame],
1250
+ );
1251
+ const [imageSize, setImageSize] = useState<{ uri: string; size: Size } | null>(null);
1252
+ const loadImageSize = useMemo(
1253
+ () =>
1254
+ createImageSizeLoader(
1255
+ (uri, success, failure) => Image.getSize(uri, success, failure),
1256
+ (uri, size) => setImageSize(size ? { uri, size } : null),
1257
+ ),
1258
+ [],
1259
+ );
1260
+ useEffect(() => {
1261
+ if (frameUri) loadImageSize(frameUri);
1262
+ }, [frameUri, loadImageSize]);
1263
+ // The bitmap can disagree with the frame metadata; its own aspect ratio decides the rectangle.
1256
1264
  const displayRect = useMemo(
1257
1265
  () =>
1258
1266
  currentFrame
1259
- ? containedRect(containerSize, { width: currentFrame.width, height: currentFrame.height })
1267
+ ? containedRect(
1268
+ containerSize,
1269
+ imageSize?.uri === frameUri
1270
+ ? imageSize.size
1271
+ : { width: currentFrame.width, height: currentFrame.height },
1272
+ )
1260
1273
  : null,
1261
- [containerSize, currentFrame],
1262
- );
1263
- const frameUri = useMemo(
1264
- () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1265
- [currentFrame],
1274
+ [containerSize, currentFrame, frameUri, imageSize],
1266
1275
  );
1267
1276
 
1268
1277
  const controlContext = useCallback(() => {
@@ -1283,7 +1292,7 @@ export function SharedBrowserPanel({
1283
1292
  const inputContext = useCallback(() => {
1284
1293
  const context = controlContext();
1285
1294
  const current = stateRef.current;
1286
- const targetFrame = frameRef.current;
1295
+ const targetFrame = lifecycle.frame;
1287
1296
  if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
1288
1297
  return {
1289
1298
  ...context,
@@ -1316,12 +1325,13 @@ export function SharedBrowserPanel({
1316
1325
 
1317
1326
  const sendEvent = useCallback(
1318
1327
  (event: BrowserInputEvent) => {
1328
+ if (lifecycle.busy) return;
1319
1329
  const context = requireInputContext();
1320
- if (!context || inputMutation.isPending) return;
1321
- mutationEpochRef.current += 1;
1330
+ if (!context || !lifecycle.begin()) return;
1331
+ setInputBusy(true);
1322
1332
  inputMutation.mutate({ ...context, event });
1323
1333
  },
1324
- [inputMutation, requireInputContext],
1334
+ [inputMutation, lifecycle, requireInputContext],
1325
1335
  );
1326
1336
 
1327
1337
  const pointFromEvent = useCallback(
@@ -1329,7 +1339,7 @@ export function SharedBrowserPanel({
1329
1339
  if (!displayRect) return null;
1330
1340
  const x = Math.min(displayRect.width, Math.max(0, event.nativeEvent.locationX));
1331
1341
  const y = Math.min(displayRect.height, Math.max(0, event.nativeEvent.locationY));
1332
- return { x, y, width: displayRect.width, height: displayRect.height };
1342
+ return { x, y };
1333
1343
  },
1334
1344
  [displayRect],
1335
1345
  );
@@ -1338,17 +1348,18 @@ export function SharedBrowserPanel({
1338
1348
  (event: GestureResponderEvent) => {
1339
1349
  const start = dragStartRef.current;
1340
1350
  const end = pointFromEvent(event);
1351
+ const viewport = stateRef.current?.viewport;
1341
1352
  dragStartRef.current = null;
1342
- if (!start || !end) return;
1353
+ if (!start || !end || !displayRect || !viewport) return;
1354
+ const toViewport = (point: DisplayPoint) => toViewportPoint(point, displayRect, viewport);
1343
1355
  lastPointRef.current = { x: end.x, y: end.y };
1344
1356
  const distance = Math.hypot(end.x - start.x, end.y - start.y);
1345
1357
  if (distance >= DRAG_THRESHOLD) {
1346
1358
  if (swipeMode === "scroll") {
1347
- const current = stateRef.current;
1348
- const scale = current ? current.viewport.width / end.width : 1;
1359
+ const scale = viewport.width / displayRect.width;
1349
1360
  sendEvent({
1350
1361
  kind: "scroll",
1351
- point: { ...start, width: end.width, height: end.height },
1362
+ point: toViewport(start),
1352
1363
  deltaX: clampScrollDelta((start.x - end.x) * scale),
1353
1364
  deltaY: clampScrollDelta((start.y - end.y) * scale),
1354
1365
  });
@@ -1356,20 +1367,20 @@ export function SharedBrowserPanel({
1356
1367
  }
1357
1368
  sendEvent({
1358
1369
  kind: "drag",
1359
- start: { ...start, width: end.width, height: end.height },
1360
- end,
1370
+ start: toViewport(start),
1371
+ end: toViewport(end),
1361
1372
  button: interactionMode === "right" ? "right" : "left",
1362
1373
  });
1363
1374
  return;
1364
1375
  }
1365
1376
  sendEvent({
1366
1377
  kind: "click",
1367
- point: end,
1378
+ point: toViewport(end),
1368
1379
  button: interactionMode === "right" ? "right" : "left",
1369
1380
  clickCount: interactionMode === "double" ? 2 : 1,
1370
1381
  });
1371
1382
  },
1372
- [interactionMode, pointFromEvent, sendEvent, swipeMode],
1383
+ [displayRect, interactionMode, pointFromEvent, sendEvent, swipeMode],
1373
1384
  );
1374
1385
 
1375
1386
  const panResponder = useMemo(
@@ -1400,7 +1411,7 @@ export function SharedBrowserPanel({
1400
1411
  const takeControl = useCallback(
1401
1412
  (takeover: boolean) => {
1402
1413
  if (!viewerToken || acquireMutation.isPending) return;
1403
- mutationEpochRef.current += 1;
1414
+ lifecycle.bump();
1404
1415
  acquireMutation.mutate({ viewerToken, takeover });
1405
1416
  },
1406
1417
  [acquireMutation, viewerToken],
@@ -1415,7 +1426,7 @@ export function SharedBrowserPanel({
1415
1426
  ) {
1416
1427
  return;
1417
1428
  }
1418
- mutationEpochRef.current += 1;
1429
+ lifecycle.bump();
1419
1430
  releaseMutation.mutate({ viewerToken: viewer, controlToken });
1420
1431
  }, [controlToken, releaseMutation]);
1421
1432
 
@@ -1429,11 +1440,11 @@ export function SharedBrowserPanel({
1429
1440
  setOperationError("Enter an address to navigate.");
1430
1441
  return;
1431
1442
  }
1432
- mutationEpochRef.current += 1;
1443
+ lifecycle.bump();
1433
1444
  navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1434
1445
  return;
1435
1446
  }
1436
- mutationEpochRef.current += 1;
1447
+ lifecycle.bump();
1437
1448
  navigateMutation.mutate({ ...context, action: { kind: action } });
1438
1449
  },
1439
1450
  [navigateMutation, requireControlContext],
@@ -1457,7 +1468,7 @@ export function SharedBrowserPanel({
1457
1468
  );
1458
1469
  return;
1459
1470
  }
1460
- mutationEpochRef.current += 1;
1471
+ lifecycle.bump();
1461
1472
  resizeMutation.mutate({ ...context, viewport: { width, height } });
1462
1473
  }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1463
1474
 
@@ -1465,7 +1476,7 @@ export function SharedBrowserPanel({
1465
1476
  (presetId: DevicePresetId) => {
1466
1477
  const context = requireControlContext();
1467
1478
  if (!context || deviceMutation.isPending) return;
1468
- mutationEpochRef.current += 1;
1479
+ lifecycle.bump();
1469
1480
  setDevicePickerOpen(false);
1470
1481
  deviceMutation.mutate({ ...context, presetId });
1471
1482
  },
@@ -1474,13 +1485,14 @@ export function SharedBrowserPanel({
1474
1485
 
1475
1486
  const scroll = useCallback(
1476
1487
  (deltaX: number, deltaY: number) => {
1477
- if (!displayRect) return;
1488
+ const viewport = stateRef.current?.viewport;
1489
+ if (!displayRect || !viewport) return;
1478
1490
  const previous = lastPointRef.current;
1479
1491
  const x = previous && previous.x <= displayRect.width ? previous.x : displayRect.width / 2;
1480
1492
  const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
1481
1493
  sendEvent({
1482
1494
  kind: "scroll",
1483
- point: { x, y, width: displayRect.width, height: displayRect.height },
1495
+ point: toViewportPoint({ x, y }, displayRect, viewport),
1484
1496
  deltaX,
1485
1497
  deltaY,
1486
1498
  });
@@ -1495,7 +1507,7 @@ export function SharedBrowserPanel({
1495
1507
 
1496
1508
  const reconnect = useCallback(() => {
1497
1509
  if (attachQuery.isFetching) return;
1498
- mutationEpochRef.current += 1;
1510
+ lifecycle.bump();
1499
1511
  setReconnecting(true);
1500
1512
  setControlToken(null);
1501
1513
  setOperationError(null);
@@ -1700,7 +1712,7 @@ export function SharedBrowserPanel({
1700
1712
  }
1701
1713
  accessibilityRole="image"
1702
1714
  onError={() => {
1703
- frameRef.current = null;
1715
+ lifecycle.frame = null;
1704
1716
  setImageError(true);
1705
1717
  void captureQuery.refetch({ cancelRefetch: false });
1706
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.204.1",
3
+ "version": "1.1.0-next.211.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",
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",
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",
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",
48
48
  "test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
49
49
  },
50
50
  "dependencies": {
package/shared/browser.ts CHANGED
@@ -365,3 +365,70 @@ export function mapDisplayedPoint(
365
365
  y: Math.min(viewport.height - 1, Math.max(0, (point.y / point.height) * viewport.height)),
366
366
  };
367
367
  }
368
+
369
+ export interface FrameSize {
370
+ width: number;
371
+ height: number;
372
+ }
373
+
374
+ export interface FrameRect extends FrameSize {
375
+ x: number;
376
+ y: number;
377
+ }
378
+
379
+ export function containedRect(container: FrameSize, image: FrameSize): FrameRect | null {
380
+ if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
381
+ return null;
382
+ }
383
+ const scale = Math.min(container.width / image.width, container.height / image.height);
384
+ const width = image.width * scale;
385
+ const height = image.height * scale;
386
+ return {
387
+ x: (container.width - width) / 2,
388
+ y: (container.height - height) / 2,
389
+ width,
390
+ height,
391
+ };
392
+ }
393
+
394
+ /** Converts a point in the displayed image to the canonical viewport, scaling both axes by viewport.width / rect.width. */
395
+ export function toViewportPoint(
396
+ point: MappedPoint,
397
+ rect: FrameSize,
398
+ viewport: Viewport,
399
+ ): MappedPoint & FrameSize {
400
+ const scale = viewport.width / rect.width;
401
+ return {
402
+ x: point.x * scale,
403
+ y: Math.min(viewport.height, point.y * scale),
404
+ width: viewport.width,
405
+ height: viewport.height,
406
+ };
407
+ }
408
+
409
+ type GetImageSize = (
410
+ uri: string,
411
+ success: (width: number, height: number) => void,
412
+ failure: () => void,
413
+ ) => void;
414
+
415
+ /** Looks up the intrinsic size once per distinct URI and drops results superseded by a newer URI. */
416
+ export function createImageSizeLoader(
417
+ getSize: GetImageSize,
418
+ onSize: (uri: string, size: FrameSize | null) => void,
419
+ ): (uri: string) => void {
420
+ let current: string | null = null;
421
+ return (uri) => {
422
+ if (uri === current) return;
423
+ current = uri;
424
+ getSize(
425
+ uri,
426
+ (width, height) => {
427
+ if (current === uri) onSize(uri, { width, height });
428
+ },
429
+ () => {
430
+ if (current === uri) onSize(uri, null);
431
+ },
432
+ );
433
+ };
434
+ }