@omercnet/paseo-shared-browser 1.2.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -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 +1254 -0
- package/client/browser-input-queue.ts +519 -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-currency.ts +45 -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 +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- 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 +375 -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 +1060 -0
- package/client/use-browser-video.ts +481 -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 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -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 +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -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 +340 -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/tab-close-confirmation.test.ts +97 -0
- 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 +83 -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,426 @@ 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
|
+
// False when input revoked this capture's time: it may still be painted,
|
|
753
|
+
// but it can never authorize a press, and the viewer must not try.
|
|
754
|
+
const actionable = this.rememberFrame(session, frame, capturedAtMonotonicMs);
|
|
755
|
+
packets.push({ ...packet, frame, actionable });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
while (session.videoReceipts.size > MAX_RECENT_FRAMES) {
|
|
759
|
+
const oldest = session.videoReceipts.keys().next().value;
|
|
760
|
+
if (oldest === undefined) break;
|
|
761
|
+
session.videoReceipts.delete(oldest);
|
|
762
|
+
}
|
|
763
|
+
return readBrowserVideoRpc.output.parse({
|
|
764
|
+
state,
|
|
765
|
+
status: raw.status,
|
|
766
|
+
streamId: raw.streamId,
|
|
767
|
+
packets,
|
|
768
|
+
...(typeof raw.reason === "string" ? { reason: raw.reason } : {}),
|
|
769
|
+
...(raw.reasonCode === "encoder-capacity" || raw.reasonCode === "video-disabled"
|
|
770
|
+
? { reasonCode: raw.reasonCode }
|
|
771
|
+
: {}),
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
} finally {
|
|
775
|
+
session.videoReads.delete(input.viewerToken);
|
|
776
|
+
}
|
|
281
777
|
}
|
|
282
778
|
|
|
283
779
|
async acquireControl(
|
|
@@ -290,6 +786,9 @@ export class SessionManager {
|
|
|
290
786
|
const current = session.controller;
|
|
291
787
|
if (current && current.viewerToken !== viewerToken && !takeover)
|
|
292
788
|
throw new Error("Browser control is held by another viewer");
|
|
789
|
+
if (current?.viewerToken !== viewerToken) {
|
|
790
|
+
await this.cancelGesture(session);
|
|
791
|
+
}
|
|
293
792
|
const controller =
|
|
294
793
|
current?.viewerToken === viewerToken
|
|
295
794
|
? current
|
|
@@ -310,6 +809,7 @@ export class SessionManager {
|
|
|
310
809
|
const session = this.requireViewer(viewerToken);
|
|
311
810
|
return this.serialize(session, async () => {
|
|
312
811
|
this.requireController(session, viewerToken, controlToken);
|
|
812
|
+
await this.cancelGesture(session);
|
|
313
813
|
session.controller = null;
|
|
314
814
|
this.heartbeatViewer(session, viewerToken);
|
|
315
815
|
return { state: await this.snapshotState(session, viewerToken) };
|
|
@@ -320,47 +820,70 @@ export class SessionManager {
|
|
|
320
820
|
const session = this.requireViewer(input.viewerToken);
|
|
321
821
|
return this.serialize(session, async () => {
|
|
322
822
|
this.requireMutationAccess(session, input);
|
|
823
|
+
await this.cancelGesture(session);
|
|
323
824
|
const navigationGeneration = session.navigationGeneration;
|
|
324
825
|
if (input.action.kind === "goto")
|
|
325
826
|
await this.request(session, "navigate", { url: normalizeBrowserUrl(input.action.url) });
|
|
326
827
|
else if (input.action.kind === "back") {
|
|
327
|
-
await this.refreshPageMetadata(session);
|
|
828
|
+
await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
328
829
|
if (!session.canGoBack) throw new Error("Browser cannot go back");
|
|
329
830
|
await this.request(session, "back", null);
|
|
330
831
|
} else if (input.action.kind === "forward") {
|
|
331
|
-
await this.refreshPageMetadata(session);
|
|
832
|
+
await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
332
833
|
if (!session.canGoForward) throw new Error("Browser cannot go forward");
|
|
333
834
|
await this.request(session, "forward", null);
|
|
334
835
|
} else await this.request(session, "reload", null);
|
|
335
|
-
|
|
836
|
+
// One authoritative post-navigation read. Loading is browser-native; an
|
|
837
|
+
// unavailable observation remains an explicit error state, not cached-ready.
|
|
838
|
+
await this.snapshotState(session, input.viewerToken, NAVIGATION_METADATA_TIMEOUT_MS);
|
|
336
839
|
if (session.navigationGeneration === navigationGeneration) session.navigationGeneration += 1;
|
|
337
840
|
this.invalidateFrames(session);
|
|
338
841
|
this.renewController(session, input.viewerToken);
|
|
339
|
-
return { state:
|
|
842
|
+
return { state: this.projectState(session, input.viewerToken) };
|
|
340
843
|
});
|
|
341
844
|
}
|
|
342
845
|
|
|
846
|
+
/** Resize CSS layout under the control lease; panel following preserves device behavior and density. */
|
|
343
847
|
async resize(input: ResizeInput): Promise<{ state: BrowserState }> {
|
|
344
848
|
const session = this.requireViewer(input.viewerToken);
|
|
345
849
|
return this.serialize(session, async () => {
|
|
346
850
|
this.requireMutationAccess(session, input);
|
|
851
|
+
await this.cancelGesture(session);
|
|
347
852
|
assertViewport(input.viewport);
|
|
853
|
+
const preserveEmulation = input.preserveEmulation === true;
|
|
854
|
+
const captureScale = input.captureDensity ?? (preserveEmulation ? session.captureScale : 1);
|
|
855
|
+
if (
|
|
856
|
+
input.viewport.width * captureScale > MAX_VIEWPORT.width ||
|
|
857
|
+
input.viewport.height * captureScale > MAX_VIEWPORT.height
|
|
858
|
+
) {
|
|
859
|
+
throw new RangeError(
|
|
860
|
+
"Viewport at the current capture density exceeds supported image bounds",
|
|
861
|
+
);
|
|
862
|
+
}
|
|
348
863
|
if (
|
|
349
864
|
session.viewport.width !== input.viewport.width ||
|
|
350
865
|
session.viewport.height !== input.viewport.height ||
|
|
351
|
-
session.
|
|
866
|
+
session.captureScale !== captureScale ||
|
|
867
|
+
(!preserveEmulation && session.devicePresetId !== null)
|
|
352
868
|
) {
|
|
869
|
+
const preset = preserveEmulation
|
|
870
|
+
? DEVICE_PRESETS.find(({ id }) => id === session.devicePresetId)
|
|
871
|
+
: undefined;
|
|
353
872
|
await this.request(session, "emulate", {
|
|
354
873
|
...input.viewport,
|
|
355
|
-
deviceScaleFactor: 1,
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
874
|
+
deviceScaleFactor: Math.max(preset?.deviceScaleFactor ?? 1, captureScale),
|
|
875
|
+
captureScale,
|
|
876
|
+
mobile: preset?.isMobile ?? false,
|
|
877
|
+
touch: preset?.hasTouch ?? false,
|
|
878
|
+
userAgent: preserveEmulation ? session.userAgent : session.defaultUserAgent,
|
|
879
|
+
platform: preset?.platform ?? "",
|
|
360
880
|
});
|
|
361
881
|
session.viewport = { ...input.viewport };
|
|
362
|
-
session.
|
|
363
|
-
|
|
882
|
+
session.captureScale = captureScale;
|
|
883
|
+
if (!preserveEmulation) {
|
|
884
|
+
session.devicePresetId = null;
|
|
885
|
+
session.userAgent = session.defaultUserAgent;
|
|
886
|
+
}
|
|
364
887
|
session.viewportGeneration += 1;
|
|
365
888
|
this.invalidateFrames(session);
|
|
366
889
|
}
|
|
@@ -373,24 +896,31 @@ export class SessionManager {
|
|
|
373
896
|
const session = this.requireViewer(input.viewerToken);
|
|
374
897
|
return this.serialize(session, async () => {
|
|
375
898
|
this.requireMutationAccess(session, input);
|
|
899
|
+
await this.cancelGesture(session);
|
|
376
900
|
const preset = DEVICE_PRESETS.find(({ id }) => id === input.presetId);
|
|
377
901
|
if (!preset) throw new Error("Unknown device preset");
|
|
902
|
+
// Resolve the display under the session lock. A mode toggle must neither
|
|
903
|
+
// replay remembered dimensions nor change the capture's pixel resolution.
|
|
904
|
+
const viewport = input.preserveDisplay ? session.viewport : preset.viewport;
|
|
905
|
+
const captureScale = input.preserveDisplay ? session.captureScale : preset.captureScale;
|
|
378
906
|
await this.request(session, "emulate", {
|
|
379
|
-
width:
|
|
380
|
-
height:
|
|
381
|
-
deviceScaleFactor: preset.deviceScaleFactor,
|
|
907
|
+
width: viewport.width,
|
|
908
|
+
height: viewport.height,
|
|
909
|
+
deviceScaleFactor: Math.max(preset.deviceScaleFactor, captureScale),
|
|
910
|
+
captureScale,
|
|
382
911
|
mobile: preset.isMobile,
|
|
383
912
|
touch: preset.hasTouch,
|
|
384
913
|
userAgent: preset.userAgent,
|
|
385
914
|
platform: preset.platform,
|
|
386
915
|
});
|
|
387
|
-
session.viewport = { ...
|
|
916
|
+
session.viewport = { ...viewport };
|
|
388
917
|
session.devicePresetId = preset.id;
|
|
918
|
+
session.captureScale = captureScale;
|
|
389
919
|
session.userAgent = preset.userAgent;
|
|
390
920
|
session.viewportGeneration += 1;
|
|
391
|
-
|
|
921
|
+
// Emulation invalidates input geometry even when display dimensions stay
|
|
922
|
+
// fixed. UA-sniffing sites can use Reload; never discard a draft automatically.
|
|
392
923
|
this.invalidateFrames(session);
|
|
393
|
-
await this.request(session, "reload", null);
|
|
394
924
|
this.renewController(session, input.viewerToken);
|
|
395
925
|
return { state: await this.snapshotState(session, input.viewerToken) };
|
|
396
926
|
});
|
|
@@ -401,18 +931,372 @@ export class SessionManager {
|
|
|
401
931
|
return this.serialize(session, async () => {
|
|
402
932
|
this.requireMutationAccess(session, input);
|
|
403
933
|
this.requireRecentFrame(session, input.target);
|
|
404
|
-
|
|
405
|
-
|
|
934
|
+
const assertTargetCurrent = this.discreteInputGuard(session, input);
|
|
935
|
+
try {
|
|
936
|
+
await this.cancelGesture(session);
|
|
937
|
+
await this.dispatchDiscreteInput(session, input.event, assertTargetCurrent);
|
|
938
|
+
} finally {
|
|
939
|
+
const hadVideo = session.videoReceipts.size > 0 || session.videoReads.size > 0;
|
|
940
|
+
// Revoke authority even if auxiliary capture fails. Source-age fencing
|
|
941
|
+
// rejects buffered pre-action video and never overwrites the input outcome.
|
|
942
|
+
this.invalidateFrames(session);
|
|
943
|
+
if (hadVideo) await this.request(session, "video.invalidate", null).catch(() => undefined);
|
|
944
|
+
}
|
|
406
945
|
this.renewController(session, input.viewerToken);
|
|
407
946
|
return { state: await this.snapshotState(session, input.viewerToken) };
|
|
408
947
|
});
|
|
409
948
|
}
|
|
410
949
|
|
|
950
|
+
/**
|
|
951
|
+
* Admit one human channel from a decoded frame receipt that is still fresh.
|
|
952
|
+
* A receipt is bounded by its TTL and cleared by acknowledged input; an idle
|
|
953
|
+
* reopen therefore needs new pixels, never a retained earlier admission. Native
|
|
954
|
+
* begin pins the attachment/document. This never admits agent input.
|
|
955
|
+
*/
|
|
956
|
+
async beginGesture(input: BeginGestureInput) {
|
|
957
|
+
const session = this.requireViewer(input.viewerToken);
|
|
958
|
+
return this.serialize(session, async () => {
|
|
959
|
+
this.requireMutationAccess(session, input);
|
|
960
|
+
await this.refreshPageMetadata(session);
|
|
961
|
+
session.error = null;
|
|
962
|
+
this.requireMutationAccess(session, input);
|
|
963
|
+
if (!this.isRecentFrame(session, input.target)) {
|
|
964
|
+
// A pre-input capture can decode after scroll revoked its token. Report
|
|
965
|
+
// known non-publication, not an uncertain runtime failure. The client
|
|
966
|
+
// may obtain another decoded frame before admitting its still-unsent input.
|
|
967
|
+
const state = await this.snapshotState(session, input.viewerToken);
|
|
968
|
+
this.requireMutationAccess(session, input);
|
|
969
|
+
if (state.status !== "ready")
|
|
970
|
+
throw new Error(state.error ?? "Browser runtime is unavailable");
|
|
971
|
+
return { state, admission: "stale-frame" as const };
|
|
972
|
+
}
|
|
973
|
+
if (session.inputGeneration === null)
|
|
974
|
+
throw new Error("Browser input identity is unavailable");
|
|
975
|
+
await this.cancelGesture(session);
|
|
976
|
+
const gesture = new BrowserGesture(
|
|
977
|
+
this.issueUniqueToken(),
|
|
978
|
+
input.viewerToken,
|
|
979
|
+
input.controlToken,
|
|
980
|
+
input.pointerKind,
|
|
981
|
+
{ ...input.expected },
|
|
982
|
+
{ ...session.viewport },
|
|
983
|
+
this.now(),
|
|
984
|
+
session.inputGeneration,
|
|
985
|
+
);
|
|
986
|
+
session.gesture = gesture;
|
|
987
|
+
try {
|
|
988
|
+
await this.request(session, "input.begin", {
|
|
989
|
+
gestureId: gesture.id,
|
|
990
|
+
expectedInputGeneration: gesture.runtimeInputGeneration,
|
|
991
|
+
});
|
|
992
|
+
await this.assertGestureCurrent(session, gesture);
|
|
993
|
+
this.renewController(session, input.viewerToken);
|
|
994
|
+
this.scheduleGestureTimeout(session, gesture);
|
|
995
|
+
const state = this.projectState(session, input.viewerToken);
|
|
996
|
+
await this.assertGestureCurrent(session, gesture);
|
|
997
|
+
return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence };
|
|
998
|
+
} catch (error) {
|
|
999
|
+
await this.cancelGesture(session, gesture);
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Continue an admitted human channel independently of decoded-frame catchup.
|
|
1007
|
+
* Exact control/document/geometry/native checks fence every ordered packet, and
|
|
1008
|
+
* every new press (mouse down or first touch contact) needs its own fresh decoded
|
|
1009
|
+
* receipt. Held motion and releases reuse the channel. Uncertain publication
|
|
1010
|
+
* revokes observations and is never replayed.
|
|
1011
|
+
*/
|
|
1012
|
+
async updateGesture(input: UpdateGestureInput): Promise<UpdateGestureReply> {
|
|
1013
|
+
const session = this.requireViewer(input.viewerToken);
|
|
1014
|
+
return this.serialize(session, async () => {
|
|
1015
|
+
const gesture = this.requireGesture(session, input);
|
|
1016
|
+
let publishedEvent: BrowserGestureEvent | null = null;
|
|
1017
|
+
let eventAcknowledged = false;
|
|
1018
|
+
try {
|
|
1019
|
+
this.assertGesturePacketContext(gesture, input.expected);
|
|
1020
|
+
const event = browserGestureEventSchema.parse(input.event);
|
|
1021
|
+
gesture.assertSequence(input.sequence, this.now());
|
|
1022
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1023
|
+
gesture.validate(event);
|
|
1024
|
+
const hover = gesture.isHover(event);
|
|
1025
|
+
if (
|
|
1026
|
+
event.kind === "down" ||
|
|
1027
|
+
(event.kind === "touch" && event.type === "start" && gesture.touches.size === 0)
|
|
1028
|
+
) {
|
|
1029
|
+
if (!input.target) throw new Error("A current frame is required for a new press");
|
|
1030
|
+
this.requireRecentFrame(session, input.target);
|
|
1031
|
+
}
|
|
1032
|
+
// Revoke agent observations before an outcome can become unknown.
|
|
1033
|
+
this.invalidateGestureObservations(session, hover);
|
|
1034
|
+
if (event.kind === "key") {
|
|
1035
|
+
await this.request(session, "input.key", {
|
|
1036
|
+
gestureId: gesture.id,
|
|
1037
|
+
event: {
|
|
1038
|
+
kind: event.kind,
|
|
1039
|
+
type: event.type,
|
|
1040
|
+
key: event.key,
|
|
1041
|
+
code: event.code,
|
|
1042
|
+
modifiers: event.modifiers,
|
|
1043
|
+
repeat: event.repeat,
|
|
1044
|
+
...(event.text !== undefined ? { text: event.text } : {}),
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
} else if (event.kind === "text") {
|
|
1048
|
+
await this.request(session, "input.text", { gestureId: gesture.id, text: event.text });
|
|
1049
|
+
} else if (event.kind === "leave") {
|
|
1050
|
+
await this.request(session, "mouse.leave", { gestureId: gesture.id });
|
|
1051
|
+
gesture.lastPoint = null;
|
|
1052
|
+
} else if (event.kind === "touch") {
|
|
1053
|
+
const points = event.points.map((point) => ({
|
|
1054
|
+
...gesture.mapPoint(point),
|
|
1055
|
+
id: point.id,
|
|
1056
|
+
}));
|
|
1057
|
+
await this.request(session, "touch", { gestureId: gesture.id, type: event.type, points });
|
|
1058
|
+
} else {
|
|
1059
|
+
const point = gesture.mapPoint(event.point);
|
|
1060
|
+
const operation =
|
|
1061
|
+
event.kind === "move"
|
|
1062
|
+
? "mouse.move"
|
|
1063
|
+
: event.kind === "down"
|
|
1064
|
+
? "mouse.down"
|
|
1065
|
+
: event.kind === "up"
|
|
1066
|
+
? "mouse.up"
|
|
1067
|
+
: "mouse.wheel";
|
|
1068
|
+
await this.request(session, operation, {
|
|
1069
|
+
...point,
|
|
1070
|
+
gestureId: gesture.id,
|
|
1071
|
+
...(event.modifiers === undefined ? {} : { modifiers: event.modifiers }),
|
|
1072
|
+
...(event.kind === "down" || event.kind === "up"
|
|
1073
|
+
? { button: event.button, clickCount: event.clickCount }
|
|
1074
|
+
: {}),
|
|
1075
|
+
...(event.kind === "scroll" ? { deltaX: event.deltaX, deltaY: event.deltaY } : {}),
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
publishedEvent = event;
|
|
1079
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1080
|
+
gesture.acknowledge(event, this.now());
|
|
1081
|
+
eventAcknowledged = true;
|
|
1082
|
+
// Fence captures received during publication as well as earlier ones.
|
|
1083
|
+
this.invalidateGestureObservations(session, hover);
|
|
1084
|
+
this.renewController(session, input.viewerToken);
|
|
1085
|
+
this.scheduleGestureTimeout(session, gesture);
|
|
1086
|
+
const cursor = await this.gestureCursor(session, gesture);
|
|
1087
|
+
// An ordinary held-input acknowledgement must not block video behind a
|
|
1088
|
+
// page metadata RPC. Native attachment/document checks remain mandatory;
|
|
1089
|
+
// their navigation failure uses the qualified completion path below.
|
|
1090
|
+
const state = this.projectState(session, input.viewerToken);
|
|
1091
|
+
await this.assertGestureCurrent(session, gesture);
|
|
1092
|
+
return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence, cursor };
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
if (publishedEvent) {
|
|
1095
|
+
const completion = await this.completeNavigatingInput(
|
|
1096
|
+
session,
|
|
1097
|
+
gesture,
|
|
1098
|
+
publishedEvent,
|
|
1099
|
+
eventAcknowledged,
|
|
1100
|
+
).catch(() => null);
|
|
1101
|
+
if (completion) return completion;
|
|
1102
|
+
}
|
|
1103
|
+
await this.cancelGesture(session, gesture);
|
|
1104
|
+
throw error;
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Revoke observation authority without resetting the continuous video codec. */
|
|
1110
|
+
private invalidateGestureObservations(session: BrowserSession, hover: boolean): void {
|
|
1111
|
+
session.frameCache.clear();
|
|
1112
|
+
if (!hover) {
|
|
1113
|
+
session.recentFrames.clear();
|
|
1114
|
+
session.videoInputNotBefore = this.monotonicNow();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Return a fresh viewing state after acknowledged input navigated, without admitting another input. */
|
|
1119
|
+
private async completeNavigatingInput(
|
|
1120
|
+
session: BrowserSession,
|
|
1121
|
+
gesture: BrowserGesture,
|
|
1122
|
+
event: BrowserGestureEvent,
|
|
1123
|
+
eventAcknowledged: boolean,
|
|
1124
|
+
) {
|
|
1125
|
+
const state = await this.snapshotState(session, gesture.viewerToken);
|
|
1126
|
+
if (state.navigationGeneration === gesture.expected.navigationGeneration) return null;
|
|
1127
|
+
// Runtime inputGeneration is the private native attachment:document tuple.
|
|
1128
|
+
// A target replacement can also bump policy navigationGeneration, but must
|
|
1129
|
+
// never be accepted as same-attachment navigation after an acknowledged input.
|
|
1130
|
+
if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
|
|
1131
|
+
return null;
|
|
1132
|
+
|
|
1133
|
+
// Only the old document constraint may change after successful publication.
|
|
1134
|
+
// Takeover, viewport changes and runtime/bridge fences are still refused.
|
|
1135
|
+
this.requireMutationAccess(session, {
|
|
1136
|
+
viewerToken: gesture.viewerToken,
|
|
1137
|
+
controlToken: gesture.controlToken,
|
|
1138
|
+
expected: { ...gesture.expected, navigationGeneration: state.navigationGeneration },
|
|
1139
|
+
});
|
|
1140
|
+
if (!eventAcknowledged) gesture.acknowledge(event, this.now());
|
|
1141
|
+
await this.cancelGesture(session, gesture);
|
|
1142
|
+
const finalState = await this.snapshotState(session, gesture.viewerToken);
|
|
1143
|
+
if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
|
|
1144
|
+
return null;
|
|
1145
|
+
this.requireMutationAccess(session, {
|
|
1146
|
+
viewerToken: gesture.viewerToken,
|
|
1147
|
+
controlToken: gesture.controlToken,
|
|
1148
|
+
expected: { ...gesture.expected, navigationGeneration: finalState.navigationGeneration },
|
|
1149
|
+
});
|
|
1150
|
+
this.invalidateFrames(session);
|
|
1151
|
+
this.renewController(session, gesture.viewerToken);
|
|
1152
|
+
return {
|
|
1153
|
+
state: finalState,
|
|
1154
|
+
gestureId: gesture.id,
|
|
1155
|
+
nextSequence: gesture.nextSequence,
|
|
1156
|
+
cursor: null,
|
|
1157
|
+
completion: "navigation" as const,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Cancellation releases only this channel, even after a lost update reply or navigation drift. */
|
|
1162
|
+
async endGesture(input: EndGestureInput) {
|
|
1163
|
+
const session = this.requireViewer(input.viewerToken);
|
|
1164
|
+
return this.serialize(session, async () => {
|
|
1165
|
+
const gesture = session.gesture;
|
|
1166
|
+
if (!gesture && input.cancel) {
|
|
1167
|
+
this.requireController(session, input.viewerToken, input.controlToken);
|
|
1168
|
+
return { state: this.projectState(session, input.viewerToken), cursor: null };
|
|
1169
|
+
}
|
|
1170
|
+
const owned = this.requireGesture(session, input);
|
|
1171
|
+
try {
|
|
1172
|
+
if (!input.cancel) {
|
|
1173
|
+
this.assertGesturePacketContext(owned, input.expected);
|
|
1174
|
+
owned.assertSequence(input.sequence, this.now());
|
|
1175
|
+
await this.assertGestureCurrent(session, owned);
|
|
1176
|
+
}
|
|
1177
|
+
} finally {
|
|
1178
|
+
await this.cancelGesture(session, owned);
|
|
1179
|
+
}
|
|
1180
|
+
return { state: this.projectState(session, input.viewerToken), cursor: null };
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private requireGesture(
|
|
1185
|
+
session: BrowserSession,
|
|
1186
|
+
input: UpdateGestureInput | EndGestureInput,
|
|
1187
|
+
): BrowserGesture {
|
|
1188
|
+
const gesture = session.gesture;
|
|
1189
|
+
if (
|
|
1190
|
+
!gesture ||
|
|
1191
|
+
gesture.id !== input.gestureId ||
|
|
1192
|
+
gesture.viewerToken !== input.viewerToken ||
|
|
1193
|
+
gesture.controlToken !== input.controlToken
|
|
1194
|
+
)
|
|
1195
|
+
throw new Error("Browser gesture is unavailable");
|
|
1196
|
+
return gesture;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private assertGesturePacketContext(
|
|
1200
|
+
gesture: BrowserGesture,
|
|
1201
|
+
expected: BeginGestureInput["expected"],
|
|
1202
|
+
): void {
|
|
1203
|
+
// JSON member order is irrelevant; identity fields must match individually.
|
|
1204
|
+
for (const key of [
|
|
1205
|
+
"sessionId",
|
|
1206
|
+
"runtimeId",
|
|
1207
|
+
"bridgeEpoch",
|
|
1208
|
+
"navigationGeneration",
|
|
1209
|
+
"viewportGeneration",
|
|
1210
|
+
] as const) {
|
|
1211
|
+
if (gesture.expected[key] !== expected[key])
|
|
1212
|
+
throw new Error("Browser gesture context changed");
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
private async assertGestureCurrent(
|
|
1217
|
+
session: BrowserSession,
|
|
1218
|
+
gesture: BrowserGesture,
|
|
1219
|
+
): Promise<void> {
|
|
1220
|
+
this.requireMutationAccess(session, {
|
|
1221
|
+
viewerToken: gesture.viewerToken,
|
|
1222
|
+
controlToken: gesture.controlToken,
|
|
1223
|
+
expected: gesture.expected,
|
|
1224
|
+
});
|
|
1225
|
+
if (
|
|
1226
|
+
session.gesture !== gesture ||
|
|
1227
|
+
this.now() >= gesture.idleUntil ||
|
|
1228
|
+
this.now() >= gesture.expiresAt
|
|
1229
|
+
)
|
|
1230
|
+
throw new Error("Browser gesture expired");
|
|
1231
|
+
// Runtime check pins the CDP document/attachment without repeating a full
|
|
1232
|
+
// metadata read for every drag point. Status or a media-generation mismatch
|
|
1233
|
+
// reconciles metadata; acknowledged navigation uses its explicit completion.
|
|
1234
|
+
await this.request(session, "input.check", { gestureId: gesture.id });
|
|
1235
|
+
this.requireMutationAccess(session, {
|
|
1236
|
+
viewerToken: gesture.viewerToken,
|
|
1237
|
+
controlToken: gesture.controlToken,
|
|
1238
|
+
expected: gesture.expected,
|
|
1239
|
+
});
|
|
1240
|
+
if (session.gesture !== gesture) throw new Error("Browser gesture was cancelled");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
private async gestureCursor(session: BrowserSession, gesture: BrowserGesture) {
|
|
1244
|
+
if (!gesture.lastPoint || gesture.pointerKind !== "mouse") return null;
|
|
1245
|
+
try {
|
|
1246
|
+
const raw = await this.request(session, "cursor", {
|
|
1247
|
+
...gesture.lastPoint,
|
|
1248
|
+
gestureId: gesture.id,
|
|
1249
|
+
});
|
|
1250
|
+
const parsed = browserCursorSchema.safeParse(raw);
|
|
1251
|
+
return parsed.success ? parsed.data : null;
|
|
1252
|
+
} catch {
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private scheduleGestureTimeout(session: BrowserSession, gesture: BrowserGesture): void {
|
|
1258
|
+
if (gesture.timer) clearTimeout(gesture.timer);
|
|
1259
|
+
gesture.timer = setTimeout(
|
|
1260
|
+
() => {
|
|
1261
|
+
void this.serialize(session, async () => {
|
|
1262
|
+
if (
|
|
1263
|
+
session.gesture === gesture &&
|
|
1264
|
+
this.now() >= Math.min(gesture.idleUntil, gesture.expiresAt)
|
|
1265
|
+
) {
|
|
1266
|
+
await this.cancelGesture(session, gesture);
|
|
1267
|
+
}
|
|
1268
|
+
}).catch(() => undefined);
|
|
1269
|
+
},
|
|
1270
|
+
Math.max(1, Math.min(gesture.idleUntil, gesture.expiresAt) - this.now()),
|
|
1271
|
+
);
|
|
1272
|
+
gesture.timer.unref?.();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
private async cancelGesture(session: BrowserSession, expected?: BrowserGesture): Promise<void> {
|
|
1276
|
+
const gesture = session.gesture;
|
|
1277
|
+
if (!gesture || (expected && gesture !== expected)) return;
|
|
1278
|
+
session.gesture = null;
|
|
1279
|
+
if (gesture.timer) clearTimeout(gesture.timer);
|
|
1280
|
+
// Cleanup targets the runtime's original attachment, never a newly navigated
|
|
1281
|
+
// or replaced page. Failure is best-effort and cannot replay the input.
|
|
1282
|
+
try {
|
|
1283
|
+
await this.request(session, "input.end", { gestureId: gesture.id });
|
|
1284
|
+
} catch {
|
|
1285
|
+
/* Best-effort old-attachment cleanup must not escape teardown. */
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
411
1289
|
reset(): void {
|
|
412
1290
|
this.lifecycleGeneration += 1;
|
|
1291
|
+
for (const session of this.sessions.values()) {
|
|
1292
|
+
session.videoLifetime?.cancel();
|
|
1293
|
+
void this.cancelGesture(session);
|
|
1294
|
+
}
|
|
413
1295
|
this.sessions.clear();
|
|
414
1296
|
this.sessionCreations.clear();
|
|
415
1297
|
this.viewerSessions.clear();
|
|
1298
|
+
this.closedViewerWorkspaces.clear();
|
|
1299
|
+
this.closedViewerTabs.clear();
|
|
416
1300
|
}
|
|
417
1301
|
|
|
418
1302
|
disconnect(): void {
|
|
@@ -422,48 +1306,70 @@ export class SessionManager {
|
|
|
422
1306
|
this.reset();
|
|
423
1307
|
}
|
|
424
1308
|
|
|
425
|
-
private async getOrCreateSession(
|
|
1309
|
+
private async getOrCreateSession(
|
|
1310
|
+
workspaceId: string,
|
|
1311
|
+
tabId?: string,
|
|
1312
|
+
replaceLost = false,
|
|
1313
|
+
): Promise<BrowserSession> {
|
|
426
1314
|
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
427
|
-
const
|
|
1315
|
+
const closing = this.sessionClosures.get(workspaceId);
|
|
1316
|
+
if (closing) await closing.catch(() => undefined);
|
|
1317
|
+
if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
|
|
1318
|
+
const existing = [...this.sessions.values()].find(
|
|
1319
|
+
(session) => session.workspaceId === workspaceId && (!tabId || session.tabId === tabId),
|
|
1320
|
+
);
|
|
428
1321
|
if (existing) return existing;
|
|
429
|
-
|
|
430
|
-
|
|
1322
|
+
|
|
1323
|
+
// The launch page has no known target ID until identity returns. Wait for
|
|
1324
|
+
// another first attachment before deciding whether this target needs a
|
|
1325
|
+
// second policy session, so control and presence cannot split in two.
|
|
1326
|
+
const workspaceCreation = [...this.sessionCreations.entries()].find(
|
|
1327
|
+
([pendingKey]) => pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`),
|
|
1328
|
+
)?.[1];
|
|
1329
|
+
if (workspaceCreation) {
|
|
1330
|
+
await workspaceCreation.catch(() => undefined);
|
|
1331
|
+
return this.getOrCreateSession(workspaceId, tabId, replaceLost);
|
|
1332
|
+
}
|
|
1333
|
+
const key = tabId ? `${workspaceId}/${tabId}` : workspaceId;
|
|
431
1334
|
if (this.sessions.size + this.sessionCreations.size >= this.maxSessions)
|
|
432
1335
|
throw new Error(`Shared browser session limit (${this.maxSessions}) reached`);
|
|
433
1336
|
const lifecycleGeneration = this.lifecycleGeneration;
|
|
434
|
-
const creation = this.createSession(workspaceId);
|
|
435
|
-
this.sessionCreations.set(
|
|
1337
|
+
const creation = this.createSession(workspaceId, key, tabId, replaceLost);
|
|
1338
|
+
this.sessionCreations.set(key, creation);
|
|
436
1339
|
try {
|
|
437
1340
|
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);
|
|
1341
|
+
if (this.closed) throw new Error("Shared browser manager is closed");
|
|
1342
|
+
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
1343
|
+
if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
|
|
1344
|
+
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
|
1345
|
+
throw new Error("Shared browser manager was reset");
|
|
1346
|
+
}
|
|
1347
|
+
this.sessions.set(key, session);
|
|
451
1348
|
return session;
|
|
452
1349
|
} catch (error) {
|
|
453
1350
|
if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
|
|
454
1351
|
throw error;
|
|
455
1352
|
} finally {
|
|
456
|
-
if (this.sessionCreations.get(
|
|
457
|
-
this.sessionCreations.delete(workspaceId);
|
|
1353
|
+
if (this.sessionCreations.get(key) === creation) this.sessionCreations.delete(key);
|
|
458
1354
|
}
|
|
459
1355
|
}
|
|
460
1356
|
|
|
461
|
-
private async createSession(
|
|
462
|
-
|
|
1357
|
+
private async createSession(
|
|
1358
|
+
workspaceId: string,
|
|
1359
|
+
key: string,
|
|
1360
|
+
requestedTabId?: string,
|
|
1361
|
+
replaceLost = false,
|
|
1362
|
+
): Promise<BrowserSession> {
|
|
1363
|
+
let runtimeId: string | null = null;
|
|
463
1364
|
try {
|
|
464
|
-
const descriptor = await this.client.ensureWorkspace(workspaceId);
|
|
465
|
-
|
|
466
|
-
const
|
|
1365
|
+
const descriptor = await this.client.ensureWorkspace(workspaceId, { replaceLost });
|
|
1366
|
+
runtimeId = descriptor.runtimeId;
|
|
1367
|
+
const initialTarget = requestedTabId ? { targetId: requestedTabId } : null;
|
|
1368
|
+
const identity = asRecord(
|
|
1369
|
+
await this.client.requestWorkspace(workspaceId, "identity", initialTarget),
|
|
1370
|
+
);
|
|
1371
|
+
const tabId =
|
|
1372
|
+
requestedTabId ?? (typeof identity.targetId === "string" ? identity.targetId : null);
|
|
467
1373
|
const userAgent = boundedText(String(identity.userAgent ?? "Chromium"), 512);
|
|
468
1374
|
await this.client.requestWorkspace(workspaceId, "emulate", {
|
|
469
1375
|
...DEFAULT_VIEWPORT,
|
|
@@ -472,15 +1378,25 @@ export class SessionManager {
|
|
|
472
1378
|
touch: false,
|
|
473
1379
|
userAgent,
|
|
474
1380
|
platform: "",
|
|
1381
|
+
...(tabId ? { targetId: tabId } : {}),
|
|
475
1382
|
});
|
|
476
|
-
const state = asRecord(
|
|
1383
|
+
const state = asRecord(
|
|
1384
|
+
await this.client.requestWorkspace(
|
|
1385
|
+
workspaceId,
|
|
1386
|
+
"state",
|
|
1387
|
+
tabId ? { targetId: tabId } : null,
|
|
1388
|
+
),
|
|
1389
|
+
);
|
|
477
1390
|
return {
|
|
1391
|
+
key,
|
|
478
1392
|
workspaceId,
|
|
1393
|
+
tabId,
|
|
479
1394
|
sessionId: this.issueUniqueToken(),
|
|
480
1395
|
runtimeId: descriptor.runtimeId,
|
|
481
1396
|
runtimeCreatedAt: descriptor.createdAt,
|
|
482
1397
|
bridgeEpoch: this.bridgeEpoch,
|
|
483
1398
|
viewport: { ...DEFAULT_VIEWPORT },
|
|
1399
|
+
captureScale: 1,
|
|
484
1400
|
navigationGeneration: 0,
|
|
485
1401
|
viewportGeneration: 0,
|
|
486
1402
|
devicePresetId: null,
|
|
@@ -491,15 +1407,40 @@ export class SessionManager {
|
|
|
491
1407
|
mutationTail: Promise.resolve(),
|
|
492
1408
|
frameCache: new Map(),
|
|
493
1409
|
recentFrames: new Map(),
|
|
1410
|
+
frameRevision: 0,
|
|
1411
|
+
videoNotBefore: null,
|
|
1412
|
+
videoInputNotBefore: null,
|
|
1413
|
+
videoReads: new Set(),
|
|
1414
|
+
videoLifetime: null,
|
|
1415
|
+
videoReceipts: new Map(),
|
|
494
1416
|
lastUrl: boundedText(String(state.url ?? ""), 8192),
|
|
495
1417
|
lastTitle: boundedText(String(state.title ?? ""), 1024),
|
|
496
1418
|
canGoBack: Boolean(state.canGoBack),
|
|
497
1419
|
canGoForward: Boolean(state.canGoForward),
|
|
498
1420
|
error: null,
|
|
1421
|
+
notice:
|
|
1422
|
+
typeof identity.displayNotice === "string"
|
|
1423
|
+
? boundedText(identity.displayNotice, 256)
|
|
1424
|
+
: null,
|
|
1425
|
+
runtimeLost: false,
|
|
499
1426
|
archived: false,
|
|
1427
|
+
gesture: null,
|
|
1428
|
+
inputGeneration: typeof state.inputGeneration === "string" ? state.inputGeneration : null,
|
|
500
1429
|
};
|
|
501
1430
|
} catch (error) {
|
|
502
|
-
|
|
1431
|
+
const hasOtherSession = [...this.sessions.values()].some(
|
|
1432
|
+
(session) => session.workspaceId === workspaceId && session.key !== key,
|
|
1433
|
+
);
|
|
1434
|
+
const hasOtherCreation = [...this.sessionCreations.keys()].some(
|
|
1435
|
+
(pendingKey) =>
|
|
1436
|
+
pendingKey !== key &&
|
|
1437
|
+
(pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`)),
|
|
1438
|
+
);
|
|
1439
|
+
// An invalid explicit tab must never tear down a workspace runtime that
|
|
1440
|
+
// another participant may already be using without a policy session.
|
|
1441
|
+
if (runtimeId && !requestedTabId && !hasOtherSession && !hasOtherCreation) {
|
|
1442
|
+
await this.client.closeWorkspace(workspaceId, runtimeId).catch(() => undefined);
|
|
1443
|
+
}
|
|
503
1444
|
throw error;
|
|
504
1445
|
}
|
|
505
1446
|
}
|
|
@@ -510,12 +1451,62 @@ export class SessionManager {
|
|
|
510
1451
|
input: JsonValue,
|
|
511
1452
|
): Promise<JsonValue> {
|
|
512
1453
|
if (session.archived) throw new Error("Workspace was archived");
|
|
513
|
-
|
|
1454
|
+
const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
1455
|
+
return this.client
|
|
1456
|
+
.requestWorkspace(
|
|
1457
|
+
session.workspaceId,
|
|
1458
|
+
operation,
|
|
1459
|
+
session.tabId ? { ...data, targetId: session.tabId } : input,
|
|
1460
|
+
)
|
|
1461
|
+
.catch((error) => {
|
|
1462
|
+
if (error instanceof RuntimeProtocolError && error.code === "RUNTIME_LOST") {
|
|
1463
|
+
// The workspace's single browser is gone, so every page session of it is gone.
|
|
1464
|
+
session.runtimeLost = true;
|
|
1465
|
+
for (const candidate of this.sessions.values())
|
|
1466
|
+
if (candidate.workspaceId === session.workspaceId) candidate.runtimeLost = true;
|
|
1467
|
+
}
|
|
1468
|
+
throw error;
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Cleanup depends on viewer ownership, never on whether capture produced pixels. */
|
|
1473
|
+
private async stopUnusedVideo(session: BrowserSession): Promise<void> {
|
|
1474
|
+
if (session.viewers.size !== 0) return;
|
|
1475
|
+
session.videoLifetime?.cancel();
|
|
1476
|
+
await this.request(session, "video.stop", null).catch(() => undefined);
|
|
1477
|
+
this.invalidateFrames(session);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** Recheck leases under the same session queue before stopping optional capture. */
|
|
1481
|
+
private armVideoLifetime(session: BrowserSession): void {
|
|
1482
|
+
session.videoLifetime ??= createViewerCaptureLifetime({
|
|
1483
|
+
now: this.now,
|
|
1484
|
+
latestExpiry: () =>
|
|
1485
|
+
session.viewers.size > 0
|
|
1486
|
+
? Math.max(...[...session.viewers.values()].map((viewer) => viewer.expiresAt))
|
|
1487
|
+
: null,
|
|
1488
|
+
onExpired: () =>
|
|
1489
|
+
this.serialize(session, async () => {
|
|
1490
|
+
if (this.sessions.get(session.key) !== session || session.archived) return;
|
|
1491
|
+
this.pruneExpired(session);
|
|
1492
|
+
await this.stopUnusedVideo(session);
|
|
1493
|
+
}),
|
|
1494
|
+
});
|
|
1495
|
+
session.videoLifetime.arm();
|
|
514
1496
|
}
|
|
515
1497
|
|
|
516
1498
|
private requireViewer(token: string): BrowserSession {
|
|
1499
|
+
if (this.closedViewerWorkspaces.has(token)) throw new Error("Browser is closed");
|
|
1500
|
+
const closedTabExpiry = this.closedViewerTabs.get(token);
|
|
1501
|
+
if (closedTabExpiry !== undefined) {
|
|
1502
|
+
if (closedTabExpiry > this.now()) throw new Error("Browser tab is closed");
|
|
1503
|
+
this.closedViewerTabs.delete(token);
|
|
1504
|
+
}
|
|
517
1505
|
const session = this.viewerSessions.get(token);
|
|
518
|
-
|
|
1506
|
+
const viewer = session?.viewers.get(token);
|
|
1507
|
+
// Expiry is enforced at the authentication boundary itself: an unpruned lapsed lease
|
|
1508
|
+
// never authorizes an entrypoint that does not run pruneExpired first.
|
|
1509
|
+
if (!session || !viewer || viewer.expiresAt <= this.now())
|
|
519
1510
|
throw new Error("Viewer token is invalid or expired");
|
|
520
1511
|
return session;
|
|
521
1512
|
}
|
|
@@ -540,8 +1531,10 @@ export class SessionManager {
|
|
|
540
1531
|
if (
|
|
541
1532
|
session.controller &&
|
|
542
1533
|
(session.controller.expiresAt <= now || !session.viewers.has(session.controller.viewerToken))
|
|
543
|
-
)
|
|
1534
|
+
) {
|
|
544
1535
|
session.controller = null;
|
|
1536
|
+
void this.serialize(session, () => this.cancelGesture(session)).catch(() => undefined);
|
|
1537
|
+
}
|
|
545
1538
|
}
|
|
546
1539
|
private requireController(
|
|
547
1540
|
session: BrowserSession,
|
|
@@ -586,16 +1579,33 @@ export class SessionManager {
|
|
|
586
1579
|
session: BrowserSession,
|
|
587
1580
|
target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
|
|
588
1581
|
): void {
|
|
1582
|
+
// Retain diagnostic evidence before validation prunes expired receipts.
|
|
1583
|
+
// An absent receipt cannot distinguish expiry from an earlier revocation.
|
|
1584
|
+
const frame = session.recentFrames.get(target.frameId);
|
|
1585
|
+
if (this.isRecentFrame(session, target)) return;
|
|
1586
|
+
if (!frame) throw createBrowserFrameRejection("admission", "missing-or-revoked");
|
|
1587
|
+
|
|
1588
|
+
const expiresAt = frame.expiresAtMonotonicMs ?? frame.expiresAt;
|
|
1589
|
+
const now = frame.expiresAtMonotonicMs === undefined ? this.now() : this.monotonicNow();
|
|
1590
|
+
const ageMs = now - (expiresAt - FRAME_TOKEN_TTL_MS);
|
|
1591
|
+
const reason = expiresAt <= now ? "expired" : "context-changed";
|
|
1592
|
+
throw createBrowserFrameRejection("admission", reason, ageMs);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Check issued frame authority without admitting or cancelling a live channel. */
|
|
1596
|
+
private isRecentFrame(
|
|
1597
|
+
session: BrowserSession,
|
|
1598
|
+
target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
|
|
1599
|
+
): boolean {
|
|
589
1600
|
this.pruneRecentFrames(session);
|
|
590
1601
|
const frame = session.recentFrames.get(target.frameId);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
)
|
|
598
|
-
throw new Error("Browser frame is stale");
|
|
1602
|
+
return Boolean(
|
|
1603
|
+
frame &&
|
|
1604
|
+
target.navigationGeneration === session.navigationGeneration &&
|
|
1605
|
+
target.viewportGeneration === session.viewportGeneration &&
|
|
1606
|
+
frame.navigationGeneration === session.navigationGeneration &&
|
|
1607
|
+
frame.viewportGeneration === session.viewportGeneration,
|
|
1608
|
+
);
|
|
599
1609
|
}
|
|
600
1610
|
private renewController(session: BrowserSession, token: string): void {
|
|
601
1611
|
this.heartbeatViewer(session, token);
|
|
@@ -627,8 +1637,14 @@ export class SessionManager {
|
|
|
627
1637
|
}
|
|
628
1638
|
private pruneRecentFrames(session: BrowserSession): void {
|
|
629
1639
|
const now = this.now();
|
|
630
|
-
|
|
631
|
-
|
|
1640
|
+
const monotonicNow = this.monotonicNow();
|
|
1641
|
+
for (const [id, frame] of session.recentFrames) {
|
|
1642
|
+
const expired =
|
|
1643
|
+
frame.expiresAtMonotonicMs === undefined
|
|
1644
|
+
? frame.expiresAt <= now
|
|
1645
|
+
: frame.expiresAtMonotonicMs <= monotonicNow;
|
|
1646
|
+
if (expired) session.recentFrames.delete(id);
|
|
1647
|
+
}
|
|
632
1648
|
}
|
|
633
1649
|
private serialize<T>(session: BrowserSession, operation: () => Promise<T>): Promise<T> {
|
|
634
1650
|
const result = session.mutationTail.then(operation, operation);
|
|
@@ -639,8 +1655,12 @@ export class SessionManager {
|
|
|
639
1655
|
return result;
|
|
640
1656
|
}
|
|
641
1657
|
private invalidateFrames(session: BrowserSession): void {
|
|
1658
|
+
session.frameRevision += 1;
|
|
1659
|
+
session.videoNotBefore = this.monotonicNow();
|
|
1660
|
+
session.videoInputNotBefore = session.videoNotBefore;
|
|
642
1661
|
session.frameCache.clear();
|
|
643
1662
|
session.recentFrames.clear();
|
|
1663
|
+
session.videoReceipts.clear();
|
|
644
1664
|
}
|
|
645
1665
|
|
|
646
1666
|
private async frameForQuality(
|
|
@@ -650,26 +1670,25 @@ export class SessionManager {
|
|
|
650
1670
|
const cached = session.frameCache.get(quality);
|
|
651
1671
|
if (
|
|
652
1672
|
cached &&
|
|
653
|
-
cached.frame
|
|
654
|
-
cached.frame.viewportGeneration === session.viewportGeneration &&
|
|
1673
|
+
frameMatchesBrowserContext(cached.frame, session) &&
|
|
655
1674
|
this.now() - cached.cachedAt <= this.frameCacheMs
|
|
656
1675
|
)
|
|
657
1676
|
return cached.frame;
|
|
658
1677
|
const generation = {
|
|
1678
|
+
sessionId: session.sessionId,
|
|
1679
|
+
runtimeId: session.runtimeId,
|
|
1680
|
+
captureEpoch: session.bridgeEpoch,
|
|
659
1681
|
navigationGeneration: session.navigationGeneration,
|
|
660
1682
|
viewportGeneration: session.viewportGeneration,
|
|
661
1683
|
};
|
|
662
1684
|
const raw = asRecord(
|
|
663
1685
|
await this.request(session, "frame", {
|
|
664
|
-
maxBytes:
|
|
1686
|
+
maxBytes: FRAME_MAX_BYTES,
|
|
665
1687
|
quality: JPEG_QUALITY[quality],
|
|
666
1688
|
waitMs: SCREENCAST_WAIT_MS,
|
|
667
1689
|
}),
|
|
668
1690
|
);
|
|
669
|
-
if (
|
|
670
|
-
generation.navigationGeneration !== session.navigationGeneration ||
|
|
671
|
-
generation.viewportGeneration !== session.viewportGeneration
|
|
672
|
-
)
|
|
1691
|
+
if (!frameMatchesBrowserContext(generation, session))
|
|
673
1692
|
throw new Error("Browser changed during capture; request another frame");
|
|
674
1693
|
const byteLength = Number(raw.byteLength);
|
|
675
1694
|
const dataBase64 = String(raw.dataBase64 ?? "");
|
|
@@ -680,8 +1699,33 @@ export class SessionManager {
|
|
|
680
1699
|
Buffer.byteLength(dataBase64, "base64") !== byteLength
|
|
681
1700
|
)
|
|
682
1701
|
throw new Error("Browser returned an invalid frame");
|
|
1702
|
+
const expectedPixels = captureDimensions(session.viewport, session.captureScale);
|
|
1703
|
+
if (Number(raw.width) !== expectedPixels.width || Number(raw.height) !== expectedPixels.height)
|
|
1704
|
+
throw new Error("Browser returned a frame for a stale viewport");
|
|
1705
|
+
|
|
1706
|
+
// Runtime fallback receipts remain fresh for at most one second. A 100ms
|
|
1707
|
+
// policy-cache expiry must not create a new token and resend the same large
|
|
1708
|
+
// JPEG at every poll. Reuse only its exact receipt, not merely equal pixels.
|
|
1709
|
+
if (
|
|
1710
|
+
cached &&
|
|
1711
|
+
typeof raw.capturedAt === "string" &&
|
|
1712
|
+
Number.isFinite(Date.parse(raw.capturedAt)) &&
|
|
1713
|
+
(raw.transport === "screenshot" || raw.transport === "cdp-screencast") &&
|
|
1714
|
+
session.frameCache.get(quality) === cached &&
|
|
1715
|
+
cached.frame.sessionId === generation.sessionId &&
|
|
1716
|
+
cached.frame.runtimeId === generation.runtimeId &&
|
|
1717
|
+
cached.frame.captureEpoch === generation.captureEpoch &&
|
|
1718
|
+
cached.frame.navigationGeneration === generation.navigationGeneration &&
|
|
1719
|
+
cached.frame.viewportGeneration === generation.viewportGeneration &&
|
|
1720
|
+
(["dataBase64", "byteLength", "width", "height", "capturedAt", "transport"] as const).every(
|
|
1721
|
+
(key) => cached.rawFrame[key] === raw[key],
|
|
1722
|
+
)
|
|
1723
|
+
) {
|
|
1724
|
+
cached.cachedAt = this.now();
|
|
1725
|
+
return cached.frame;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
683
1728
|
const frame: BrowserFrame = {
|
|
684
|
-
sessionId: session.sessionId,
|
|
685
1729
|
frameId: this.issueUniqueToken(),
|
|
686
1730
|
mimeType: "image/jpeg",
|
|
687
1731
|
transport: raw.transport === "cdp-screencast" ? "cdp-screencast" : "screenshot",
|
|
@@ -690,66 +1734,152 @@ export class SessionManager {
|
|
|
690
1734
|
width: Number(raw.width),
|
|
691
1735
|
height: Number(raw.height),
|
|
692
1736
|
...generation,
|
|
693
|
-
runtimeId: session.runtimeId,
|
|
694
|
-
captureEpoch: session.bridgeEpoch,
|
|
695
1737
|
capturedAt: String(raw.capturedAt),
|
|
696
1738
|
};
|
|
697
|
-
|
|
698
|
-
throw new Error("Browser returned a frame for a stale viewport");
|
|
699
|
-
session.frameCache.set(quality, { frame, cachedAt: this.now() });
|
|
1739
|
+
session.frameCache.set(quality, { frame, cachedAt: this.now(), rawFrame: { ...raw } });
|
|
700
1740
|
return frame;
|
|
701
1741
|
}
|
|
702
1742
|
|
|
703
|
-
|
|
1743
|
+
/** Native source clocks have bounded uncertainty. Displaying a late packet
|
|
1744
|
+
* must never resurrect the press token revoked by acknowledged live input.
|
|
1745
|
+
* JPEG receipts retain the runtime's existing post-input capture-generation fence. */
|
|
1746
|
+
private rememberFrame(
|
|
1747
|
+
session: BrowserSession,
|
|
1748
|
+
frame: BrowserFrameAuthority,
|
|
1749
|
+
capturedAtMonotonicMs?: number,
|
|
1750
|
+
observedAt = this.now(),
|
|
1751
|
+
): boolean {
|
|
1752
|
+
if (capturedAtMonotonicMs !== undefined && session.videoInputNotBefore !== null) {
|
|
1753
|
+
const exclusiveFloor = session.videoInputNotBefore + VIDEO_SOURCE_CLOCK_TOLERANCE_MS;
|
|
1754
|
+
if (!Number.isFinite(capturedAtMonotonicMs) || capturedAtMonotonicMs <= exclusiveFloor) {
|
|
1755
|
+
session.recentFrames.delete(frame.frameId);
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
704
1759
|
this.pruneRecentFrames(session);
|
|
705
1760
|
session.recentFrames.set(frame.frameId, {
|
|
706
1761
|
navigationGeneration: frame.navigationGeneration,
|
|
707
1762
|
viewportGeneration: frame.viewportGeneration,
|
|
708
|
-
expiresAt:
|
|
1763
|
+
expiresAt: observedAt + FRAME_TOKEN_TTL_MS,
|
|
1764
|
+
...(capturedAtMonotonicMs === undefined
|
|
1765
|
+
? {}
|
|
1766
|
+
: {
|
|
1767
|
+
expiresAtMonotonicMs: this.monotonicNow() + FRAME_TOKEN_TTL_MS,
|
|
1768
|
+
}),
|
|
709
1769
|
});
|
|
710
1770
|
while (session.recentFrames.size > MAX_RECENT_FRAMES) {
|
|
711
1771
|
const oldest = session.recentFrames.keys().next().value;
|
|
712
1772
|
if (!oldest) break;
|
|
713
1773
|
session.recentFrames.delete(oldest);
|
|
714
1774
|
}
|
|
1775
|
+
return session.recentFrames.has(frame.frameId);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Retain one freshly admitted observation for this operation only. Runtime
|
|
1780
|
+
* latency cannot expire it after publication; control, native document and
|
|
1781
|
+
* every attachment/display fence still revoke it. No continuation is exposed.
|
|
1782
|
+
*/
|
|
1783
|
+
private discreteInputGuard(session: BrowserSession, input: SendInput): () => Promise<void> {
|
|
1784
|
+
const context = {
|
|
1785
|
+
viewerToken: input.viewerToken,
|
|
1786
|
+
controlToken: input.controlToken,
|
|
1787
|
+
expected: {
|
|
1788
|
+
sessionId: session.sessionId,
|
|
1789
|
+
runtimeId: session.runtimeId,
|
|
1790
|
+
bridgeEpoch: session.bridgeEpoch,
|
|
1791
|
+
navigationGeneration: session.navigationGeneration,
|
|
1792
|
+
viewportGeneration: session.viewportGeneration,
|
|
1793
|
+
},
|
|
1794
|
+
};
|
|
1795
|
+
const inputGeneration = session.inputGeneration;
|
|
1796
|
+
const revision = session.frameRevision;
|
|
1797
|
+
return async () => {
|
|
1798
|
+
await this.refreshPageMetadata(session);
|
|
1799
|
+
if (
|
|
1800
|
+
inputGeneration !== session.inputGeneration ||
|
|
1801
|
+
revision !== session.frameRevision ||
|
|
1802
|
+
context.expected.navigationGeneration !== session.navigationGeneration ||
|
|
1803
|
+
context.expected.viewportGeneration !== session.viewportGeneration
|
|
1804
|
+
) {
|
|
1805
|
+
throw createBrowserFrameRejection("admitted-input", "context-changed");
|
|
1806
|
+
}
|
|
1807
|
+
this.requireMutationAccess(session, context);
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Every discrete native primitive (text, key, press, hover, wheel) runs on one private
|
|
1812
|
+
* channel pinned to the original attachment and document, which the runtime re-checks
|
|
1813
|
+
* before each publication. The ID is never returned to callers and grants no agent
|
|
1814
|
+
* continuation. Native end uses the original page even after navigation, revocation
|
|
1815
|
+
* or a lost down ACK. */
|
|
1816
|
+
private async dispatchDiscreteInput(
|
|
1817
|
+
session: BrowserSession,
|
|
1818
|
+
event: BrowserInputEvent,
|
|
1819
|
+
assertTargetCurrent: () => Promise<void>,
|
|
1820
|
+
): Promise<void> {
|
|
1821
|
+
// Reconcile the captured document before anything is published, hover included, so a
|
|
1822
|
+
// stalled or failed metadata read holds back every native primitive.
|
|
1823
|
+
await assertTargetCurrent();
|
|
1824
|
+
if (session.inputGeneration === null) {
|
|
1825
|
+
throw new Error("Browser input identity is unavailable");
|
|
1826
|
+
}
|
|
1827
|
+
const gestureId = this.issueUniqueToken();
|
|
1828
|
+
await runWithInputCleanup(
|
|
1829
|
+
async () => {
|
|
1830
|
+
await this.request(session, "input.begin", {
|
|
1831
|
+
gestureId,
|
|
1832
|
+
expectedInputGeneration: session.inputGeneration,
|
|
1833
|
+
});
|
|
1834
|
+
await this.dispatchInput(session, event, assertTargetCurrent, gestureId);
|
|
1835
|
+
},
|
|
1836
|
+
() => this.request(session, "input.end", { gestureId }),
|
|
1837
|
+
);
|
|
715
1838
|
}
|
|
716
1839
|
|
|
717
1840
|
private async dispatchInput(
|
|
718
1841
|
session: BrowserSession,
|
|
719
1842
|
event: BrowserInputEvent,
|
|
720
|
-
|
|
1843
|
+
assertTargetCurrent: () => Promise<void>,
|
|
1844
|
+
gestureId: string,
|
|
721
1845
|
): Promise<void> {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
await this.request(session, "text.insert", { text: event.text });
|
|
1846
|
+
// The document was reconciled and pinned by dispatchDiscreteInput; the runtime refuses
|
|
1847
|
+
// every primitive below once its attachment or document differs from that pin.
|
|
1848
|
+
if (event.kind === "move") {
|
|
1849
|
+
const point = mapDisplayedPoint(event.point, session.viewport);
|
|
1850
|
+
await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
|
|
728
1851
|
return;
|
|
729
1852
|
}
|
|
730
|
-
if (event.kind === "
|
|
731
|
-
await this.request(session, "
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
await this.request(session, "key.up", { key: event.key });
|
|
736
|
-
}
|
|
1853
|
+
if (event.kind === "type") {
|
|
1854
|
+
await this.request(session, "text.insert", {
|
|
1855
|
+
text: event.text,
|
|
1856
|
+
gestureId,
|
|
1857
|
+
});
|
|
737
1858
|
return;
|
|
738
1859
|
}
|
|
739
|
-
if (event.kind === "
|
|
740
|
-
|
|
741
|
-
|
|
1860
|
+
if (event.kind === "key") {
|
|
1861
|
+
await this.request(session, "key.down", {
|
|
1862
|
+
key: event.key,
|
|
1863
|
+
gestureId,
|
|
1864
|
+
});
|
|
1865
|
+
await runWithInputCleanup(assertTargetCurrent, () =>
|
|
1866
|
+
this.request(session, "key.up", {
|
|
1867
|
+
key: event.key,
|
|
1868
|
+
gestureId,
|
|
1869
|
+
}),
|
|
1870
|
+
);
|
|
742
1871
|
return;
|
|
743
1872
|
}
|
|
744
1873
|
if (event.kind === "scroll") {
|
|
745
1874
|
const point = mapDisplayedPoint(event.point, session.viewport);
|
|
746
|
-
await this.request(session, "mouse.move", { x: point.x, y: point.y });
|
|
1875
|
+
await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
|
|
747
1876
|
await assertTargetCurrent();
|
|
748
1877
|
await this.request(session, "mouse.wheel", {
|
|
749
1878
|
x: point.x,
|
|
750
1879
|
y: point.y,
|
|
751
1880
|
deltaX: event.deltaX,
|
|
752
1881
|
deltaY: event.deltaY,
|
|
1882
|
+
gestureId,
|
|
753
1883
|
});
|
|
754
1884
|
return;
|
|
755
1885
|
}
|
|
@@ -757,7 +1887,11 @@ export class SessionManager {
|
|
|
757
1887
|
event.kind === "drag" ? event.start : event.point,
|
|
758
1888
|
session.viewport,
|
|
759
1889
|
);
|
|
760
|
-
await this.request(session, "mouse.move", {
|
|
1890
|
+
await this.request(session, "mouse.move", {
|
|
1891
|
+
x: start.x,
|
|
1892
|
+
y: start.y,
|
|
1893
|
+
gestureId,
|
|
1894
|
+
});
|
|
761
1895
|
await assertTargetCurrent();
|
|
762
1896
|
const count = event.kind === "click" ? event.clickCount : 1;
|
|
763
1897
|
for (let clickCount = 1; clickCount <= count; clickCount += 1) {
|
|
@@ -766,54 +1900,83 @@ export class SessionManager {
|
|
|
766
1900
|
y: start.y,
|
|
767
1901
|
button: event.button,
|
|
768
1902
|
clickCount,
|
|
1903
|
+
gestureId,
|
|
769
1904
|
});
|
|
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 });
|
|
1905
|
+
await runWithInputCleanup(
|
|
1906
|
+
async () => {
|
|
775
1907
|
await assertTargetCurrent();
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1908
|
+
if (event.kind === "drag") {
|
|
1909
|
+
const end = mapDisplayedPoint(event.end, session.viewport);
|
|
1910
|
+
await this.request(session, "mouse.move", {
|
|
1911
|
+
x: end.x,
|
|
1912
|
+
y: end.y,
|
|
1913
|
+
gestureId,
|
|
1914
|
+
});
|
|
1915
|
+
await assertTargetCurrent();
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
async () => {
|
|
1919
|
+
const end =
|
|
1920
|
+
event.kind === "drag" ? mapDisplayedPoint(event.end, session.viewport) : start;
|
|
1921
|
+
await this.request(session, "mouse.up", {
|
|
1922
|
+
x: end.x,
|
|
1923
|
+
y: end.y,
|
|
1924
|
+
button: event.button,
|
|
1925
|
+
clickCount,
|
|
1926
|
+
gestureId,
|
|
1927
|
+
});
|
|
1928
|
+
},
|
|
1929
|
+
);
|
|
786
1930
|
}
|
|
787
1931
|
}
|
|
788
1932
|
|
|
789
|
-
private async refreshPageMetadata(session: BrowserSession): Promise<void> {
|
|
790
|
-
const raw = asRecord(
|
|
1933
|
+
private async refreshPageMetadata(session: BrowserSession, timeoutMs?: number): Promise<void> {
|
|
1934
|
+
const raw = asRecord(
|
|
1935
|
+
await this.request(session, "state", timeoutMs === undefined ? null : { timeoutMs }),
|
|
1936
|
+
);
|
|
791
1937
|
const url = boundedText(String(raw.url ?? ""), 8192);
|
|
792
|
-
|
|
1938
|
+
const inputGeneration = typeof raw.inputGeneration === "string" ? raw.inputGeneration : null;
|
|
1939
|
+
const documentChanged =
|
|
1940
|
+
session.inputGeneration !== null && inputGeneration !== session.inputGeneration;
|
|
1941
|
+
if ((session.lastUrl && session.lastUrl !== url) || documentChanged) {
|
|
1942
|
+
await this.cancelGesture(session);
|
|
793
1943
|
session.navigationGeneration += 1;
|
|
794
1944
|
this.invalidateFrames(session);
|
|
795
1945
|
}
|
|
1946
|
+
session.inputGeneration = inputGeneration;
|
|
796
1947
|
session.lastUrl = url;
|
|
797
1948
|
session.lastTitle = boundedText(String(raw.title ?? ""), 1024);
|
|
798
1949
|
session.canGoBack = Boolean(raw.canGoBack);
|
|
799
1950
|
session.canGoForward = Boolean(raw.canGoForward);
|
|
800
1951
|
}
|
|
801
1952
|
|
|
802
|
-
private async snapshotState(
|
|
1953
|
+
private async snapshotState(
|
|
1954
|
+
session: BrowserSession,
|
|
1955
|
+
viewerToken: string,
|
|
1956
|
+
timeoutMs?: number,
|
|
1957
|
+
): Promise<BrowserState> {
|
|
803
1958
|
this.pruneExpired(session);
|
|
804
1959
|
if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
|
|
805
1960
|
try {
|
|
806
|
-
await this.refreshPageMetadata(session);
|
|
1961
|
+
await this.refreshPageMetadata(session, timeoutMs);
|
|
807
1962
|
session.error = null;
|
|
808
1963
|
} catch (error) {
|
|
809
1964
|
session.error = boundedText(error instanceof Error ? error.message : String(error), 2048);
|
|
810
1965
|
}
|
|
1966
|
+
return this.projectState(session, viewerToken);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** Project the last reconciled state without a page RPC. Media generations independently fence late pixels. */
|
|
1970
|
+
private projectState(session: BrowserSession, viewerToken: string): BrowserState {
|
|
1971
|
+
this.pruneExpired(session);
|
|
1972
|
+
if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
|
|
811
1973
|
const controller = session.controller;
|
|
812
1974
|
const controllerViewer = controller ? session.viewers.get(controller.viewerToken) : null;
|
|
813
1975
|
return {
|
|
814
1976
|
sessionId: session.sessionId,
|
|
815
1977
|
workspaceId: session.workspaceId,
|
|
816
1978
|
runtimeId: session.runtimeId,
|
|
1979
|
+
...(session.tabId ? { tabId: session.tabId } : {}),
|
|
817
1980
|
runtimeCreatedAt: session.runtimeCreatedAt,
|
|
818
1981
|
bridgeEpoch: session.bridgeEpoch,
|
|
819
1982
|
status: session.error ? "error" : "ready",
|
|
@@ -822,6 +1985,7 @@ export class SessionManager {
|
|
|
822
1985
|
canGoBack: session.canGoBack,
|
|
823
1986
|
canGoForward: session.canGoForward,
|
|
824
1987
|
viewport: { ...session.viewport },
|
|
1988
|
+
captureScale: session.captureScale,
|
|
825
1989
|
navigationGeneration: session.navigationGeneration,
|
|
826
1990
|
viewportGeneration: session.viewportGeneration,
|
|
827
1991
|
devicePresetId: session.devicePresetId,
|
|
@@ -831,6 +1995,7 @@ export class SessionManager {
|
|
|
831
1995
|
controllerExpiresAt: controller ? new Date(controller.expiresAt).toISOString() : null,
|
|
832
1996
|
viewerCount: session.viewers.size,
|
|
833
1997
|
error: session.error,
|
|
1998
|
+
...(session.notice ? { notice: session.notice } : {}),
|
|
834
1999
|
};
|
|
835
2000
|
}
|
|
836
2001
|
}
|