@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.237.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/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/package.json +9 -5
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
package/server/browser-policy.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
3
4
|
import {
|
|
4
5
|
type applyDevicePresetRpc,
|
|
5
6
|
type BrowserFrame,
|
|
7
|
+
type BrowserGestureEvent,
|
|
6
8
|
type BrowserInputEvent,
|
|
7
9
|
type BrowserState,
|
|
10
|
+
type BrowserTab,
|
|
11
|
+
type beginBrowserGestureRpc,
|
|
12
|
+
browserCursorSchema,
|
|
13
|
+
browserGestureEventSchema,
|
|
14
|
+
browserTabSchema,
|
|
15
|
+
canUseCaptureDensity,
|
|
16
|
+
type captureBrowserRpc,
|
|
17
|
+
type closeBrowserRpc,
|
|
8
18
|
DEFAULT_VIEWPORT,
|
|
9
19
|
DEVICE_PRESETS,
|
|
10
20
|
type DevicePresetId,
|
|
21
|
+
type endBrowserGestureRpc,
|
|
11
22
|
FRAME_MAX_BYTES,
|
|
12
23
|
MAX_VIEWPORT,
|
|
13
24
|
MIN_VIEWPORT,
|
|
@@ -15,38 +26,71 @@ import {
|
|
|
15
26
|
type navigateBrowserRpc,
|
|
16
27
|
type resizeBrowserRpc,
|
|
17
28
|
type sendBrowserInputRpc,
|
|
29
|
+
type setCaptureDensityRpc,
|
|
30
|
+
type updateBrowserGestureRpc,
|
|
18
31
|
type Viewport,
|
|
19
32
|
} from "../shared/browser";
|
|
20
|
-
import
|
|
33
|
+
import {
|
|
34
|
+
type BrowserFrameAuthority,
|
|
35
|
+
type BrowserVideoReadInput,
|
|
36
|
+
type BrowserVideoReadReply,
|
|
37
|
+
readBrowserVideoRpc,
|
|
38
|
+
VIDEO_MAX_BATCH_BYTES,
|
|
39
|
+
VIDEO_SOURCE_CLOCK_TOLERANCE_MS,
|
|
40
|
+
} from "../shared/browser-video";
|
|
41
|
+
import {
|
|
42
|
+
type CaptureQuality,
|
|
43
|
+
captureDimensions,
|
|
44
|
+
DEFAULT_CAPTURE_QUALITY,
|
|
45
|
+
JPEG_QUALITY,
|
|
46
|
+
} from "../shared/capture-settings";
|
|
47
|
+
import { frameMatchesBrowserContext } from "./browser-frame-context";
|
|
48
|
+
import { createBrowserFrameRejection } from "./browser-frame-rejection";
|
|
49
|
+
import { BrowserGesture } from "./browser-gesture";
|
|
50
|
+
import { runWithInputCleanup } from "./input-cleanup";
|
|
51
|
+
import { sameRuntimeInputAttachment } from "./input-generation";
|
|
52
|
+
import { runtimeVideoPacketSchema } from "./native-video-packet";
|
|
53
|
+
import { NAVIGATION_METADATA_TIMEOUT_MS } from "./navigation-budget";
|
|
54
|
+
import { type JsonValue, RuntimeProtocolError } from "./runtime-protocol";
|
|
55
|
+
import { createViewerCaptureLifetime, type ViewerCaptureLifetime } from "./viewer-capture-lifetime";
|
|
21
56
|
export type WorkspaceValidator = (workspaceId: string) => Promise<void | boolean>;
|
|
57
|
+
type CaptureReply = Omit<RpcOutput<typeof captureBrowserRpc>, "state"> & { state: BrowserState };
|
|
58
|
+
type DensityInput = RpcInput<typeof setCaptureDensityRpc>;
|
|
59
|
+
type CloseInput = RpcInput<typeof closeBrowserRpc>;
|
|
22
60
|
type NavigateInput = RpcInput<typeof navigateBrowserRpc>;
|
|
23
61
|
type ResizeInput = RpcInput<typeof resizeBrowserRpc>;
|
|
24
62
|
type ApplyDevicePresetInput = RpcInput<typeof applyDevicePresetRpc>;
|
|
25
63
|
type SendInput = RpcInput<typeof sendBrowserInputRpc>;
|
|
26
|
-
type
|
|
27
|
-
type
|
|
64
|
+
type BeginGestureInput = RpcInput<typeof beginBrowserGestureRpc>;
|
|
65
|
+
type UpdateGestureInput = RpcInput<typeof updateBrowserGestureRpc>;
|
|
66
|
+
type UpdateGestureReply = Omit<RpcOutput<typeof updateBrowserGestureRpc>, "state"> & {
|
|
67
|
+
state: BrowserState;
|
|
68
|
+
};
|
|
69
|
+
type EndGestureInput = RpcInput<typeof endBrowserGestureRpc>;
|
|
28
70
|
|
|
29
71
|
const VIEWER_TTL_MS = 45_000;
|
|
30
72
|
const CONTROL_LEASE_MS = 30_000;
|
|
31
73
|
const FRAME_CACHE_MS = 100;
|
|
32
74
|
const FRAME_TOKEN_TTL_MS = 5_000;
|
|
33
|
-
const MAX_RECENT_FRAMES =
|
|
75
|
+
const MAX_RECENT_FRAMES = 512;
|
|
34
76
|
const MAX_VIEWERS_PER_SESSION = 16;
|
|
35
|
-
const MAX_SESSIONS =
|
|
36
|
-
const SCREENCAST_QUALITY = 65;
|
|
37
|
-
const RUNTIME_FRAME_MAX_BYTES = 750_000;
|
|
77
|
+
const MAX_SESSIONS = 64;
|
|
38
78
|
const SCREENCAST_WAIT_MS = 500;
|
|
39
|
-
const JPEG_QUALITY = { low: 40, medium: 65, high: 85 } as const;
|
|
40
79
|
|
|
41
80
|
export interface BrowserRuntimeClient {
|
|
42
81
|
connect(): Promise<{ epoch: number }>;
|
|
43
|
-
|
|
82
|
+
/** `replaceLost` is internal authority; without it a lost runtime is reported, never replaced. */
|
|
83
|
+
ensureWorkspace(
|
|
84
|
+
workspaceId: string,
|
|
85
|
+
options?: { replaceLost?: boolean },
|
|
86
|
+
): Promise<{
|
|
44
87
|
workspaceId: string;
|
|
45
88
|
runtimeId: string;
|
|
46
89
|
createdAt: number;
|
|
47
90
|
}>;
|
|
48
91
|
requestWorkspace(workspaceId: string, operation: string, input: JsonValue): Promise<JsonValue>;
|
|
49
92
|
archiveWorkspace(workspaceId: string): Promise<void>;
|
|
93
|
+
closeWorkspace(workspaceId: string, runtimeId: string): Promise<void>;
|
|
50
94
|
disconnect(): void;
|
|
51
95
|
}
|
|
52
96
|
|
|
@@ -54,6 +98,8 @@ export interface SessionManagerOptions {
|
|
|
54
98
|
validateWorkspace: WorkspaceValidator;
|
|
55
99
|
client: BrowserRuntimeClient;
|
|
56
100
|
now?: () => number;
|
|
101
|
+
/** Same process-local elapsed clock as native capture, injectable for deterministic tests. */
|
|
102
|
+
monotonicNow?: () => number;
|
|
57
103
|
issueToken?: () => string;
|
|
58
104
|
viewerTtlMs?: number;
|
|
59
105
|
controlLeaseMs?: number;
|
|
@@ -71,12 +117,15 @@ interface Controller {
|
|
|
71
117
|
expiresAt: number;
|
|
72
118
|
}
|
|
73
119
|
interface BrowserSession {
|
|
120
|
+
key: string;
|
|
74
121
|
workspaceId: string;
|
|
122
|
+
tabId: string | null;
|
|
75
123
|
sessionId: string;
|
|
76
124
|
runtimeId: string;
|
|
77
125
|
runtimeCreatedAt: number;
|
|
78
126
|
bridgeEpoch: number;
|
|
79
127
|
viewport: Viewport;
|
|
128
|
+
captureScale: number;
|
|
80
129
|
navigationGeneration: number;
|
|
81
130
|
viewportGeneration: number;
|
|
82
131
|
devicePresetId: DevicePresetId | null;
|
|
@@ -85,17 +134,43 @@ interface BrowserSession {
|
|
|
85
134
|
viewers: Map<string, Viewer>;
|
|
86
135
|
controller: Controller | null;
|
|
87
136
|
mutationTail: Promise<void>;
|
|
88
|
-
frameCache: Map<
|
|
137
|
+
frameCache: Map<
|
|
138
|
+
CaptureQuality,
|
|
139
|
+
{
|
|
140
|
+
frame: BrowserFrame;
|
|
141
|
+
cachedAt: number;
|
|
142
|
+
/** Exact runtime receipt, distinct from its normalized public frame fields. */
|
|
143
|
+
rawFrame: Record<string, JsonValue>;
|
|
144
|
+
}
|
|
145
|
+
>;
|
|
89
146
|
recentFrames: Map<
|
|
90
147
|
string,
|
|
91
|
-
{
|
|
148
|
+
{
|
|
149
|
+
navigationGeneration: number;
|
|
150
|
+
viewportGeneration: number;
|
|
151
|
+
expiresAt: number;
|
|
152
|
+
expiresAtMonotonicMs?: number;
|
|
153
|
+
}
|
|
92
154
|
>;
|
|
93
155
|
lastUrl: string;
|
|
94
156
|
lastTitle: string;
|
|
95
157
|
canGoBack: boolean;
|
|
96
158
|
canGoForward: boolean;
|
|
97
159
|
error: string | null;
|
|
160
|
+
notice: string | null;
|
|
161
|
+
/** Set when the runtime reported it can never serve again; the next explicit attach replaces it. */
|
|
162
|
+
runtimeLost: boolean;
|
|
98
163
|
archived: boolean;
|
|
164
|
+
gesture: BrowserGesture | null;
|
|
165
|
+
inputGeneration: string | null;
|
|
166
|
+
frameRevision: number;
|
|
167
|
+
/** Playback and input fences use the native capture's Node elapsed clock. */
|
|
168
|
+
videoNotBefore: number | null;
|
|
169
|
+
/** Older native pixels may play, but cannot renew press authority after input. */
|
|
170
|
+
videoInputNotBefore: number | null;
|
|
171
|
+
videoReads: Set<string>;
|
|
172
|
+
videoLifetime: ViewerCaptureLifetime | null;
|
|
173
|
+
videoReceipts: Map<string, { frame: BrowserFrameAuthority; capturedAtMonotonicMs: number }>;
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
function defaultToken(): string {
|
|
@@ -146,6 +221,7 @@ export class SessionManager {
|
|
|
146
221
|
private readonly validateWorkspace: WorkspaceValidator;
|
|
147
222
|
private readonly client: BrowserRuntimeClient;
|
|
148
223
|
private readonly now: () => number;
|
|
224
|
+
private readonly monotonicNow: () => number;
|
|
149
225
|
private readonly issueToken: () => string;
|
|
150
226
|
private readonly viewerTtlMs: number;
|
|
151
227
|
private readonly controlLeaseMs: number;
|
|
@@ -153,8 +229,12 @@ export class SessionManager {
|
|
|
153
229
|
private readonly maxSessions: number;
|
|
154
230
|
private readonly sessions = new Map<string, BrowserSession>();
|
|
155
231
|
private readonly viewerSessions = new Map<string, BrowserSession>();
|
|
232
|
+
private readonly closedViewerWorkspaces = new Map<string, string>();
|
|
233
|
+
private readonly closedViewerTabs = new Map<string, number>();
|
|
156
234
|
private readonly sessionCreations = new Map<string, Promise<BrowserSession>>();
|
|
235
|
+
private readonly sessionClosures = new Map<string, Promise<void>>();
|
|
157
236
|
private readonly archived = new Set<string>();
|
|
237
|
+
private readonly deliberatelyClosed = new Set<string>();
|
|
158
238
|
private bridgeEpoch = 0;
|
|
159
239
|
private lifecycleGeneration = 0;
|
|
160
240
|
private closed = false;
|
|
@@ -163,6 +243,7 @@ export class SessionManager {
|
|
|
163
243
|
this.validateWorkspace = options.validateWorkspace;
|
|
164
244
|
this.client = options.client;
|
|
165
245
|
this.now = options.now ?? Date.now;
|
|
246
|
+
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
166
247
|
this.issueToken = options.issueToken ?? defaultToken;
|
|
167
248
|
this.viewerTtlMs = options.viewerTtlMs ?? VIEWER_TTL_MS;
|
|
168
249
|
this.controlLeaseMs = options.controlLeaseMs ?? CONTROL_LEASE_MS;
|
|
@@ -179,12 +260,27 @@ export class SessionManager {
|
|
|
179
260
|
|
|
180
261
|
setBridgeEpoch(epoch: number): void {
|
|
181
262
|
this.bridgeEpoch = epoch;
|
|
182
|
-
for (const session of this.sessions.values())
|
|
263
|
+
for (const session of this.sessions.values()) {
|
|
264
|
+
if (session.bridgeEpoch !== epoch) {
|
|
265
|
+
void this.cancelGesture(session);
|
|
266
|
+
// A new bridge cannot admit a token issued by the previous attachment.
|
|
267
|
+
this.invalidateFrames(session);
|
|
268
|
+
}
|
|
269
|
+
session.bridgeEpoch = epoch;
|
|
270
|
+
}
|
|
183
271
|
}
|
|
184
272
|
|
|
273
|
+
/**
|
|
274
|
+
* `replaceLost` is internal authority held only by the supervisor's explicit human attach
|
|
275
|
+
* path. It lets this call replace a workspace runtime that reported it was lost (before or
|
|
276
|
+
* while attaching) exactly once; every other caller observes the lost session without
|
|
277
|
+
* restarting it.
|
|
278
|
+
*/
|
|
185
279
|
async attach(
|
|
186
280
|
workspaceId: string,
|
|
187
281
|
viewerLabel: string,
|
|
282
|
+
tabId?: string,
|
|
283
|
+
options: { replaceLost?: boolean } = {},
|
|
188
284
|
): Promise<{ viewerToken: string; state: BrowserState }> {
|
|
189
285
|
this.assertOpen();
|
|
190
286
|
const label = viewerLabel.trim();
|
|
@@ -192,17 +288,55 @@ export class SessionManager {
|
|
|
192
288
|
const validation = await this.validateWorkspace(workspaceId);
|
|
193
289
|
if (validation === false) throw new Error("Workspace not found");
|
|
194
290
|
this.assertOpen();
|
|
195
|
-
const
|
|
291
|
+
const replaceLost = options.replaceLost === true;
|
|
292
|
+
let session = await this.getOrCreateSession(workspaceId, tabId, replaceLost);
|
|
293
|
+
let replaced = false;
|
|
294
|
+
if (session.runtimeLost && replaceLost) {
|
|
295
|
+
session = await this.replaceLostRuntime(session);
|
|
296
|
+
replaced = true;
|
|
297
|
+
}
|
|
298
|
+
const attached = await this.attachViewer(session, label);
|
|
299
|
+
if (session.runtimeLost && replaceLost && !replaced) {
|
|
300
|
+
// The loss was first observed by this very attach. One fenced replacement, no input replay.
|
|
301
|
+
session = await this.replaceLostRuntime(session);
|
|
302
|
+
return this.attachViewer(session, label);
|
|
303
|
+
}
|
|
304
|
+
return attached;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Discards every page session of the dead workspace runtime behind fresh session and
|
|
309
|
+
* runtime ids; nothing is replayed. Page ids belong to the old browser, so the
|
|
310
|
+
* replacement attaches to the new browser's launch page.
|
|
311
|
+
*/
|
|
312
|
+
private async replaceLostRuntime(lost: BrowserSession): Promise<BrowserSession> {
|
|
313
|
+
for (const candidate of [...this.sessions.values()]) {
|
|
314
|
+
if (candidate.workspaceId !== lost.workspaceId) continue;
|
|
315
|
+
candidate.archived = true;
|
|
316
|
+
candidate.videoLifetime?.cancel();
|
|
317
|
+
await this.cancelGesture(candidate);
|
|
318
|
+
for (const token of candidate.viewers.keys()) this.viewerSessions.delete(token);
|
|
319
|
+
candidate.viewers.clear();
|
|
320
|
+
candidate.controller = null;
|
|
321
|
+
this.sessions.delete(candidate.key);
|
|
322
|
+
}
|
|
323
|
+
return this.getOrCreateSession(lost.workspaceId, undefined, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private attachViewer(
|
|
327
|
+
session: BrowserSession,
|
|
328
|
+
label: string,
|
|
329
|
+
): Promise<{ viewerToken: string; state: BrowserState }> {
|
|
196
330
|
return this.serialize(session, async () => {
|
|
331
|
+
if (session.archived) throw new Error("Browser session is closed");
|
|
197
332
|
this.pruneExpired(session);
|
|
198
333
|
if (session.viewers.size >= MAX_VIEWERS_PER_SESSION)
|
|
199
334
|
throw new Error(`Shared browser viewer limit (${MAX_VIEWERS_PER_SESSION}) reached`);
|
|
200
335
|
const viewerToken = this.issueUniqueToken();
|
|
201
336
|
session.viewers.set(viewerToken, { label, expiresAt: this.now() + this.viewerTtlMs });
|
|
202
337
|
this.viewerSessions.set(viewerToken, session);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
);
|
|
338
|
+
// Actual image reads lazily own JPEG capture. Attaching a desktop video
|
|
339
|
+
// viewer or an agent doing status-only work does not start a second stream.
|
|
206
340
|
try {
|
|
207
341
|
return { viewerToken, state: await this.snapshotState(session, viewerToken) };
|
|
208
342
|
} catch (error) {
|
|
@@ -220,64 +354,424 @@ export class SessionManager {
|
|
|
220
354
|
this.pruneExpired(session);
|
|
221
355
|
const detached = session.viewers.delete(viewerToken);
|
|
222
356
|
this.viewerSessions.delete(viewerToken);
|
|
223
|
-
if (session.controller?.viewerToken === viewerToken)
|
|
224
|
-
|
|
357
|
+
if (session.controller?.viewerToken === viewerToken) {
|
|
358
|
+
await this.cancelGesture(session);
|
|
359
|
+
session.controller = null;
|
|
360
|
+
}
|
|
361
|
+
if (session.viewers.size === 0) {
|
|
225
362
|
await this.request(session, "screencast.stop", null).catch(() => undefined);
|
|
363
|
+
await this.stopUnusedVideo(session);
|
|
364
|
+
}
|
|
226
365
|
return { detached };
|
|
227
366
|
});
|
|
228
367
|
}
|
|
229
368
|
|
|
369
|
+
/** Enumerate pages in this viewer's workspace without changing any selected page. */
|
|
370
|
+
async listTabs(workspaceId: string): Promise<{ tabs: BrowserTab[] }> {
|
|
371
|
+
if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
|
|
372
|
+
const targets = await this.client.requestWorkspace(workspaceId, "tabs.list", null);
|
|
373
|
+
if (!Array.isArray(targets)) throw new Error("Browser returned an invalid tab list");
|
|
374
|
+
const tabs = await Promise.all(
|
|
375
|
+
targets.map(async (target) => {
|
|
376
|
+
const value = asRecord(target);
|
|
377
|
+
const tabId = browserTabSchema.shape.id.parse(value.targetId);
|
|
378
|
+
const session = [...this.sessions.values()].find(
|
|
379
|
+
(candidate) => candidate.workspaceId === workspaceId && candidate.tabId === tabId,
|
|
380
|
+
);
|
|
381
|
+
const presence = session
|
|
382
|
+
? await this.serialize(session, async () => {
|
|
383
|
+
this.pruneExpired(session);
|
|
384
|
+
const controllerToken = session.controller?.viewerToken;
|
|
385
|
+
return {
|
|
386
|
+
viewerCount: session.viewers.size,
|
|
387
|
+
controllerLabel: controllerToken
|
|
388
|
+
? (session.viewers.get(controllerToken)?.label ?? null)
|
|
389
|
+
: null,
|
|
390
|
+
};
|
|
391
|
+
})
|
|
392
|
+
: { viewerCount: 0, controllerLabel: null };
|
|
393
|
+
return browserTabSchema.parse({
|
|
394
|
+
id: tabId,
|
|
395
|
+
title: boundedText(String(value.title ?? ""), 1_024),
|
|
396
|
+
url: boundedText(String(value.url ?? ""), 8_192),
|
|
397
|
+
...presence,
|
|
398
|
+
});
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
return { tabs };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Expose tab metadata to a client only through its active viewer lease. */
|
|
405
|
+
async listTabsForViewer(viewerToken: string): Promise<{ tabs: BrowserTab[] }> {
|
|
406
|
+
const session = this.requireViewer(viewerToken);
|
|
407
|
+
const listed = await this.listTabs(session.workspaceId);
|
|
408
|
+
// The lease may have lapsed while the runtime answered; never return metadata then.
|
|
409
|
+
let current = false;
|
|
410
|
+
try {
|
|
411
|
+
current = this.requireViewer(viewerToken) === session && !session.archived;
|
|
412
|
+
} catch {
|
|
413
|
+
current = false;
|
|
414
|
+
}
|
|
415
|
+
if (!current) throw new Error("Browser viewer attachment changed while listing tabs");
|
|
416
|
+
return listed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A new page shares the workspace profile but does not change any viewer's selection. */
|
|
420
|
+
async createTab(viewerToken: string): Promise<{ tabId: string }> {
|
|
421
|
+
const session = this.requireViewer(viewerToken);
|
|
422
|
+
const value = asRecord(
|
|
423
|
+
await this.client.requestWorkspace(session.workspaceId, "tabs.create", null),
|
|
424
|
+
);
|
|
425
|
+
// The page now exists. A viewer lease that ended meanwhile must not read as a
|
|
426
|
+
// pre-dispatch refusal, or a caller recovering from an expired viewer would create twice.
|
|
427
|
+
let current = false;
|
|
428
|
+
try {
|
|
429
|
+
current = this.requireViewer(viewerToken) === session && !session.archived;
|
|
430
|
+
} catch {
|
|
431
|
+
current = false;
|
|
432
|
+
}
|
|
433
|
+
if (!current) throw new Error("Browser viewer attachment changed during tab creation");
|
|
434
|
+
const tabId = browserTabSchema.shape.id.parse(value.targetId);
|
|
435
|
+
return { tabId };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Close only the page controlled by this viewer, leaving other pages and viewers alive. */
|
|
439
|
+
async closeTab(input: {
|
|
440
|
+
viewerToken: string;
|
|
441
|
+
controlToken: string;
|
|
442
|
+
tabId: string;
|
|
443
|
+
}): Promise<{ closed: true }> {
|
|
444
|
+
const session = this.requireViewer(input.viewerToken);
|
|
445
|
+
return this.serialize(session, async () => {
|
|
446
|
+
this.requireController(session, input.viewerToken, input.controlToken);
|
|
447
|
+
if (session.tabId !== input.tabId) throw new Error("Browser tab selection is stale");
|
|
448
|
+
await this.cancelGesture(session);
|
|
449
|
+
await this.request(session, "tabs.close", { targetId: input.tabId });
|
|
450
|
+
session.archived = true;
|
|
451
|
+
session.videoLifetime?.cancel();
|
|
452
|
+
for (const [token, expiresAt] of this.closedViewerTabs) {
|
|
453
|
+
if (expiresAt <= this.now()) this.closedViewerTabs.delete(token);
|
|
454
|
+
}
|
|
455
|
+
for (const token of session.viewers.keys()) {
|
|
456
|
+
this.viewerSessions.delete(token);
|
|
457
|
+
this.closedViewerTabs.set(token, this.now() + this.viewerTtlMs);
|
|
458
|
+
}
|
|
459
|
+
session.viewers.clear();
|
|
460
|
+
session.controller = null;
|
|
461
|
+
this.sessions.delete(session.key);
|
|
462
|
+
return { closed: true };
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Stop one runtime and invalidate every viewer without archiving its workspace or profile. */
|
|
467
|
+
async closeBrowser(input: CloseInput): Promise<{ closed: true }> {
|
|
468
|
+
const session = this.requireViewer(input.viewerToken);
|
|
469
|
+
const closing = this.serialize(session, async () => {
|
|
470
|
+
this.requireController(session, input.viewerToken, input.controlToken);
|
|
471
|
+
if (session.sessionId !== input.sessionId || session.runtimeId !== input.runtimeId)
|
|
472
|
+
throw new Error("Browser session is stale");
|
|
473
|
+
|
|
474
|
+
this.deliberatelyClosed.add(session.workspaceId);
|
|
475
|
+
const workspaceSessions = [...this.sessions.values()].filter(
|
|
476
|
+
(candidate) => candidate.workspaceId === session.workspaceId,
|
|
477
|
+
);
|
|
478
|
+
for (const candidate of workspaceSessions) {
|
|
479
|
+
candidate.videoLifetime?.cancel();
|
|
480
|
+
await this.cancelGesture(candidate);
|
|
481
|
+
candidate.archived = true;
|
|
482
|
+
for (const token of candidate.viewers.keys()) {
|
|
483
|
+
this.viewerSessions.delete(token);
|
|
484
|
+
this.closedViewerWorkspaces.set(token, session.workspaceId);
|
|
485
|
+
}
|
|
486
|
+
candidate.viewers.clear();
|
|
487
|
+
candidate.controller = null;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
await this.client.closeWorkspace(session.workspaceId, session.runtimeId);
|
|
491
|
+
} finally {
|
|
492
|
+
for (const candidate of workspaceSessions) this.sessions.delete(candidate.key);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
this.sessionClosures.set(session.workspaceId, closing);
|
|
496
|
+
try {
|
|
497
|
+
await closing;
|
|
498
|
+
return { closed: true };
|
|
499
|
+
} finally {
|
|
500
|
+
if (this.sessionClosures.get(session.workspaceId) === closing)
|
|
501
|
+
this.sessionClosures.delete(session.workspaceId);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Only an explicit human reopen removes the close fence used by viewer and agent attach. */
|
|
506
|
+
async reopenBrowser(workspaceId: string): Promise<{ opened: true }> {
|
|
507
|
+
this.assertOpen();
|
|
508
|
+
const validation = await this.validateWorkspace(workspaceId);
|
|
509
|
+
if (validation === false) throw new Error("Workspace not found");
|
|
510
|
+
const closing = this.sessionClosures.get(workspaceId);
|
|
511
|
+
if (closing) await closing.catch(() => undefined);
|
|
512
|
+
this.deliberatelyClosed.delete(workspaceId);
|
|
513
|
+
try {
|
|
514
|
+
await this.getOrCreateSession(workspaceId);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.deliberatelyClosed.add(workspaceId);
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
for (const [token, closedWorkspaceId] of this.closedViewerWorkspaces)
|
|
520
|
+
if (closedWorkspaceId === workspaceId) this.closedViewerWorkspaces.delete(token);
|
|
521
|
+
return { opened: true };
|
|
522
|
+
}
|
|
523
|
+
|
|
230
524
|
async archiveWorkspace(workspaceId: string): Promise<void> {
|
|
231
525
|
this.assertOpen();
|
|
232
526
|
this.archived.add(workspaceId);
|
|
233
527
|
await this.client.archiveWorkspace(workspaceId);
|
|
234
|
-
const pending = this.sessionCreations.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
session.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
528
|
+
const pending = [...this.sessionCreations.entries()]
|
|
529
|
+
.filter(([key]) => key === workspaceId || key.startsWith(`${workspaceId}/`))
|
|
530
|
+
.map(([, creation]) => creation);
|
|
531
|
+
await Promise.allSettled(pending);
|
|
532
|
+
for (const session of this.sessions.values()) {
|
|
533
|
+
if (session.workspaceId !== workspaceId) continue;
|
|
534
|
+
session.archived = true;
|
|
535
|
+
session.videoLifetime?.cancel();
|
|
536
|
+
await this.cancelGesture(session);
|
|
537
|
+
for (const token of session.viewers.keys()) this.viewerSessions.delete(token);
|
|
538
|
+
session.viewers.clear();
|
|
539
|
+
session.controller = null;
|
|
540
|
+
this.sessions.delete(session.key);
|
|
541
|
+
}
|
|
243
542
|
}
|
|
244
543
|
|
|
544
|
+
/** List retained browser sessions even after their last viewer leaves or expires. */
|
|
245
545
|
async listOpenWorkspaceIds(): Promise<string[]> {
|
|
246
546
|
this.assertOpen();
|
|
247
|
-
const result
|
|
547
|
+
const result = new Set<string>();
|
|
248
548
|
for (const session of this.sessions.values()) {
|
|
249
549
|
await this.serialize(session, async () => {
|
|
250
550
|
this.pruneExpired(session);
|
|
251
|
-
|
|
551
|
+
// Viewer leases control access and capture, not the lifetime of the browser.
|
|
552
|
+
// Keep an idle browser discoverable so another device can reattach to it.
|
|
553
|
+
if (!session.archived) result.add(session.workspaceId);
|
|
252
554
|
});
|
|
253
555
|
}
|
|
254
|
-
return result;
|
|
556
|
+
return [...result];
|
|
255
557
|
}
|
|
256
558
|
async status(viewerToken: string): Promise<{ state: BrowserState }> {
|
|
257
559
|
const session = this.requireViewer(viewerToken);
|
|
258
560
|
return this.serialize(session, async () => {
|
|
561
|
+
if (session.archived) throw new Error("Browser session is closed");
|
|
259
562
|
this.pruneExpired(session);
|
|
260
563
|
this.heartbeatViewer(session, viewerToken);
|
|
261
564
|
return { state: await this.snapshotState(session, viewerToken) };
|
|
262
565
|
});
|
|
263
566
|
}
|
|
264
567
|
|
|
568
|
+
/** A lost runtime keeps its typed code so observers can tell it from a transient failure. */
|
|
569
|
+
private metadataFailure(session: BrowserSession, error: string): Error {
|
|
570
|
+
const message = `Browser capture metadata failed: ${error}`;
|
|
571
|
+
return session.runtimeLost
|
|
572
|
+
? new RuntimeProtocolError("RUNTIME_LOST", message)
|
|
573
|
+
: new Error(message);
|
|
574
|
+
}
|
|
575
|
+
|
|
265
576
|
async capture(
|
|
266
577
|
viewerToken: string,
|
|
267
|
-
quality: CaptureQuality =
|
|
578
|
+
quality: CaptureQuality = DEFAULT_CAPTURE_QUALITY,
|
|
268
579
|
knownFrameId: string | null = null,
|
|
269
|
-
): Promise<
|
|
580
|
+
): Promise<CaptureReply> {
|
|
270
581
|
const session = this.requireViewer(viewerToken);
|
|
271
582
|
return this.serialize(session, async () => {
|
|
272
583
|
this.pruneExpired(session);
|
|
273
584
|
this.heartbeatViewer(session, viewerToken);
|
|
585
|
+
// Reconcile native document identity before capturing and again before
|
|
586
|
+
// issuing authority. A same-URL reload can invalidate the captured pixels.
|
|
587
|
+
const before = await this.snapshotState(session, viewerToken);
|
|
588
|
+
if (before.error) throw this.metadataFailure(session, before.error);
|
|
589
|
+
const inputGeneration = session.inputGeneration;
|
|
590
|
+
const revision = session.frameRevision;
|
|
274
591
|
const frame = await this.frameForQuality(session, quality);
|
|
275
|
-
this.
|
|
592
|
+
const observedAt = this.now();
|
|
593
|
+
const state = await this.snapshotState(session, viewerToken);
|
|
594
|
+
if (state.error) throw this.metadataFailure(session, state.error);
|
|
595
|
+
if (
|
|
596
|
+
inputGeneration !== session.inputGeneration ||
|
|
597
|
+
revision !== session.frameRevision ||
|
|
598
|
+
!frameMatchesBrowserContext(frame, before) ||
|
|
599
|
+
!frameMatchesBrowserContext(frame, state)
|
|
600
|
+
) {
|
|
601
|
+
throw new Error("Browser changed during capture; request another frame");
|
|
602
|
+
}
|
|
603
|
+
if (observedAt + FRAME_TOKEN_TTL_MS <= this.now()) {
|
|
604
|
+
throw new Error("Browser capture expired during validation; request another frame");
|
|
605
|
+
}
|
|
606
|
+
this.rememberFrame(session, frame, undefined, observedAt);
|
|
607
|
+
return { state, frame: knownFrameId === frame.frameId ? null : frame };
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Change physical density without navigating, switching input mode, or resizing CSS layout. */
|
|
612
|
+
async setCaptureDensity(input: DensityInput): Promise<{ state: BrowserState }> {
|
|
613
|
+
const session = this.requireViewer(input.viewerToken);
|
|
614
|
+
return this.serialize(session, async () => {
|
|
615
|
+
this.requireMutationAccess(session, input);
|
|
616
|
+
if (!canUseCaptureDensity(session.viewport, input.density))
|
|
617
|
+
throw new RangeError("Capture density exceeds the supported image bounds");
|
|
618
|
+
if (session.captureScale !== input.density) {
|
|
619
|
+
await this.cancelGesture(session);
|
|
620
|
+
const preset = DEVICE_PRESETS.find(({ id }) => id === session.devicePresetId);
|
|
621
|
+
await this.request(session, "capture.density", {
|
|
622
|
+
density: input.density,
|
|
623
|
+
deviceScaleFactor: preset?.deviceScaleFactor ?? 1,
|
|
624
|
+
});
|
|
625
|
+
session.captureScale = input.density;
|
|
626
|
+
session.viewportGeneration += 1;
|
|
627
|
+
this.invalidateFrames(session);
|
|
628
|
+
}
|
|
629
|
+
this.renewController(session, input.viewerToken);
|
|
630
|
+
return { state: await this.snapshotState(session, input.viewerToken) };
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Wait outside mutation serialization. Normal media reads use cached policy
|
|
636
|
+
* state and native generation fences. A newly observed native document needs
|
|
637
|
+
* one bounded reconciliation because video viewers may not request JPEG/status.
|
|
638
|
+
* The transition read never admits packets, even after reconciliation succeeds.
|
|
639
|
+
*/
|
|
640
|
+
async readVideo(input: BrowserVideoReadInput): Promise<BrowserVideoReadReply> {
|
|
641
|
+
input = readBrowserVideoRpc.input.parse(input);
|
|
642
|
+
const session = this.requireViewer(input.viewerToken);
|
|
643
|
+
const before = await this.serialize(session, async () => {
|
|
644
|
+
this.pruneExpired(session);
|
|
645
|
+
this.heartbeatViewer(session, input.viewerToken);
|
|
646
|
+
if (session.videoReads.has(input.viewerToken)) {
|
|
647
|
+
throw new Error("A video read is already pending for this viewer");
|
|
648
|
+
}
|
|
649
|
+
const state = this.projectState(session, input.viewerToken);
|
|
650
|
+
session.videoReads.add(input.viewerToken);
|
|
651
|
+
this.armVideoLifetime(session);
|
|
276
652
|
return {
|
|
277
|
-
state
|
|
278
|
-
|
|
653
|
+
state,
|
|
654
|
+
inputGeneration: session.inputGeneration,
|
|
655
|
+
frameRevision: session.frameRevision,
|
|
279
656
|
};
|
|
280
657
|
});
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
const raw = asRecord(
|
|
661
|
+
await this.request(session, "video.read", {
|
|
662
|
+
quality: input.quality,
|
|
663
|
+
...(input.bitrate === undefined ? {} : { bitrate: input.bitrate }),
|
|
664
|
+
...(input.fps === undefined ? {} : { fps: input.fps }),
|
|
665
|
+
streamId: input.streamId,
|
|
666
|
+
afterSequence: input.afterSequence,
|
|
667
|
+
waitMs: input.waitMs,
|
|
668
|
+
requestKeyFrame: input.requestKeyFrame,
|
|
669
|
+
}),
|
|
670
|
+
);
|
|
671
|
+
return await this.serialize(session, async () => {
|
|
672
|
+
if (this.requireViewer(input.viewerToken) !== session || session.archived) {
|
|
673
|
+
throw new Error("Video viewer attachment changed");
|
|
674
|
+
}
|
|
675
|
+
if (raw.inputGeneration !== session.inputGeneration) {
|
|
676
|
+
// External/script navigation must not depend on visual fallback to
|
|
677
|
+
// reconcile policy state. Revoke old receipts first, and discard this
|
|
678
|
+
// entire read: neither pre-transition nor mixed-document pixels qualify.
|
|
679
|
+
this.invalidateFrames(session);
|
|
680
|
+
const state = await this.snapshotState(
|
|
681
|
+
session,
|
|
682
|
+
input.viewerToken,
|
|
683
|
+
NAVIGATION_METADATA_TIMEOUT_MS,
|
|
684
|
+
);
|
|
685
|
+
if (this.requireViewer(input.viewerToken) !== session || session.archived) {
|
|
686
|
+
throw new Error("Video viewer attachment changed");
|
|
687
|
+
}
|
|
688
|
+
return { state, status: "reset", streamId: null, packets: [] };
|
|
689
|
+
}
|
|
690
|
+
const state = this.projectState(session, input.viewerToken);
|
|
691
|
+
const changed =
|
|
692
|
+
session.frameRevision !== before.frameRevision ||
|
|
693
|
+
state.sessionId !== before.state.sessionId ||
|
|
694
|
+
state.runtimeId !== before.state.runtimeId ||
|
|
695
|
+
state.bridgeEpoch !== before.state.bridgeEpoch ||
|
|
696
|
+
state.navigationGeneration !== before.state.navigationGeneration ||
|
|
697
|
+
state.viewportGeneration !== before.state.viewportGeneration ||
|
|
698
|
+
session.inputGeneration !== before.inputGeneration ||
|
|
699
|
+
raw.inputGeneration !== session.inputGeneration;
|
|
700
|
+
if (changed || state.status !== "ready") {
|
|
701
|
+
return { state, status: "reset", streamId: null, packets: [] };
|
|
702
|
+
}
|
|
703
|
+
const dimensions = captureDimensions(session.viewport, session.captureScale);
|
|
704
|
+
const packets: BrowserVideoReadReply["packets"] = [];
|
|
705
|
+
let bytes = 0;
|
|
706
|
+
if (Array.isArray(raw.packets)) {
|
|
707
|
+
for (const value of raw.packets.slice(0, 32)) {
|
|
708
|
+
const parsed = runtimeVideoPacketSchema.safeParse(value);
|
|
709
|
+
if (!parsed.success) throw new Error("Runtime returned an invalid video packet");
|
|
710
|
+
// Private source time cannot be supplied or changed by a remote viewer.
|
|
711
|
+
const { capturedAtMonotonicMs, ...packet } = parsed.data;
|
|
712
|
+
const age = this.monotonicNow() - capturedAtMonotonicMs;
|
|
713
|
+
if (
|
|
714
|
+
(session.videoNotBefore !== null && capturedAtMonotonicMs < session.videoNotBefore) ||
|
|
715
|
+
age < -VIDEO_SOURCE_CLOCK_TOLERANCE_MS ||
|
|
716
|
+
age > 1000 ||
|
|
717
|
+
packet.width !== dimensions.width ||
|
|
718
|
+
packet.height !== dimensions.height
|
|
719
|
+
) {
|
|
720
|
+
return { state, status: "reset", streamId: null, packets: [] };
|
|
721
|
+
}
|
|
722
|
+
bytes += Buffer.byteLength(packet.dataBase64, "base64");
|
|
723
|
+
if (bytes > VIDEO_MAX_BATCH_BYTES)
|
|
724
|
+
throw new Error("Runtime returned an oversized video batch");
|
|
725
|
+
if (packet.streamId !== raw.streamId)
|
|
726
|
+
throw new Error("Video stream identity changed within a batch");
|
|
727
|
+
const receipt = JSON.stringify([
|
|
728
|
+
packet.streamId,
|
|
729
|
+
packet.captureGeneration,
|
|
730
|
+
packet.sequence,
|
|
731
|
+
packet.timestampUs,
|
|
732
|
+
]);
|
|
733
|
+
const remembered = session.videoReceipts.get(receipt);
|
|
734
|
+
if (remembered && remembered.capturedAtMonotonicMs !== capturedAtMonotonicMs) {
|
|
735
|
+
throw new Error("Runtime changed an existing video receipt timestamp");
|
|
736
|
+
}
|
|
737
|
+
let frame = remembered?.frame;
|
|
738
|
+
if (!frame) {
|
|
739
|
+
frame = {
|
|
740
|
+
frameId: this.issueUniqueToken(),
|
|
741
|
+
sessionId: session.sessionId,
|
|
742
|
+
runtimeId: session.runtimeId,
|
|
743
|
+
captureEpoch: session.bridgeEpoch,
|
|
744
|
+
navigationGeneration: session.navigationGeneration,
|
|
745
|
+
viewportGeneration: session.viewportGeneration,
|
|
746
|
+
width: packet.width,
|
|
747
|
+
height: packet.height,
|
|
748
|
+
capturedAt: packet.capturedAt,
|
|
749
|
+
};
|
|
750
|
+
session.videoReceipts.set(receipt, { frame, capturedAtMonotonicMs });
|
|
751
|
+
}
|
|
752
|
+
this.rememberFrame(session, frame, capturedAtMonotonicMs);
|
|
753
|
+
packets.push({ ...packet, frame });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
while (session.videoReceipts.size > MAX_RECENT_FRAMES) {
|
|
757
|
+
const oldest = session.videoReceipts.keys().next().value;
|
|
758
|
+
if (oldest === undefined) break;
|
|
759
|
+
session.videoReceipts.delete(oldest);
|
|
760
|
+
}
|
|
761
|
+
return readBrowserVideoRpc.output.parse({
|
|
762
|
+
state,
|
|
763
|
+
status: raw.status,
|
|
764
|
+
streamId: raw.streamId,
|
|
765
|
+
packets,
|
|
766
|
+
...(typeof raw.reason === "string" ? { reason: raw.reason } : {}),
|
|
767
|
+
...(raw.reasonCode === "encoder-capacity" || raw.reasonCode === "video-disabled"
|
|
768
|
+
? { reasonCode: raw.reasonCode }
|
|
769
|
+
: {}),
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
} finally {
|
|
773
|
+
session.videoReads.delete(input.viewerToken);
|
|
774
|
+
}
|
|
281
775
|
}
|
|
282
776
|
|
|
283
777
|
async acquireControl(
|
|
@@ -290,6 +784,9 @@ export class SessionManager {
|
|
|
290
784
|
const current = session.controller;
|
|
291
785
|
if (current && current.viewerToken !== viewerToken && !takeover)
|
|
292
786
|
throw new Error("Browser control is held by another viewer");
|
|
787
|
+
if (current?.viewerToken !== viewerToken) {
|
|
788
|
+
await this.cancelGesture(session);
|
|
789
|
+
}
|
|
293
790
|
const controller =
|
|
294
791
|
current?.viewerToken === viewerToken
|
|
295
792
|
? current
|
|
@@ -310,6 +807,7 @@ export class SessionManager {
|
|
|
310
807
|
const session = this.requireViewer(viewerToken);
|
|
311
808
|
return this.serialize(session, async () => {
|
|
312
809
|
this.requireController(session, viewerToken, controlToken);
|
|
810
|
+
await this.cancelGesture(session);
|
|
313
811
|
session.controller = null;
|
|
314
812
|
this.heartbeatViewer(session, viewerToken);
|
|
315
813
|
return { state: await this.snapshotState(session, viewerToken) };
|
|
@@ -320,47 +818,70 @@ export class SessionManager {
|
|
|
320
818
|
const session = this.requireViewer(input.viewerToken);
|
|
321
819
|
return this.serialize(session, async () => {
|
|
322
820
|
this.requireMutationAccess(session, input);
|
|
821
|
+
await this.cancelGesture(session);
|
|
323
822
|
const navigationGeneration = session.navigationGeneration;
|
|
324
823
|
if (input.action.kind === "goto")
|
|
325
824
|
await this.request(session, "navigate", { url: normalizeBrowserUrl(input.action.url) });
|
|
326
825
|
else if (input.action.kind === "back") {
|
|
327
|
-
await this.refreshPageMetadata(session);
|
|
826
|
+
await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
328
827
|
if (!session.canGoBack) throw new Error("Browser cannot go back");
|
|
329
828
|
await this.request(session, "back", null);
|
|
330
829
|
} else if (input.action.kind === "forward") {
|
|
331
|
-
await this.refreshPageMetadata(session);
|
|
830
|
+
await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
332
831
|
if (!session.canGoForward) throw new Error("Browser cannot go forward");
|
|
333
832
|
await this.request(session, "forward", null);
|
|
334
833
|
} else await this.request(session, "reload", null);
|
|
335
|
-
|
|
834
|
+
// One authoritative post-navigation read. Loading is browser-native; an
|
|
835
|
+
// unavailable observation remains an explicit error state, not cached-ready.
|
|
836
|
+
await this.snapshotState(session, input.viewerToken, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
336
837
|
if (session.navigationGeneration === navigationGeneration) session.navigationGeneration += 1;
|
|
337
838
|
this.invalidateFrames(session);
|
|
338
839
|
this.renewController(session, input.viewerToken);
|
|
339
|
-
return { state:
|
|
840
|
+
return { state: this.projectState(session, input.viewerToken) };
|
|
340
841
|
});
|
|
341
842
|
}
|
|
342
843
|
|
|
844
|
+
/** Resize CSS layout under the control lease; panel following preserves device behavior and density. */
|
|
343
845
|
async resize(input: ResizeInput): Promise<{ state: BrowserState }> {
|
|
344
846
|
const session = this.requireViewer(input.viewerToken);
|
|
345
847
|
return this.serialize(session, async () => {
|
|
346
848
|
this.requireMutationAccess(session, input);
|
|
849
|
+
await this.cancelGesture(session);
|
|
347
850
|
assertViewport(input.viewport);
|
|
851
|
+
const preserveEmulation = input.preserveEmulation === true;
|
|
852
|
+
const captureScale = input.captureDensity ?? (preserveEmulation ? session.captureScale : 1);
|
|
853
|
+
if (
|
|
854
|
+
input.viewport.width * captureScale > MAX_VIEWPORT.width ||
|
|
855
|
+
input.viewport.height * captureScale > MAX_VIEWPORT.height
|
|
856
|
+
) {
|
|
857
|
+
throw new RangeError(
|
|
858
|
+
"Viewport at the current capture density exceeds supported image bounds",
|
|
859
|
+
);
|
|
860
|
+
}
|
|
348
861
|
if (
|
|
349
862
|
session.viewport.width !== input.viewport.width ||
|
|
350
863
|
session.viewport.height !== input.viewport.height ||
|
|
351
|
-
session.
|
|
864
|
+
session.captureScale !== captureScale ||
|
|
865
|
+
(!preserveEmulation && session.devicePresetId !== null)
|
|
352
866
|
) {
|
|
867
|
+
const preset = preserveEmulation
|
|
868
|
+
? DEVICE_PRESETS.find(({ id }) => id === session.devicePresetId)
|
|
869
|
+
: undefined;
|
|
353
870
|
await this.request(session, "emulate", {
|
|
354
871
|
...input.viewport,
|
|
355
|
-
deviceScaleFactor: 1,
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
872
|
+
deviceScaleFactor: Math.max(preset?.deviceScaleFactor ?? 1, captureScale),
|
|
873
|
+
captureScale,
|
|
874
|
+
mobile: preset?.isMobile ?? false,
|
|
875
|
+
touch: preset?.hasTouch ?? false,
|
|
876
|
+
userAgent: preserveEmulation ? session.userAgent : session.defaultUserAgent,
|
|
877
|
+
platform: preset?.platform ?? "",
|
|
360
878
|
});
|
|
361
879
|
session.viewport = { ...input.viewport };
|
|
362
|
-
session.
|
|
363
|
-
|
|
880
|
+
session.captureScale = captureScale;
|
|
881
|
+
if (!preserveEmulation) {
|
|
882
|
+
session.devicePresetId = null;
|
|
883
|
+
session.userAgent = session.defaultUserAgent;
|
|
884
|
+
}
|
|
364
885
|
session.viewportGeneration += 1;
|
|
365
886
|
this.invalidateFrames(session);
|
|
366
887
|
}
|
|
@@ -373,24 +894,31 @@ export class SessionManager {
|
|
|
373
894
|
const session = this.requireViewer(input.viewerToken);
|
|
374
895
|
return this.serialize(session, async () => {
|
|
375
896
|
this.requireMutationAccess(session, input);
|
|
897
|
+
await this.cancelGesture(session);
|
|
376
898
|
const preset = DEVICE_PRESETS.find(({ id }) => id === input.presetId);
|
|
377
899
|
if (!preset) throw new Error("Unknown device preset");
|
|
900
|
+
// Resolve the display under the session lock. A mode toggle must neither
|
|
901
|
+
// replay remembered dimensions nor change the capture's pixel resolution.
|
|
902
|
+
const viewport = input.preserveDisplay ? session.viewport : preset.viewport;
|
|
903
|
+
const captureScale = input.preserveDisplay ? session.captureScale : preset.captureScale;
|
|
378
904
|
await this.request(session, "emulate", {
|
|
379
|
-
width:
|
|
380
|
-
height:
|
|
381
|
-
deviceScaleFactor: preset.deviceScaleFactor,
|
|
905
|
+
width: viewport.width,
|
|
906
|
+
height: viewport.height,
|
|
907
|
+
deviceScaleFactor: Math.max(preset.deviceScaleFactor, captureScale),
|
|
908
|
+
captureScale,
|
|
382
909
|
mobile: preset.isMobile,
|
|
383
910
|
touch: preset.hasTouch,
|
|
384
911
|
userAgent: preset.userAgent,
|
|
385
912
|
platform: preset.platform,
|
|
386
913
|
});
|
|
387
|
-
session.viewport = { ...
|
|
914
|
+
session.viewport = { ...viewport };
|
|
388
915
|
session.devicePresetId = preset.id;
|
|
916
|
+
session.captureScale = captureScale;
|
|
389
917
|
session.userAgent = preset.userAgent;
|
|
390
918
|
session.viewportGeneration += 1;
|
|
391
|
-
|
|
919
|
+
// Emulation invalidates input geometry even when display dimensions stay
|
|
920
|
+
// fixed. UA-sniffing sites can use Reload; never discard a draft automatically.
|
|
392
921
|
this.invalidateFrames(session);
|
|
393
|
-
await this.request(session, "reload", null);
|
|
394
922
|
this.renewController(session, input.viewerToken);
|
|
395
923
|
return { state: await this.snapshotState(session, input.viewerToken) };
|
|
396
924
|
});
|
|
@@ -401,18 +929,372 @@ export class SessionManager {
|
|
|
401
929
|
return this.serialize(session, async () => {
|
|
402
930
|
this.requireMutationAccess(session, input);
|
|
403
931
|
this.requireRecentFrame(session, input.target);
|
|
404
|
-
|
|
405
|
-
|
|
932
|
+
const assertTargetCurrent = this.discreteInputGuard(session, input);
|
|
933
|
+
try {
|
|
934
|
+
await this.cancelGesture(session);
|
|
935
|
+
await this.dispatchDiscreteInput(session, input.event, assertTargetCurrent);
|
|
936
|
+
} finally {
|
|
937
|
+
const hadVideo = session.videoReceipts.size > 0 || session.videoReads.size > 0;
|
|
938
|
+
// Revoke authority even if auxiliary capture fails. Source-age fencing
|
|
939
|
+
// rejects buffered pre-action video and never overwrites the input outcome.
|
|
940
|
+
this.invalidateFrames(session);
|
|
941
|
+
if (hadVideo) await this.request(session, "video.invalidate", null).catch(() => undefined);
|
|
942
|
+
}
|
|
406
943
|
this.renewController(session, input.viewerToken);
|
|
407
944
|
return { state: await this.snapshotState(session, input.viewerToken) };
|
|
408
945
|
});
|
|
409
946
|
}
|
|
410
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Admit one human channel from a decoded frame receipt that is still fresh.
|
|
950
|
+
* A receipt is bounded by its TTL and cleared by acknowledged input; an idle
|
|
951
|
+
* reopen therefore needs new pixels, never a retained earlier admission. Native
|
|
952
|
+
* begin pins the attachment/document. This never admits agent input.
|
|
953
|
+
*/
|
|
954
|
+
async beginGesture(input: BeginGestureInput) {
|
|
955
|
+
const session = this.requireViewer(input.viewerToken);
|
|
956
|
+
return this.serialize(session, async () => {
|
|
957
|
+
this.requireMutationAccess(session, input);
|
|
958
|
+
await this.refreshPageMetadata(session);
|
|
959
|
+
session.error = null;
|
|
960
|
+
this.requireMutationAccess(session, input);
|
|
961
|
+
if (!this.isRecentFrame(session, input.target)) {
|
|
962
|
+
// A pre-input capture can decode after scroll revoked its token. Report
|
|
963
|
+
// known non-publication, not an uncertain runtime failure. The client
|
|
964
|
+
// may obtain another decoded frame before admitting its still-unsent input.
|
|
965
|
+
const state = await this.snapshotState(session, input.viewerToken);
|
|
966
|
+
this.requireMutationAccess(session, input);
|
|
967
|
+
if (state.status !== "ready")
|
|
968
|
+
throw new Error(state.error ?? "Browser runtime is unavailable");
|
|
969
|
+
return { state, admission: "stale-frame" as const };
|
|
970
|
+
}
|
|
971
|
+
if (session.inputGeneration === null)
|
|
972
|
+
throw new Error("Browser input identity is unavailable");
|
|
973
|
+
await this.cancelGesture(session);
|
|
974
|
+
const gesture = new BrowserGesture(
|
|
975
|
+
this.issueUniqueToken(),
|
|
976
|
+
input.viewerToken,
|
|
977
|
+
input.controlToken,
|
|
978
|
+
input.pointerKind,
|
|
979
|
+
{ ...input.expected },
|
|
980
|
+
{ ...session.viewport },
|
|
981
|
+
this.now(),
|
|
982
|
+
session.inputGeneration,
|
|
983
|
+
);
|
|
984
|
+
session.gesture = gesture;
|
|
985
|
+
try {
|
|
986
|
+
await this.request(session, "input.begin", {
|
|
987
|
+
gestureId: gesture.id,
|
|
988
|
+
expectedInputGeneration: gesture.runtimeInputGeneration,
|
|
989
|
+
});
|
|
990
|
+
await this.assertGestureCurrent(session, gesture);
|
|
991
|
+
this.renewController(session, input.viewerToken);
|
|
992
|
+
this.scheduleGestureTimeout(session, gesture);
|
|
993
|
+
const state = this.projectState(session, input.viewerToken);
|
|
994
|
+
await this.assertGestureCurrent(session, gesture);
|
|
995
|
+
return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence };
|
|
996
|
+
} catch (error) {
|
|
997
|
+
await this.cancelGesture(session, gesture);
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Continue an admitted human channel independently of decoded-frame catchup.
|
|
1005
|
+
* Exact control/document/geometry/native checks fence every ordered packet, and
|
|
1006
|
+
* every new press (mouse down or first touch contact) needs its own fresh decoded
|
|
1007
|
+
* receipt. Held motion and releases reuse the channel. Uncertain publication
|
|
1008
|
+
* revokes observations and is never replayed.
|
|
1009
|
+
*/
|
|
1010
|
+
async updateGesture(input: UpdateGestureInput): Promise<UpdateGestureReply> {
|
|
1011
|
+
const session = this.requireViewer(input.viewerToken);
|
|
1012
|
+
return this.serialize(session, async () => {
|
|
1013
|
+
const gesture = this.requireGesture(session, input);
|
|
1014
|
+
let publishedEvent: BrowserGestureEvent | null = null;
|
|
1015
|
+
let eventAcknowledged = false;
|
|
1016
|
+
try {
|
|
1017
|
+
this.assertGesturePacketContext(gesture, input.expected);
|
|
1018
|
+
const event = browserGestureEventSchema.parse(input.event);
|
|
1019
|
+
gesture.assertSequence(input.sequence, this.now());
|
|
1020
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1021
|
+
gesture.validate(event);
|
|
1022
|
+
const hover = gesture.isHover(event);
|
|
1023
|
+
if (
|
|
1024
|
+
event.kind === "down" ||
|
|
1025
|
+
(event.kind === "touch" && event.type === "start" && gesture.touches.size === 0)
|
|
1026
|
+
) {
|
|
1027
|
+
if (!input.target) throw new Error("A current frame is required for a new press");
|
|
1028
|
+
this.requireRecentFrame(session, input.target);
|
|
1029
|
+
}
|
|
1030
|
+
// Revoke agent observations before an outcome can become unknown.
|
|
1031
|
+
this.invalidateGestureObservations(session, hover);
|
|
1032
|
+
if (event.kind === "key") {
|
|
1033
|
+
await this.request(session, "input.key", {
|
|
1034
|
+
gestureId: gesture.id,
|
|
1035
|
+
event: {
|
|
1036
|
+
kind: event.kind,
|
|
1037
|
+
type: event.type,
|
|
1038
|
+
key: event.key,
|
|
1039
|
+
code: event.code,
|
|
1040
|
+
modifiers: event.modifiers,
|
|
1041
|
+
repeat: event.repeat,
|
|
1042
|
+
...(event.text !== undefined ? { text: event.text } : {}),
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
} else if (event.kind === "text") {
|
|
1046
|
+
await this.request(session, "input.text", { gestureId: gesture.id, text: event.text });
|
|
1047
|
+
} else if (event.kind === "leave") {
|
|
1048
|
+
await this.request(session, "mouse.leave", { gestureId: gesture.id });
|
|
1049
|
+
gesture.lastPoint = null;
|
|
1050
|
+
} else if (event.kind === "touch") {
|
|
1051
|
+
const points = event.points.map((point) => ({
|
|
1052
|
+
...gesture.mapPoint(point),
|
|
1053
|
+
id: point.id,
|
|
1054
|
+
}));
|
|
1055
|
+
await this.request(session, "touch", { gestureId: gesture.id, type: event.type, points });
|
|
1056
|
+
} else {
|
|
1057
|
+
const point = gesture.mapPoint(event.point);
|
|
1058
|
+
const operation =
|
|
1059
|
+
event.kind === "move"
|
|
1060
|
+
? "mouse.move"
|
|
1061
|
+
: event.kind === "down"
|
|
1062
|
+
? "mouse.down"
|
|
1063
|
+
: event.kind === "up"
|
|
1064
|
+
? "mouse.up"
|
|
1065
|
+
: "mouse.wheel";
|
|
1066
|
+
await this.request(session, operation, {
|
|
1067
|
+
...point,
|
|
1068
|
+
gestureId: gesture.id,
|
|
1069
|
+
...(event.modifiers === undefined ? {} : { modifiers: event.modifiers }),
|
|
1070
|
+
...(event.kind === "down" || event.kind === "up"
|
|
1071
|
+
? { button: event.button, clickCount: event.clickCount }
|
|
1072
|
+
: {}),
|
|
1073
|
+
...(event.kind === "scroll" ? { deltaX: event.deltaX, deltaY: event.deltaY } : {}),
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
publishedEvent = event;
|
|
1077
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1078
|
+
gesture.acknowledge(event, this.now());
|
|
1079
|
+
eventAcknowledged = true;
|
|
1080
|
+
// Fence captures received during publication as well as earlier ones.
|
|
1081
|
+
this.invalidateGestureObservations(session, hover);
|
|
1082
|
+
this.renewController(session, input.viewerToken);
|
|
1083
|
+
this.scheduleGestureTimeout(session, gesture);
|
|
1084
|
+
const cursor = await this.gestureCursor(session, gesture);
|
|
1085
|
+
// An ordinary held-input acknowledgement must not block video behind a
|
|
1086
|
+
// page metadata RPC. Native attachment/document checks remain mandatory;
|
|
1087
|
+
// their navigation failure uses the qualified completion path below.
|
|
1088
|
+
const state = this.projectState(session, input.viewerToken);
|
|
1089
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1090
|
+
return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence, cursor };
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if (publishedEvent) {
|
|
1093
|
+
const completion = await this.completeNavigatingInput(
|
|
1094
|
+
session,
|
|
1095
|
+
gesture,
|
|
1096
|
+
publishedEvent,
|
|
1097
|
+
eventAcknowledged,
|
|
1098
|
+
).catch(() => null);
|
|
1099
|
+
if (completion) return completion;
|
|
1100
|
+
}
|
|
1101
|
+
await this.cancelGesture(session, gesture);
|
|
1102
|
+
throw error;
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Revoke observation authority without resetting the continuous video codec. */
|
|
1108
|
+
private invalidateGestureObservations(session: BrowserSession, hover: boolean): void {
|
|
1109
|
+
session.frameCache.clear();
|
|
1110
|
+
if (!hover) {
|
|
1111
|
+
session.recentFrames.clear();
|
|
1112
|
+
session.videoInputNotBefore = this.monotonicNow();
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Return a fresh viewing state after acknowledged input navigated, without admitting another input. */
|
|
1117
|
+
private async completeNavigatingInput(
|
|
1118
|
+
session: BrowserSession,
|
|
1119
|
+
gesture: BrowserGesture,
|
|
1120
|
+
event: BrowserGestureEvent,
|
|
1121
|
+
eventAcknowledged: boolean,
|
|
1122
|
+
) {
|
|
1123
|
+
const state = await this.snapshotState(session, gesture.viewerToken);
|
|
1124
|
+
if (state.navigationGeneration === gesture.expected.navigationGeneration) return null;
|
|
1125
|
+
// Runtime inputGeneration is the private native attachment:document tuple.
|
|
1126
|
+
// A target replacement can also bump policy navigationGeneration, but must
|
|
1127
|
+
// never be accepted as same-attachment navigation after an acknowledged input.
|
|
1128
|
+
if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
|
|
1129
|
+
return null;
|
|
1130
|
+
|
|
1131
|
+
// Only the old document constraint may change after successful publication.
|
|
1132
|
+
// Takeover, viewport changes and runtime/bridge fences are still refused.
|
|
1133
|
+
this.requireMutationAccess(session, {
|
|
1134
|
+
viewerToken: gesture.viewerToken,
|
|
1135
|
+
controlToken: gesture.controlToken,
|
|
1136
|
+
expected: { ...gesture.expected, navigationGeneration: state.navigationGeneration },
|
|
1137
|
+
});
|
|
1138
|
+
if (!eventAcknowledged) gesture.acknowledge(event, this.now());
|
|
1139
|
+
await this.cancelGesture(session, gesture);
|
|
1140
|
+
const finalState = await this.snapshotState(session, gesture.viewerToken);
|
|
1141
|
+
if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
|
|
1142
|
+
return null;
|
|
1143
|
+
this.requireMutationAccess(session, {
|
|
1144
|
+
viewerToken: gesture.viewerToken,
|
|
1145
|
+
controlToken: gesture.controlToken,
|
|
1146
|
+
expected: { ...gesture.expected, navigationGeneration: finalState.navigationGeneration },
|
|
1147
|
+
});
|
|
1148
|
+
this.invalidateFrames(session);
|
|
1149
|
+
this.renewController(session, gesture.viewerToken);
|
|
1150
|
+
return {
|
|
1151
|
+
state: finalState,
|
|
1152
|
+
gestureId: gesture.id,
|
|
1153
|
+
nextSequence: gesture.nextSequence,
|
|
1154
|
+
cursor: null,
|
|
1155
|
+
completion: "navigation" as const,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Cancellation releases only this channel, even after a lost update reply or navigation drift. */
|
|
1160
|
+
async endGesture(input: EndGestureInput) {
|
|
1161
|
+
const session = this.requireViewer(input.viewerToken);
|
|
1162
|
+
return this.serialize(session, async () => {
|
|
1163
|
+
const gesture = session.gesture;
|
|
1164
|
+
if (!gesture && input.cancel) {
|
|
1165
|
+
this.requireController(session, input.viewerToken, input.controlToken);
|
|
1166
|
+
return { state: this.projectState(session, input.viewerToken), cursor: null };
|
|
1167
|
+
}
|
|
1168
|
+
const owned = this.requireGesture(session, input);
|
|
1169
|
+
try {
|
|
1170
|
+
if (!input.cancel) {
|
|
1171
|
+
this.assertGesturePacketContext(owned, input.expected);
|
|
1172
|
+
owned.assertSequence(input.sequence, this.now());
|
|
1173
|
+
await this.assertGestureCurrent(session, owned);
|
|
1174
|
+
}
|
|
1175
|
+
} finally {
|
|
1176
|
+
await this.cancelGesture(session, owned);
|
|
1177
|
+
}
|
|
1178
|
+
return { state: this.projectState(session, input.viewerToken), cursor: null };
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private requireGesture(
|
|
1183
|
+
session: BrowserSession,
|
|
1184
|
+
input: UpdateGestureInput | EndGestureInput,
|
|
1185
|
+
): BrowserGesture {
|
|
1186
|
+
const gesture = session.gesture;
|
|
1187
|
+
if (
|
|
1188
|
+
!gesture ||
|
|
1189
|
+
gesture.id !== input.gestureId ||
|
|
1190
|
+
gesture.viewerToken !== input.viewerToken ||
|
|
1191
|
+
gesture.controlToken !== input.controlToken
|
|
1192
|
+
)
|
|
1193
|
+
throw new Error("Browser gesture is unavailable");
|
|
1194
|
+
return gesture;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private assertGesturePacketContext(
|
|
1198
|
+
gesture: BrowserGesture,
|
|
1199
|
+
expected: BeginGestureInput["expected"],
|
|
1200
|
+
): void {
|
|
1201
|
+
// JSON member order is irrelevant; identity fields must match individually.
|
|
1202
|
+
for (const key of [
|
|
1203
|
+
"sessionId",
|
|
1204
|
+
"runtimeId",
|
|
1205
|
+
"bridgeEpoch",
|
|
1206
|
+
"navigationGeneration",
|
|
1207
|
+
"viewportGeneration",
|
|
1208
|
+
] as const) {
|
|
1209
|
+
if (gesture.expected[key] !== expected[key])
|
|
1210
|
+
throw new Error("Browser gesture context changed");
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private async assertGestureCurrent(
|
|
1215
|
+
session: BrowserSession,
|
|
1216
|
+
gesture: BrowserGesture,
|
|
1217
|
+
): Promise<void> {
|
|
1218
|
+
this.requireMutationAccess(session, {
|
|
1219
|
+
viewerToken: gesture.viewerToken,
|
|
1220
|
+
controlToken: gesture.controlToken,
|
|
1221
|
+
expected: gesture.expected,
|
|
1222
|
+
});
|
|
1223
|
+
if (
|
|
1224
|
+
session.gesture !== gesture ||
|
|
1225
|
+
this.now() >= gesture.idleUntil ||
|
|
1226
|
+
this.now() >= gesture.expiresAt
|
|
1227
|
+
)
|
|
1228
|
+
throw new Error("Browser gesture expired");
|
|
1229
|
+
// Runtime check pins the CDP document/attachment without repeating a full
|
|
1230
|
+
// metadata read for every drag point. Status or a media-generation mismatch
|
|
1231
|
+
// reconciles metadata; acknowledged navigation uses its explicit completion.
|
|
1232
|
+
await this.request(session, "input.check", { gestureId: gesture.id });
|
|
1233
|
+
this.requireMutationAccess(session, {
|
|
1234
|
+
viewerToken: gesture.viewerToken,
|
|
1235
|
+
controlToken: gesture.controlToken,
|
|
1236
|
+
expected: gesture.expected,
|
|
1237
|
+
});
|
|
1238
|
+
if (session.gesture !== gesture) throw new Error("Browser gesture was cancelled");
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private async gestureCursor(session: BrowserSession, gesture: BrowserGesture) {
|
|
1242
|
+
if (!gesture.lastPoint || gesture.pointerKind !== "mouse") return null;
|
|
1243
|
+
try {
|
|
1244
|
+
const raw = await this.request(session, "cursor", {
|
|
1245
|
+
...gesture.lastPoint,
|
|
1246
|
+
gestureId: gesture.id,
|
|
1247
|
+
});
|
|
1248
|
+
const parsed = browserCursorSchema.safeParse(raw);
|
|
1249
|
+
return parsed.success ? parsed.data : null;
|
|
1250
|
+
} catch {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private scheduleGestureTimeout(session: BrowserSession, gesture: BrowserGesture): void {
|
|
1256
|
+
if (gesture.timer) clearTimeout(gesture.timer);
|
|
1257
|
+
gesture.timer = setTimeout(
|
|
1258
|
+
() => {
|
|
1259
|
+
void this.serialize(session, async () => {
|
|
1260
|
+
if (
|
|
1261
|
+
session.gesture === gesture &&
|
|
1262
|
+
this.now() >= Math.min(gesture.idleUntil, gesture.expiresAt)
|
|
1263
|
+
) {
|
|
1264
|
+
await this.cancelGesture(session, gesture);
|
|
1265
|
+
}
|
|
1266
|
+
}).catch(() => undefined);
|
|
1267
|
+
},
|
|
1268
|
+
Math.max(1, Math.min(gesture.idleUntil, gesture.expiresAt) - this.now()),
|
|
1269
|
+
);
|
|
1270
|
+
gesture.timer.unref?.();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private async cancelGesture(session: BrowserSession, expected?: BrowserGesture): Promise<void> {
|
|
1274
|
+
const gesture = session.gesture;
|
|
1275
|
+
if (!gesture || (expected && gesture !== expected)) return;
|
|
1276
|
+
session.gesture = null;
|
|
1277
|
+
if (gesture.timer) clearTimeout(gesture.timer);
|
|
1278
|
+
// Cleanup targets the runtime's original attachment, never a newly navigated
|
|
1279
|
+
// or replaced page. Failure is best-effort and cannot replay the input.
|
|
1280
|
+
try {
|
|
1281
|
+
await this.request(session, "input.end", { gestureId: gesture.id });
|
|
1282
|
+
} catch {
|
|
1283
|
+
/* Best-effort old-attachment cleanup must not escape teardown. */
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
411
1287
|
reset(): void {
|
|
412
1288
|
this.lifecycleGeneration += 1;
|
|
1289
|
+
for (const session of this.sessions.values()) {
|
|
1290
|
+
session.videoLifetime?.cancel();
|
|
1291
|
+
void this.cancelGesture(session);
|
|
1292
|
+
}
|
|
413
1293
|
this.sessions.clear();
|
|
414
1294
|
this.sessionCreations.clear();
|
|
415
1295
|
this.viewerSessions.clear();
|
|
1296
|
+
this.closedViewerWorkspaces.clear();
|
|
1297
|
+
this.closedViewerTabs.clear();
|
|
416
1298
|
}
|
|
417
1299
|
|
|
418
1300
|
disconnect(): void {
|
|
@@ -422,48 +1304,70 @@ export class SessionManager {
|
|
|
422
1304
|
this.reset();
|
|
423
1305
|
}
|
|
424
1306
|
|
|
425
|
-
private async getOrCreateSession(
|
|
1307
|
+
private async getOrCreateSession(
|
|
1308
|
+
workspaceId: string,
|
|
1309
|
+
tabId?: string,
|
|
1310
|
+
replaceLost = false,
|
|
1311
|
+
): Promise<BrowserSession> {
|
|
426
1312
|
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
427
|
-
const
|
|
1313
|
+
const closing = this.sessionClosures.get(workspaceId);
|
|
1314
|
+
if (closing) await closing.catch(() => undefined);
|
|
1315
|
+
if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
|
|
1316
|
+
const existing = [...this.sessions.values()].find(
|
|
1317
|
+
(session) => session.workspaceId === workspaceId && (!tabId || session.tabId === tabId),
|
|
1318
|
+
);
|
|
428
1319
|
if (existing) return existing;
|
|
429
|
-
|
|
430
|
-
|
|
1320
|
+
|
|
1321
|
+
// The launch page has no known target ID until identity returns. Wait for
|
|
1322
|
+
// another first attachment before deciding whether this target needs a
|
|
1323
|
+
// second policy session, so control and presence cannot split in two.
|
|
1324
|
+
const workspaceCreation = [...this.sessionCreations.entries()].find(
|
|
1325
|
+
([pendingKey]) => pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`),
|
|
1326
|
+
)?.[1];
|
|
1327
|
+
if (workspaceCreation) {
|
|
1328
|
+
await workspaceCreation.catch(() => undefined);
|
|
1329
|
+
return this.getOrCreateSession(workspaceId, tabId, replaceLost);
|
|
1330
|
+
}
|
|
1331
|
+
const key = tabId ? `${workspaceId}/${tabId}` : workspaceId;
|
|
431
1332
|
if (this.sessions.size + this.sessionCreations.size >= this.maxSessions)
|
|
432
1333
|
throw new Error(`Shared browser session limit (${this.maxSessions}) reached`);
|
|
433
1334
|
const lifecycleGeneration = this.lifecycleGeneration;
|
|
434
|
-
const creation = this.createSession(workspaceId);
|
|
435
|
-
this.sessionCreations.set(
|
|
1335
|
+
const creation = this.createSession(workspaceId, key, tabId, replaceLost);
|
|
1336
|
+
this.sessionCreations.set(key, creation);
|
|
436
1337
|
try {
|
|
437
1338
|
const session = await creation;
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
? "Shared browser manager is closed"
|
|
446
|
-
: this.archived.has(workspaceId)
|
|
447
|
-
? "Workspace was archived"
|
|
448
|
-
: "Shared browser manager was reset",
|
|
449
|
-
);
|
|
450
|
-
this.sessions.set(workspaceId, session);
|
|
1339
|
+
if (this.closed) throw new Error("Shared browser manager is closed");
|
|
1340
|
+
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
1341
|
+
if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
|
|
1342
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
1343
|
+
throw new Error("Shared browser manager was reset");
|
|
1344
|
+
}
|
|
1345
|
+
this.sessions.set(key, session);
|
|
451
1346
|
return session;
|
|
452
1347
|
} catch (error) {
|
|
453
1348
|
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
454
1349
|
throw error;
|
|
455
1350
|
} finally {
|
|
456
|
-
if (this.sessionCreations.get(
|
|
457
|
-
this.sessionCreations.delete(workspaceId);
|
|
1351
|
+
if (this.sessionCreations.get(key) === creation) this.sessionCreations.delete(key);
|
|
458
1352
|
}
|
|
459
1353
|
}
|
|
460
1354
|
|
|
461
|
-
private async createSession(
|
|
462
|
-
|
|
1355
|
+
private async createSession(
|
|
1356
|
+
workspaceId: string,
|
|
1357
|
+
key: string,
|
|
1358
|
+
requestedTabId?: string,
|
|
1359
|
+
replaceLost = false,
|
|
1360
|
+
): Promise<BrowserSession> {
|
|
1361
|
+
let runtimeId: string | null = null;
|
|
463
1362
|
try {
|
|
464
|
-
const descriptor = await this.client.ensureWorkspace(workspaceId);
|
|
465
|
-
|
|
466
|
-
const
|
|
1363
|
+
const descriptor = await this.client.ensureWorkspace(workspaceId, { replaceLost });
|
|
1364
|
+
runtimeId = descriptor.runtimeId;
|
|
1365
|
+
const initialTarget = requestedTabId ? { targetId: requestedTabId } : null;
|
|
1366
|
+
const identity = asRecord(
|
|
1367
|
+
await this.client.requestWorkspace(workspaceId, "identity", initialTarget),
|
|
1368
|
+
);
|
|
1369
|
+
const tabId =
|
|
1370
|
+
requestedTabId ?? (typeof identity.targetId === "string" ? identity.targetId : null);
|
|
467
1371
|
const userAgent = boundedText(String(identity.userAgent ?? "Chromium"), 512);
|
|
468
1372
|
await this.client.requestWorkspace(workspaceId, "emulate", {
|
|
469
1373
|
...DEFAULT_VIEWPORT,
|
|
@@ -472,15 +1376,25 @@ export class SessionManager {
|
|
|
472
1376
|
touch: false,
|
|
473
1377
|
userAgent,
|
|
474
1378
|
platform: "",
|
|
1379
|
+
...(tabId ? { targetId: tabId } : {}),
|
|
475
1380
|
});
|
|
476
|
-
const state = asRecord(
|
|
1381
|
+
const state = asRecord(
|
|
1382
|
+
await this.client.requestWorkspace(
|
|
1383
|
+
workspaceId,
|
|
1384
|
+
"state",
|
|
1385
|
+
tabId ? { targetId: tabId } : null,
|
|
1386
|
+
),
|
|
1387
|
+
);
|
|
477
1388
|
return {
|
|
1389
|
+
key,
|
|
478
1390
|
workspaceId,
|
|
1391
|
+
tabId,
|
|
479
1392
|
sessionId: this.issueUniqueToken(),
|
|
480
1393
|
runtimeId: descriptor.runtimeId,
|
|
481
1394
|
runtimeCreatedAt: descriptor.createdAt,
|
|
482
1395
|
bridgeEpoch: this.bridgeEpoch,
|
|
483
1396
|
viewport: { ...DEFAULT_VIEWPORT },
|
|
1397
|
+
captureScale: 1,
|
|
484
1398
|
navigationGeneration: 0,
|
|
485
1399
|
viewportGeneration: 0,
|
|
486
1400
|
devicePresetId: null,
|
|
@@ -491,15 +1405,40 @@ export class SessionManager {
|
|
|
491
1405
|
mutationTail: Promise.resolve(),
|
|
492
1406
|
frameCache: new Map(),
|
|
493
1407
|
recentFrames: new Map(),
|
|
1408
|
+
frameRevision: 0,
|
|
1409
|
+
videoNotBefore: null,
|
|
1410
|
+
videoInputNotBefore: null,
|
|
1411
|
+
videoReads: new Set(),
|
|
1412
|
+
videoLifetime: null,
|
|
1413
|
+
videoReceipts: new Map(),
|
|
494
1414
|
lastUrl: boundedText(String(state.url ?? ""), 8192),
|
|
495
1415
|
lastTitle: boundedText(String(state.title ?? ""), 1024),
|
|
496
1416
|
canGoBack: Boolean(state.canGoBack),
|
|
497
1417
|
canGoForward: Boolean(state.canGoForward),
|
|
498
1418
|
error: null,
|
|
1419
|
+
notice:
|
|
1420
|
+
typeof identity.displayNotice === "string"
|
|
1421
|
+
? boundedText(identity.displayNotice, 256)
|
|
1422
|
+
: null,
|
|
1423
|
+
runtimeLost: false,
|
|
499
1424
|
archived: false,
|
|
1425
|
+
gesture: null,
|
|
1426
|
+
inputGeneration: typeof state.inputGeneration === "string" ? state.inputGeneration : null,
|
|
500
1427
|
};
|
|
501
1428
|
} catch (error) {
|
|
502
|
-
|
|
1429
|
+
const hasOtherSession = [...this.sessions.values()].some(
|
|
1430
|
+
(session) => session.workspaceId === workspaceId && session.key !== key,
|
|
1431
|
+
);
|
|
1432
|
+
const hasOtherCreation = [...this.sessionCreations.keys()].some(
|
|
1433
|
+
(pendingKey) =>
|
|
1434
|
+
pendingKey !== key &&
|
|
1435
|
+
(pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`)),
|
|
1436
|
+
);
|
|
1437
|
+
// An invalid explicit tab must never tear down a workspace runtime that
|
|
1438
|
+
// another participant may already be using without a policy session.
|
|
1439
|
+
if (runtimeId && !requestedTabId && !hasOtherSession && !hasOtherCreation) {
|
|
1440
|
+
await this.client.closeWorkspace(workspaceId, runtimeId).catch(() => undefined);
|
|
1441
|
+
}
|
|
503
1442
|
throw error;
|
|
504
1443
|
}
|
|
505
1444
|
}
|
|
@@ -510,12 +1449,62 @@ export class SessionManager {
|
|
|
510
1449
|
input: JsonValue,
|
|
511
1450
|
): Promise<JsonValue> {
|
|
512
1451
|
if (session.archived) throw new Error("Workspace was archived");
|
|
513
|
-
|
|
1452
|
+
const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
1453
|
+
return this.client
|
|
1454
|
+
.requestWorkspace(
|
|
1455
|
+
session.workspaceId,
|
|
1456
|
+
operation,
|
|
1457
|
+
session.tabId ? { ...data, targetId: session.tabId } : input,
|
|
1458
|
+
)
|
|
1459
|
+
.catch((error) => {
|
|
1460
|
+
if (error instanceof RuntimeProtocolError && error.code === "RUNTIME_LOST") {
|
|
1461
|
+
// The workspace's single browser is gone, so every page session of it is gone.
|
|
1462
|
+
session.runtimeLost = true;
|
|
1463
|
+
for (const candidate of this.sessions.values())
|
|
1464
|
+
if (candidate.workspaceId === session.workspaceId) candidate.runtimeLost = true;
|
|
1465
|
+
}
|
|
1466
|
+
throw error;
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Cleanup depends on viewer ownership, never on whether capture produced pixels. */
|
|
1471
|
+
private async stopUnusedVideo(session: BrowserSession): Promise<void> {
|
|
1472
|
+
if (session.viewers.size !== 0) return;
|
|
1473
|
+
session.videoLifetime?.cancel();
|
|
1474
|
+
await this.request(session, "video.stop", null).catch(() => undefined);
|
|
1475
|
+
this.invalidateFrames(session);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Recheck leases under the same session queue before stopping optional capture. */
|
|
1479
|
+
private armVideoLifetime(session: BrowserSession): void {
|
|
1480
|
+
session.videoLifetime ??= createViewerCaptureLifetime({
|
|
1481
|
+
now: this.now,
|
|
1482
|
+
latestExpiry: () =>
|
|
1483
|
+
session.viewers.size > 0
|
|
1484
|
+
? Math.max(...[...session.viewers.values()].map((viewer) => viewer.expiresAt))
|
|
1485
|
+
: null,
|
|
1486
|
+
onExpired: () =>
|
|
1487
|
+
this.serialize(session, async () => {
|
|
1488
|
+
if (this.sessions.get(session.key) !== session || session.archived) return;
|
|
1489
|
+
this.pruneExpired(session);
|
|
1490
|
+
await this.stopUnusedVideo(session);
|
|
1491
|
+
}),
|
|
1492
|
+
});
|
|
1493
|
+
session.videoLifetime.arm();
|
|
514
1494
|
}
|
|
515
1495
|
|
|
516
1496
|
private requireViewer(token: string): BrowserSession {
|
|
1497
|
+
if (this.closedViewerWorkspaces.has(token)) throw new Error("Browser is closed");
|
|
1498
|
+
const closedTabExpiry = this.closedViewerTabs.get(token);
|
|
1499
|
+
if (closedTabExpiry !== undefined) {
|
|
1500
|
+
if (closedTabExpiry > this.now()) throw new Error("Browser tab is closed");
|
|
1501
|
+
this.closedViewerTabs.delete(token);
|
|
1502
|
+
}
|
|
517
1503
|
const session = this.viewerSessions.get(token);
|
|
518
|
-
|
|
1504
|
+
const viewer = session?.viewers.get(token);
|
|
1505
|
+
// Expiry is enforced at the authentication boundary itself: an unpruned lapsed lease
|
|
1506
|
+
// never authorizes an entrypoint that does not run pruneExpired first.
|
|
1507
|
+
if (!session || !viewer || viewer.expiresAt <= this.now())
|
|
519
1508
|
throw new Error("Viewer token is invalid or expired");
|
|
520
1509
|
return session;
|
|
521
1510
|
}
|
|
@@ -540,8 +1529,10 @@ export class SessionManager {
|
|
|
540
1529
|
if (
|
|
541
1530
|
session.controller &&
|
|
542
1531
|
(session.controller.expiresAt <= now || !session.viewers.has(session.controller.viewerToken))
|
|
543
|
-
)
|
|
1532
|
+
) {
|
|
544
1533
|
session.controller = null;
|
|
1534
|
+
void this.serialize(session, () => this.cancelGesture(session)).catch(() => undefined);
|
|
1535
|
+
}
|
|
545
1536
|
}
|
|
546
1537
|
private requireController(
|
|
547
1538
|
session: BrowserSession,
|
|
@@ -586,16 +1577,33 @@ export class SessionManager {
|
|
|
586
1577
|
session: BrowserSession,
|
|
587
1578
|
target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
|
|
588
1579
|
): void {
|
|
1580
|
+
// Retain diagnostic evidence before validation prunes expired receipts.
|
|
1581
|
+
// An absent receipt cannot distinguish expiry from an earlier revocation.
|
|
1582
|
+
const frame = session.recentFrames.get(target.frameId);
|
|
1583
|
+
if (this.isRecentFrame(session, target)) return;
|
|
1584
|
+
if (!frame) throw createBrowserFrameRejection("admission", "missing-or-revoked");
|
|
1585
|
+
|
|
1586
|
+
const expiresAt = frame.expiresAtMonotonicMs ?? frame.expiresAt;
|
|
1587
|
+
const now = frame.expiresAtMonotonicMs === undefined ? this.now() : this.monotonicNow();
|
|
1588
|
+
const ageMs = now - (expiresAt - FRAME_TOKEN_TTL_MS);
|
|
1589
|
+
const reason = expiresAt <= now ? "expired" : "context-changed";
|
|
1590
|
+
throw createBrowserFrameRejection("admission", reason, ageMs);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** Check issued frame authority without admitting or cancelling a live channel. */
|
|
1594
|
+
private isRecentFrame(
|
|
1595
|
+
session: BrowserSession,
|
|
1596
|
+
target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
|
|
1597
|
+
): boolean {
|
|
589
1598
|
this.pruneRecentFrames(session);
|
|
590
1599
|
const frame = session.recentFrames.get(target.frameId);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
)
|
|
598
|
-
throw new Error("Browser frame is stale");
|
|
1600
|
+
return Boolean(
|
|
1601
|
+
frame &&
|
|
1602
|
+
target.navigationGeneration === session.navigationGeneration &&
|
|
1603
|
+
target.viewportGeneration === session.viewportGeneration &&
|
|
1604
|
+
frame.navigationGeneration === session.navigationGeneration &&
|
|
1605
|
+
frame.viewportGeneration === session.viewportGeneration,
|
|
1606
|
+
);
|
|
599
1607
|
}
|
|
600
1608
|
private renewController(session: BrowserSession, token: string): void {
|
|
601
1609
|
this.heartbeatViewer(session, token);
|
|
@@ -627,8 +1635,14 @@ export class SessionManager {
|
|
|
627
1635
|
}
|
|
628
1636
|
private pruneRecentFrames(session: BrowserSession): void {
|
|
629
1637
|
const now = this.now();
|
|
630
|
-
|
|
631
|
-
|
|
1638
|
+
const monotonicNow = this.monotonicNow();
|
|
1639
|
+
for (const [id, frame] of session.recentFrames) {
|
|
1640
|
+
const expired =
|
|
1641
|
+
frame.expiresAtMonotonicMs === undefined
|
|
1642
|
+
? frame.expiresAt <= now
|
|
1643
|
+
: frame.expiresAtMonotonicMs <= monotonicNow;
|
|
1644
|
+
if (expired) session.recentFrames.delete(id);
|
|
1645
|
+
}
|
|
632
1646
|
}
|
|
633
1647
|
private serialize<T>(session: BrowserSession, operation: () => Promise<T>): Promise<T> {
|
|
634
1648
|
const result = session.mutationTail.then(operation, operation);
|
|
@@ -639,8 +1653,12 @@ export class SessionManager {
|
|
|
639
1653
|
return result;
|
|
640
1654
|
}
|
|
641
1655
|
private invalidateFrames(session: BrowserSession): void {
|
|
1656
|
+
session.frameRevision += 1;
|
|
1657
|
+
session.videoNotBefore = this.monotonicNow();
|
|
1658
|
+
session.videoInputNotBefore = session.videoNotBefore;
|
|
642
1659
|
session.frameCache.clear();
|
|
643
1660
|
session.recentFrames.clear();
|
|
1661
|
+
session.videoReceipts.clear();
|
|
644
1662
|
}
|
|
645
1663
|
|
|
646
1664
|
private async frameForQuality(
|
|
@@ -650,26 +1668,25 @@ export class SessionManager {
|
|
|
650
1668
|
const cached = session.frameCache.get(quality);
|
|
651
1669
|
if (
|
|
652
1670
|
cached &&
|
|
653
|
-
cached.frame
|
|
654
|
-
cached.frame.viewportGeneration === session.viewportGeneration &&
|
|
1671
|
+
frameMatchesBrowserContext(cached.frame, session) &&
|
|
655
1672
|
this.now() - cached.cachedAt <= this.frameCacheMs
|
|
656
1673
|
)
|
|
657
1674
|
return cached.frame;
|
|
658
1675
|
const generation = {
|
|
1676
|
+
sessionId: session.sessionId,
|
|
1677
|
+
runtimeId: session.runtimeId,
|
|
1678
|
+
captureEpoch: session.bridgeEpoch,
|
|
659
1679
|
navigationGeneration: session.navigationGeneration,
|
|
660
1680
|
viewportGeneration: session.viewportGeneration,
|
|
661
1681
|
};
|
|
662
1682
|
const raw = asRecord(
|
|
663
1683
|
await this.request(session, "frame", {
|
|
664
|
-
maxBytes:
|
|
1684
|
+
maxBytes: FRAME_MAX_BYTES,
|
|
665
1685
|
quality: JPEG_QUALITY[quality],
|
|
666
1686
|
waitMs: SCREENCAST_WAIT_MS,
|
|
667
1687
|
}),
|
|
668
1688
|
);
|
|
669
|
-
if (
|
|
670
|
-
generation.navigationGeneration !== session.navigationGeneration ||
|
|
671
|
-
generation.viewportGeneration !== session.viewportGeneration
|
|
672
|
-
)
|
|
1689
|
+
if (!frameMatchesBrowserContext(generation, session))
|
|
673
1690
|
throw new Error("Browser changed during capture; request another frame");
|
|
674
1691
|
const byteLength = Number(raw.byteLength);
|
|
675
1692
|
const dataBase64 = String(raw.dataBase64 ?? "");
|
|
@@ -680,8 +1697,33 @@ export class SessionManager {
|
|
|
680
1697
|
Buffer.byteLength(dataBase64, "base64") !== byteLength
|
|
681
1698
|
)
|
|
682
1699
|
throw new Error("Browser returned an invalid frame");
|
|
1700
|
+
const expectedPixels = captureDimensions(session.viewport, session.captureScale);
|
|
1701
|
+
if (Number(raw.width) !== expectedPixels.width || Number(raw.height) !== expectedPixels.height)
|
|
1702
|
+
throw new Error("Browser returned a frame for a stale viewport");
|
|
1703
|
+
|
|
1704
|
+
// Runtime fallback receipts remain fresh for at most one second. A 100ms
|
|
1705
|
+
// policy-cache expiry must not create a new token and resend the same large
|
|
1706
|
+
// JPEG at every poll. Reuse only its exact receipt, not merely equal pixels.
|
|
1707
|
+
if (
|
|
1708
|
+
cached &&
|
|
1709
|
+
typeof raw.capturedAt === "string" &&
|
|
1710
|
+
Number.isFinite(Date.parse(raw.capturedAt)) &&
|
|
1711
|
+
(raw.transport === "screenshot" || raw.transport === "cdp-screencast") &&
|
|
1712
|
+
session.frameCache.get(quality) === cached &&
|
|
1713
|
+
cached.frame.sessionId === generation.sessionId &&
|
|
1714
|
+
cached.frame.runtimeId === generation.runtimeId &&
|
|
1715
|
+
cached.frame.captureEpoch === generation.captureEpoch &&
|
|
1716
|
+
cached.frame.navigationGeneration === generation.navigationGeneration &&
|
|
1717
|
+
cached.frame.viewportGeneration === generation.viewportGeneration &&
|
|
1718
|
+
(["dataBase64", "byteLength", "width", "height", "capturedAt", "transport"] as const).every(
|
|
1719
|
+
(key) => cached.rawFrame[key] === raw[key],
|
|
1720
|
+
)
|
|
1721
|
+
) {
|
|
1722
|
+
cached.cachedAt = this.now();
|
|
1723
|
+
return cached.frame;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
683
1726
|
const frame: BrowserFrame = {
|
|
684
|
-
sessionId: session.sessionId,
|
|
685
1727
|
frameId: this.issueUniqueToken(),
|
|
686
1728
|
mimeType: "image/jpeg",
|
|
687
1729
|
transport: raw.transport === "cdp-screencast" ? "cdp-screencast" : "screenshot",
|
|
@@ -690,22 +1732,38 @@ export class SessionManager {
|
|
|
690
1732
|
width: Number(raw.width),
|
|
691
1733
|
height: Number(raw.height),
|
|
692
1734
|
...generation,
|
|
693
|
-
runtimeId: session.runtimeId,
|
|
694
|
-
captureEpoch: session.bridgeEpoch,
|
|
695
1735
|
capturedAt: String(raw.capturedAt),
|
|
696
1736
|
};
|
|
697
|
-
|
|
698
|
-
throw new Error("Browser returned a frame for a stale viewport");
|
|
699
|
-
session.frameCache.set(quality, { frame, cachedAt: this.now() });
|
|
1737
|
+
session.frameCache.set(quality, { frame, cachedAt: this.now(), rawFrame: { ...raw } });
|
|
700
1738
|
return frame;
|
|
701
1739
|
}
|
|
702
1740
|
|
|
703
|
-
|
|
1741
|
+
/** Native source clocks have bounded uncertainty. Displaying a late packet
|
|
1742
|
+
* must never resurrect the press token revoked by acknowledged live input.
|
|
1743
|
+
* JPEG receipts retain the runtime's existing post-input capture-generation fence. */
|
|
1744
|
+
private rememberFrame(
|
|
1745
|
+
session: BrowserSession,
|
|
1746
|
+
frame: BrowserFrameAuthority,
|
|
1747
|
+
capturedAtMonotonicMs?: number,
|
|
1748
|
+
observedAt = this.now(),
|
|
1749
|
+
): void {
|
|
1750
|
+
if (capturedAtMonotonicMs !== undefined && session.videoInputNotBefore !== null) {
|
|
1751
|
+
const exclusiveFloor = session.videoInputNotBefore + VIDEO_SOURCE_CLOCK_TOLERANCE_MS;
|
|
1752
|
+
if (!Number.isFinite(capturedAtMonotonicMs) || capturedAtMonotonicMs <= exclusiveFloor) {
|
|
1753
|
+
session.recentFrames.delete(frame.frameId);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
704
1757
|
this.pruneRecentFrames(session);
|
|
705
1758
|
session.recentFrames.set(frame.frameId, {
|
|
706
1759
|
navigationGeneration: frame.navigationGeneration,
|
|
707
1760
|
viewportGeneration: frame.viewportGeneration,
|
|
708
|
-
expiresAt:
|
|
1761
|
+
expiresAt: observedAt + FRAME_TOKEN_TTL_MS,
|
|
1762
|
+
...(capturedAtMonotonicMs === undefined
|
|
1763
|
+
? {}
|
|
1764
|
+
: {
|
|
1765
|
+
expiresAtMonotonicMs: this.monotonicNow() + FRAME_TOKEN_TTL_MS,
|
|
1766
|
+
}),
|
|
709
1767
|
});
|
|
710
1768
|
while (session.recentFrames.size > MAX_RECENT_FRAMES) {
|
|
711
1769
|
const oldest = session.recentFrames.keys().next().value;
|
|
@@ -714,42 +1772,111 @@ export class SessionManager {
|
|
|
714
1772
|
}
|
|
715
1773
|
}
|
|
716
1774
|
|
|
1775
|
+
/**
|
|
1776
|
+
* Retain one freshly admitted observation for this operation only. Runtime
|
|
1777
|
+
* latency cannot expire it after publication; control, native document and
|
|
1778
|
+
* every attachment/display fence still revoke it. No continuation is exposed.
|
|
1779
|
+
*/
|
|
1780
|
+
private discreteInputGuard(session: BrowserSession, input: SendInput): () => Promise<void> {
|
|
1781
|
+
const context = {
|
|
1782
|
+
viewerToken: input.viewerToken,
|
|
1783
|
+
controlToken: input.controlToken,
|
|
1784
|
+
expected: {
|
|
1785
|
+
sessionId: session.sessionId,
|
|
1786
|
+
runtimeId: session.runtimeId,
|
|
1787
|
+
bridgeEpoch: session.bridgeEpoch,
|
|
1788
|
+
navigationGeneration: session.navigationGeneration,
|
|
1789
|
+
viewportGeneration: session.viewportGeneration,
|
|
1790
|
+
},
|
|
1791
|
+
};
|
|
1792
|
+
const inputGeneration = session.inputGeneration;
|
|
1793
|
+
const revision = session.frameRevision;
|
|
1794
|
+
return async () => {
|
|
1795
|
+
await this.refreshPageMetadata(session);
|
|
1796
|
+
if (
|
|
1797
|
+
inputGeneration !== session.inputGeneration ||
|
|
1798
|
+
revision !== session.frameRevision ||
|
|
1799
|
+
context.expected.navigationGeneration !== session.navigationGeneration ||
|
|
1800
|
+
context.expected.viewportGeneration !== session.viewportGeneration
|
|
1801
|
+
) {
|
|
1802
|
+
throw createBrowserFrameRejection("admitted-input", "context-changed");
|
|
1803
|
+
}
|
|
1804
|
+
this.requireMutationAccess(session, context);
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** Every discrete native primitive (text, key, press, hover, wheel) runs on one private
|
|
1809
|
+
* channel pinned to the original attachment and document, which the runtime re-checks
|
|
1810
|
+
* before each publication. The ID is never returned to callers and grants no agent
|
|
1811
|
+
* continuation. Native end uses the original page even after navigation, revocation
|
|
1812
|
+
* or a lost down ACK. */
|
|
1813
|
+
private async dispatchDiscreteInput(
|
|
1814
|
+
session: BrowserSession,
|
|
1815
|
+
event: BrowserInputEvent,
|
|
1816
|
+
assertTargetCurrent: () => Promise<void>,
|
|
1817
|
+
): Promise<void> {
|
|
1818
|
+
// Reconcile the captured document before anything is published, hover included, so a
|
|
1819
|
+
// stalled or failed metadata read holds back every native primitive.
|
|
1820
|
+
await assertTargetCurrent();
|
|
1821
|
+
if (session.inputGeneration === null) {
|
|
1822
|
+
throw new Error("Browser input identity is unavailable");
|
|
1823
|
+
}
|
|
1824
|
+
const gestureId = this.issueUniqueToken();
|
|
1825
|
+
await runWithInputCleanup(
|
|
1826
|
+
async () => {
|
|
1827
|
+
await this.request(session, "input.begin", {
|
|
1828
|
+
gestureId,
|
|
1829
|
+
expectedInputGeneration: session.inputGeneration,
|
|
1830
|
+
});
|
|
1831
|
+
await this.dispatchInput(session, event, assertTargetCurrent, gestureId);
|
|
1832
|
+
},
|
|
1833
|
+
() => this.request(session, "input.end", { gestureId }),
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
717
1837
|
private async dispatchInput(
|
|
718
1838
|
session: BrowserSession,
|
|
719
1839
|
event: BrowserInputEvent,
|
|
720
|
-
|
|
1840
|
+
assertTargetCurrent: () => Promise<void>,
|
|
1841
|
+
gestureId: string,
|
|
721
1842
|
): Promise<void> {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
await this.request(session, "text.insert", { text: event.text });
|
|
1843
|
+
// The document was reconciled and pinned by dispatchDiscreteInput; the runtime refuses
|
|
1844
|
+
// every primitive below once its attachment or document differs from that pin.
|
|
1845
|
+
if (event.kind === "move") {
|
|
1846
|
+
const point = mapDisplayedPoint(event.point, session.viewport);
|
|
1847
|
+
await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
|
|
728
1848
|
return;
|
|
729
1849
|
}
|
|
730
|
-
if (event.kind === "
|
|
731
|
-
await this.request(session, "
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
await this.request(session, "key.up", { key: event.key });
|
|
736
|
-
}
|
|
1850
|
+
if (event.kind === "type") {
|
|
1851
|
+
await this.request(session, "text.insert", {
|
|
1852
|
+
text: event.text,
|
|
1853
|
+
gestureId,
|
|
1854
|
+
});
|
|
737
1855
|
return;
|
|
738
1856
|
}
|
|
739
|
-
if (event.kind === "
|
|
740
|
-
|
|
741
|
-
|
|
1857
|
+
if (event.kind === "key") {
|
|
1858
|
+
await this.request(session, "key.down", {
|
|
1859
|
+
key: event.key,
|
|
1860
|
+
gestureId,
|
|
1861
|
+
});
|
|
1862
|
+
await runWithInputCleanup(assertTargetCurrent, () =>
|
|
1863
|
+
this.request(session, "key.up", {
|
|
1864
|
+
key: event.key,
|
|
1865
|
+
gestureId,
|
|
1866
|
+
}),
|
|
1867
|
+
);
|
|
742
1868
|
return;
|
|
743
1869
|
}
|
|
744
1870
|
if (event.kind === "scroll") {
|
|
745
1871
|
const point = mapDisplayedPoint(event.point, session.viewport);
|
|
746
|
-
await this.request(session, "mouse.move", { x: point.x, y: point.y });
|
|
1872
|
+
await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
|
|
747
1873
|
await assertTargetCurrent();
|
|
748
1874
|
await this.request(session, "mouse.wheel", {
|
|
749
1875
|
x: point.x,
|
|
750
1876
|
y: point.y,
|
|
751
1877
|
deltaX: event.deltaX,
|
|
752
1878
|
deltaY: event.deltaY,
|
|
1879
|
+
gestureId,
|
|
753
1880
|
});
|
|
754
1881
|
return;
|
|
755
1882
|
}
|
|
@@ -757,7 +1884,11 @@ export class SessionManager {
|
|
|
757
1884
|
event.kind === "drag" ? event.start : event.point,
|
|
758
1885
|
session.viewport,
|
|
759
1886
|
);
|
|
760
|
-
await this.request(session, "mouse.move", {
|
|
1887
|
+
await this.request(session, "mouse.move", {
|
|
1888
|
+
x: start.x,
|
|
1889
|
+
y: start.y,
|
|
1890
|
+
gestureId,
|
|
1891
|
+
});
|
|
761
1892
|
await assertTargetCurrent();
|
|
762
1893
|
const count = event.kind === "click" ? event.clickCount : 1;
|
|
763
1894
|
for (let clickCount = 1; clickCount <= count; clickCount += 1) {
|
|
@@ -766,54 +1897,83 @@ export class SessionManager {
|
|
|
766
1897
|
y: start.y,
|
|
767
1898
|
button: event.button,
|
|
768
1899
|
clickCount,
|
|
1900
|
+
gestureId,
|
|
769
1901
|
});
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (event.kind === "drag") {
|
|
773
|
-
const end = mapDisplayedPoint(event.end, session.viewport);
|
|
774
|
-
await this.request(session, "mouse.move", { x: end.x, y: end.y });
|
|
1902
|
+
await runWithInputCleanup(
|
|
1903
|
+
async () => {
|
|
775
1904
|
await assertTargetCurrent();
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1905
|
+
if (event.kind === "drag") {
|
|
1906
|
+
const end = mapDisplayedPoint(event.end, session.viewport);
|
|
1907
|
+
await this.request(session, "mouse.move", {
|
|
1908
|
+
x: end.x,
|
|
1909
|
+
y: end.y,
|
|
1910
|
+
gestureId,
|
|
1911
|
+
});
|
|
1912
|
+
await assertTargetCurrent();
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
async () => {
|
|
1916
|
+
const end =
|
|
1917
|
+
event.kind === "drag" ? mapDisplayedPoint(event.end, session.viewport) : start;
|
|
1918
|
+
await this.request(session, "mouse.up", {
|
|
1919
|
+
x: end.x,
|
|
1920
|
+
y: end.y,
|
|
1921
|
+
button: event.button,
|
|
1922
|
+
clickCount,
|
|
1923
|
+
gestureId,
|
|
1924
|
+
});
|
|
1925
|
+
},
|
|
1926
|
+
);
|
|
786
1927
|
}
|
|
787
1928
|
}
|
|
788
1929
|
|
|
789
|
-
private async refreshPageMetadata(session: BrowserSession): Promise<void> {
|
|
790
|
-
const raw = asRecord(
|
|
1930
|
+
private async refreshPageMetadata(session: BrowserSession, timeoutMs?: number): Promise<void> {
|
|
1931
|
+
const raw = asRecord(
|
|
1932
|
+
await this.request(session, "state", timeoutMs === undefined ? null : { timeoutMs }),
|
|
1933
|
+
);
|
|
791
1934
|
const url = boundedText(String(raw.url ?? ""), 8192);
|
|
792
|
-
|
|
1935
|
+
const inputGeneration = typeof raw.inputGeneration === "string" ? raw.inputGeneration : null;
|
|
1936
|
+
const documentChanged =
|
|
1937
|
+
session.inputGeneration !== null && inputGeneration !== session.inputGeneration;
|
|
1938
|
+
if ((session.lastUrl && session.lastUrl !== url) || documentChanged) {
|
|
1939
|
+
await this.cancelGesture(session);
|
|
793
1940
|
session.navigationGeneration += 1;
|
|
794
1941
|
this.invalidateFrames(session);
|
|
795
1942
|
}
|
|
1943
|
+
session.inputGeneration = inputGeneration;
|
|
796
1944
|
session.lastUrl = url;
|
|
797
1945
|
session.lastTitle = boundedText(String(raw.title ?? ""), 1024);
|
|
798
1946
|
session.canGoBack = Boolean(raw.canGoBack);
|
|
799
1947
|
session.canGoForward = Boolean(raw.canGoForward);
|
|
800
1948
|
}
|
|
801
1949
|
|
|
802
|
-
private async snapshotState(
|
|
1950
|
+
private async snapshotState(
|
|
1951
|
+
session: BrowserSession,
|
|
1952
|
+
viewerToken: string,
|
|
1953
|
+
timeoutMs?: number,
|
|
1954
|
+
): Promise<BrowserState> {
|
|
803
1955
|
this.pruneExpired(session);
|
|
804
1956
|
if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
|
|
805
1957
|
try {
|
|
806
|
-
await this.refreshPageMetadata(session);
|
|
1958
|
+
await this.refreshPageMetadata(session, timeoutMs);
|
|
807
1959
|
session.error = null;
|
|
808
1960
|
} catch (error) {
|
|
809
1961
|
session.error = boundedText(error instanceof Error ? error.message : String(error), 2048);
|
|
810
1962
|
}
|
|
1963
|
+
return this.projectState(session, viewerToken);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** Project the last reconciled state without a page RPC. Media generations independently fence late pixels. */
|
|
1967
|
+
private projectState(session: BrowserSession, viewerToken: string): BrowserState {
|
|
1968
|
+
this.pruneExpired(session);
|
|
1969
|
+
if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
|
|
811
1970
|
const controller = session.controller;
|
|
812
1971
|
const controllerViewer = controller ? session.viewers.get(controller.viewerToken) : null;
|
|
813
1972
|
return {
|
|
814
1973
|
sessionId: session.sessionId,
|
|
815
1974
|
workspaceId: session.workspaceId,
|
|
816
1975
|
runtimeId: session.runtimeId,
|
|
1976
|
+
...(session.tabId ? { tabId: session.tabId } : {}),
|
|
817
1977
|
runtimeCreatedAt: session.runtimeCreatedAt,
|
|
818
1978
|
bridgeEpoch: session.bridgeEpoch,
|
|
819
1979
|
status: session.error ? "error" : "ready",
|
|
@@ -822,6 +1982,7 @@ export class SessionManager {
|
|
|
822
1982
|
canGoBack: session.canGoBack,
|
|
823
1983
|
canGoForward: session.canGoForward,
|
|
824
1984
|
viewport: { ...session.viewport },
|
|
1985
|
+
captureScale: session.captureScale,
|
|
825
1986
|
navigationGeneration: session.navigationGeneration,
|
|
826
1987
|
viewportGeneration: session.viewportGeneration,
|
|
827
1988
|
devicePresetId: session.devicePresetId,
|
|
@@ -831,6 +1992,7 @@ export class SessionManager {
|
|
|
831
1992
|
controllerExpiresAt: controller ? new Date(controller.expiresAt).toISOString() : null,
|
|
832
1993
|
viewerCount: session.viewers.size,
|
|
833
1994
|
error: session.error,
|
|
1995
|
+
...(session.notice ? { notice: session.notice } : {}),
|
|
834
1996
|
};
|
|
835
1997
|
}
|
|
836
1998
|
}
|