@omercnet/paseo-shared-browser 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/client/browser.tsx +78 -66
- package/client/frame-lifecycle.ts +65 -0
- package/client/sidebar.tsx +134 -0
- package/index.client.tsx +7 -1
- package/index.server.ts +5 -1
- package/package.json +4 -4
- package/paseo-plugin.json +1 -1
- package/server/agent-browser-runtime.ts +82 -33
- package/shared/browser.ts +67 -0
package/README.md
CHANGED
|
@@ -93,15 +93,19 @@ Before upgrading the plugin on Windows, close active Shared Browser sessions and
|
|
|
93
93
|
daemon; Windows does not allow the installer to replace runtime executables that are still running.
|
|
94
94
|
|
|
95
95
|
Open a workspace, search the Command Center for **Open Shared Browser**, or tap the **Shared
|
|
96
|
-
Browser** composer pill while a workspace session is open.
|
|
96
|
+
Browser** composer pill while a workspace session is open. On Paseo 0.11 or newer, a **Shared
|
|
97
|
+
Browser (n)** row in the sidebar footer appears while browser sessions are open; it lists them and
|
|
98
|
+
jumps to the chosen workspace's browser panel.
|
|
97
99
|
|
|
98
100
|
## Agent MCP access
|
|
99
101
|
|
|
100
102
|
The plugin automatically injects its stdio MCP adapter only when a new, non-internal agent is
|
|
101
103
|
created with a provider that accepts external MCP servers. Agents that already exist, resumed
|
|
102
|
-
sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's OMP
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's built-in OMP
|
|
105
|
+
provider accepts session MCP servers from Paseo 0.11, so new OMP agents receive the adapter there.
|
|
106
|
+
On Paseo 0.9 and 0.10 the built-in OMP adapter rejects external MCP servers, so OMP agents are left
|
|
107
|
+
unchanged. Pi agents continue to receive the adapter, but they require Pi's optional MCP support to
|
|
108
|
+
launch it.
|
|
105
109
|
|
|
106
110
|
The injected MCP server exposes exactly these tools: `shared_browser_status`,
|
|
107
111
|
`shared_browser_capture`, `shared_browser_acquire_control`, `shared_browser_release_control`,
|
|
@@ -180,5 +184,5 @@ persistence, and archive teardown.
|
|
|
180
184
|
Release Please maintains the version, changelog, component tag, and GitHub release from
|
|
181
185
|
Conventional Commits in the monorepo.
|
|
182
186
|
|
|
183
|
-
Both the Paseo daemon and app must satisfy `^0.9.0 || ^0.10.0`. The client surface uses React Native primitives
|
|
187
|
+
Both the Paseo daemon and app must satisfy `^0.9.0 || ^0.10.0 || ^0.11.0`. The client surface uses React Native primitives
|
|
184
188
|
and works in desktop, web, iOS, and Android Paseo clients.
|
package/client/browser.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1107
|
-
const knownFrame =
|
|
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
|
-
|
|
1123
|
+
lifecycle.accept(result.mutationEpoch, result.frame);
|
|
1142
1124
|
setImageError(false);
|
|
1143
|
-
setFrame(
|
|
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:
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 ||
|
|
1321
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1443
|
+
lifecycle.bump();
|
|
1433
1444
|
navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
|
|
1434
1445
|
return;
|
|
1435
1446
|
}
|
|
1436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginClientContext,
|
|
3
|
+
type PluginPopoverProps,
|
|
4
|
+
type PluginSidebarItemProps,
|
|
5
|
+
useRpc,
|
|
6
|
+
useWorkspace,
|
|
7
|
+
} from "@getpaseo/plugin/client";
|
|
8
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
9
|
+
import { useQuery } from "@tanstack/react-query";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
12
|
+
import { listOpenBrowserWorkspacesRpc } from "../shared/browser";
|
|
13
|
+
|
|
14
|
+
const PANEL_ID = "shared-browser";
|
|
15
|
+
const PRESENCE_POLL_MS = 5_000;
|
|
16
|
+
const TOUCH_TARGET = 44;
|
|
17
|
+
const ROW_HEIGHT = 36;
|
|
18
|
+
const RADIUS = 8;
|
|
19
|
+
|
|
20
|
+
function useOpenBrowserWorkspaceIds(): readonly string[] {
|
|
21
|
+
const listOpen = useRpc(listOpenBrowserWorkspacesRpc);
|
|
22
|
+
const query = useQuery({
|
|
23
|
+
queryKey: ["shared-browser", "presence"],
|
|
24
|
+
queryFn: async () => (await listOpen({})).workspaceIds,
|
|
25
|
+
refetchInterval: PRESENCE_POLL_MS,
|
|
26
|
+
retry: false,
|
|
27
|
+
});
|
|
28
|
+
return query.data ?? [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SessionRow({
|
|
32
|
+
workspaceId,
|
|
33
|
+
theme,
|
|
34
|
+
compact,
|
|
35
|
+
onPress,
|
|
36
|
+
}: {
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
theme: PluginPopoverProps["theme"];
|
|
39
|
+
compact: boolean;
|
|
40
|
+
onPress(): void;
|
|
41
|
+
}) {
|
|
42
|
+
const name = useWorkspace(workspaceId, (workspace) => workspace.title ?? workspace.name);
|
|
43
|
+
const styles = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
StyleSheet.create({
|
|
46
|
+
row: {
|
|
47
|
+
minHeight: compact ? TOUCH_TARGET : ROW_HEIGHT,
|
|
48
|
+
justifyContent: "center",
|
|
49
|
+
paddingHorizontal: 12,
|
|
50
|
+
borderRadius: RADIUS,
|
|
51
|
+
},
|
|
52
|
+
label: { color: theme.colors.foreground, fontSize: 14 },
|
|
53
|
+
}),
|
|
54
|
+
[theme, compact],
|
|
55
|
+
);
|
|
56
|
+
// A workspace this client has not loaded cannot host the panel; openPanel would throw.
|
|
57
|
+
if (name === null) return null;
|
|
58
|
+
return (
|
|
59
|
+
<Pressable
|
|
60
|
+
accessibilityRole="button"
|
|
61
|
+
accessibilityLabel={`Open Shared Browser for ${name}`}
|
|
62
|
+
onPress={onPress}
|
|
63
|
+
style={({ pressed }) => [
|
|
64
|
+
styles.row,
|
|
65
|
+
pressed ? { backgroundColor: theme.colors.surface1 } : null,
|
|
66
|
+
]}
|
|
67
|
+
>
|
|
68
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
69
|
+
{name}
|
|
70
|
+
</Text>
|
|
71
|
+
</Pressable>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createSessionsPopover(client: PluginClientContext) {
|
|
76
|
+
return function SessionsPopover({ theme, layout, close }: PluginPopoverProps) {
|
|
77
|
+
const workspaceIds = useOpenBrowserWorkspaceIds();
|
|
78
|
+
const styles = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
StyleSheet.create({
|
|
81
|
+
container: { padding: layout.compact ? 16 : 8, gap: 4, minWidth: 220 },
|
|
82
|
+
title: {
|
|
83
|
+
color: theme.colors.foregroundMuted,
|
|
84
|
+
fontSize: 12,
|
|
85
|
+
paddingHorizontal: 12,
|
|
86
|
+
paddingBottom: 4,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
[theme, layout.compact],
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<View style={styles.container}>
|
|
93
|
+
<Text style={styles.title}>Open browser sessions</Text>
|
|
94
|
+
{workspaceIds.map((workspaceId) => (
|
|
95
|
+
<SessionRow
|
|
96
|
+
key={workspaceId}
|
|
97
|
+
workspaceId={workspaceId}
|
|
98
|
+
theme={theme}
|
|
99
|
+
compact={layout.compact}
|
|
100
|
+
onPress={() => {
|
|
101
|
+
close();
|
|
102
|
+
client.openPanel(PANEL_ID, { workspaceId });
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createSidebarItem(client: PluginClientContext) {
|
|
112
|
+
const SessionsPopover = createSessionsPopover(client);
|
|
113
|
+
return function SharedBrowserSidebarItem({ openPopover }: PluginSidebarItemProps) {
|
|
114
|
+
const count = useOpenBrowserWorkspaceIds().length;
|
|
115
|
+
if (count === 0) return null;
|
|
116
|
+
return (
|
|
117
|
+
<SidebarRow
|
|
118
|
+
icon="PanelsTopLeft"
|
|
119
|
+
label={`Shared Browser (${count})`}
|
|
120
|
+
onPress={() => openPopover(SessionsPopover)}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
|
|
127
|
+
export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
|
|
128
|
+
if (typeof client.addSidebarFooterItem !== "function") return () => {};
|
|
129
|
+
return client.addSidebarFooterItem({
|
|
130
|
+
id: "shared-browser-sessions",
|
|
131
|
+
title: "Shared Browser",
|
|
132
|
+
Component: createSidebarItem(client),
|
|
133
|
+
});
|
|
134
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
2
|
import { contributeSharedBrowserClient, SharedBrowserPanel } from "./client/browser";
|
|
3
|
+
import { contributeSharedBrowserSidebar } from "./client/sidebar";
|
|
3
4
|
|
|
4
5
|
export default function contribute(client: PluginClientContext) {
|
|
5
6
|
client.addWorkspacePanel({
|
|
@@ -31,5 +32,10 @@ export default function contribute(client: PluginClientContext) {
|
|
|
31
32
|
},
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const removeSidebar = contributeSharedBrowserSidebar(client);
|
|
36
|
+
const cleanup = contributeSharedBrowserClient(client);
|
|
37
|
+
return () => {
|
|
38
|
+
removeSidebar();
|
|
39
|
+
return cleanup();
|
|
40
|
+
};
|
|
35
41
|
}
|
package/index.server.ts
CHANGED
|
@@ -77,8 +77,12 @@ export default function contribute(server: PluginServerContext) {
|
|
|
77
77
|
server.handle(resizeBrowserRpc, handleResizeBrowser);
|
|
78
78
|
server.handle(applyDevicePresetRpc, handleApplyDevicePreset);
|
|
79
79
|
server.handle(sendBrowserInputRpc, handleSendBrowserInput);
|
|
80
|
+
// OMP accepts session MCP servers from Paseo 0.11.0-beta.1, the release that added
|
|
81
|
+
// registerUsageSource; 0.9/0.10 reject agent creation when an OMP config carries MCP servers.
|
|
82
|
+
const ompAcceptsMcp = typeof server.registerUsageSource === "function";
|
|
80
83
|
server.before("agent.create", async ({ request }) => {
|
|
81
|
-
if (request.config.internal
|
|
84
|
+
if (request.config.internal) return request;
|
|
85
|
+
if (request.config.provider === "omp" && !ompAcceptsMcp) return request;
|
|
82
86
|
const ticket = randomBytes(32).toString("base64url");
|
|
83
87
|
if (!(await issueTicketWithinDeadline(ticket))) return request;
|
|
84
88
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -43,12 +43,12 @@
|
|
|
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",
|
|
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",
|
|
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": {
|
|
51
|
-
"@getpaseo/plugin": "0.
|
|
51
|
+
"@getpaseo/plugin": "0.11.0-beta.3",
|
|
52
52
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
53
53
|
"agent-browser": "0.37.1",
|
|
54
54
|
"esbuild": "0.28.2",
|
package/paseo-plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shared-browser",
|
|
3
3
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
4
|
-
"requirements": { "paseo": "^0.9.0 || ^0.10.0" },
|
|
4
|
+
"requirements": { "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0" },
|
|
5
5
|
"build": [["node", "scripts/prepare-dependencies.mjs"], ["npm", "run", "prepare:runtime"]]
|
|
6
6
|
}
|
|
@@ -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;
|
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
|
+
}
|