@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.
- package/client/browser.tsx +37 -28
- package/client/frame-lifecycle.ts +65 -0
- package/package.json +3 -3
- package/server/agent-browser-runtime.ts +82 -33
package/client/browser.tsx
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1090
|
-
const knownFrame =
|
|
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
|
-
|
|
1123
|
+
lifecycle.accept(result.mutationEpoch, result.frame);
|
|
1125
1124
|
setImageError(false);
|
|
1126
|
-
setFrame(
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1443
|
+
lifecycle.bump();
|
|
1435
1444
|
navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
|
|
1436
1445
|
return;
|
|
1437
1446
|
}
|
|
1438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
889
|
+
await this.preparePage(replacement);
|
|
841
890
|
if (this.page === previous) {
|
|
842
891
|
this.page = replacement;
|
|
843
892
|
this.targetId = target.targetId;
|