@omercnet/paseo-shared-browser 1.1.0-next.210.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.
- package/client/browser.tsx +37 -28
- package/client/frame-lifecycle.ts +65 -0
- package/package.json +3 -3
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.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 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",
|
|
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": {
|