@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.237.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/package.json +9 -5
  82. package/paseo-plugin.json +3 -1
  83. package/scripts/prepare-runtime.mjs +1 -1
  84. package/server/agent-browser-runtime.ts +1598 -314
  85. package/server/browser-cursor.test.ts +64 -0
  86. package/server/browser-cursor.ts +69 -0
  87. package/server/browser-frame-context.ts +22 -0
  88. package/server/browser-frame-epoch.test.ts +95 -0
  89. package/server/browser-frame-rejection.ts +17 -0
  90. package/server/browser-gesture.ts +120 -0
  91. package/server/browser-operation-diagnostics.test.ts +79 -0
  92. package/server/browser-operation-diagnostics.ts +103 -0
  93. package/server/browser-policy.ts +1313 -151
  94. package/server/browser-video-input-authority.test.ts +208 -0
  95. package/server/browser-video-policy.test.ts +564 -0
  96. package/server/browser-video-socket.test.ts +368 -0
  97. package/server/browser.ts +151 -16
  98. package/server/capture-transport-policy.test.ts +105 -0
  99. package/server/capture-transport-policy.ts +154 -0
  100. package/server/capture-transport-runtime.test.ts +213 -0
  101. package/server/daemon-launch-chain.test.ts +70 -0
  102. package/server/daemon-shutdown.test.ts +289 -0
  103. package/server/discrete-input-freshness.test.ts +364 -0
  104. package/server/discrete-input-native-lifetime.test.ts +150 -0
  105. package/server/display-loss-recovery.test.ts +294 -0
  106. package/server/emulation-restoration.test.ts +262 -0
  107. package/server/input-cleanup.ts +23 -0
  108. package/server/input-generation.test.ts +31 -0
  109. package/server/input-generation.ts +36 -0
  110. package/server/jpeg-capture-demand.test.ts +76 -0
  111. package/server/jpeg-capture-demand.ts +98 -0
  112. package/server/jpeg-frame.ts +29 -0
  113. package/server/keyboard-input.test.ts +61 -0
  114. package/server/keyboard-input.ts +104 -0
  115. package/server/live-input-policy.test.ts +958 -0
  116. package/server/live-input-runtime.test.ts +732 -0
  117. package/server/live-input-supervisor.test.ts +147 -0
  118. package/server/mcp-entry.ts +44 -2
  119. package/server/native-video-backpressure.test.ts +228 -0
  120. package/server/native-video-buffer.test.ts +141 -0
  121. package/server/native-video-buffer.ts +114 -0
  122. package/server/native-video-capture.test.ts +599 -0
  123. package/server/native-video-capture.ts +497 -0
  124. package/server/native-video-extension-source.test.ts +201 -0
  125. package/server/native-video-extension-source.ts +364 -0
  126. package/server/native-video-extension.test.ts +41 -0
  127. package/server/native-video-extension.ts +83 -0
  128. package/server/native-video-packet.ts +9 -0
  129. package/server/native-video-settings.test.ts +245 -0
  130. package/server/navigation-budget.ts +8 -0
  131. package/server/navigation-commit.ts +125 -0
  132. package/server/navigation-policy.test.ts +153 -0
  133. package/server/process-identity.ts +58 -0
  134. package/server/reattach-held-input.test.ts +143 -0
  135. package/server/runtime-frame-reuse.test.ts +295 -0
  136. package/server/runtime-jpeg-demand.test.ts +200 -0
  137. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  138. package/server/runtime-jpeg-source-order.test.ts +201 -0
  139. package/server/runtime-launch-environment.test.ts +111 -0
  140. package/server/runtime-navigation.test.ts +327 -0
  141. package/server/runtime-owner-display.test.ts +152 -0
  142. package/server/runtime-owner.ts +305 -31
  143. package/server/runtime-page-state.test.ts +249 -0
  144. package/server/runtime-protocol.ts +31 -1
  145. package/server/runtime-target-retention.test.ts +181 -0
  146. package/server/runtime-video-opt-in.test.ts +82 -0
  147. package/server/runtime-video.test.ts +444 -0
  148. package/server/screencast-source-time.ts +46 -0
  149. package/server/stale-wheel-admission.test.ts +136 -0
  150. package/server/supervisor-client.ts +28 -4
  151. package/server/supervisor-heartbeat.test.ts +80 -0
  152. package/server/supervisor.ts +553 -55
  153. package/server/video-source-recovery.test.ts +20 -0
  154. package/server/video-source-recovery.ts +64 -0
  155. package/server/viewer-capture-lifetime.ts +51 -0
  156. package/server/virtual-display.test.ts +184 -0
  157. package/server/virtual-display.ts +236 -0
  158. package/shared/browser-display-preferences.ts +46 -0
  159. package/shared/browser-video.ts +80 -0
  160. package/shared/browser.ts +286 -65
  161. package/shared/capture-density.ts +16 -0
  162. package/shared/capture-settings.ts +17 -0
  163. package/shared/device-presets.ts +90 -0
  164. package/shared/resolution-menu.test.ts +120 -0
  165. package/shared/resolution-menu.ts +72 -0
  166. package/shared/video-settings.test.ts +38 -0
  167. package/shared/video-settings.ts +34 -0
  168. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Owns bounded video decode/presentation and correlates each rendered source
3
+ * timestamp with its server-issued input authority. The web adapter supplies
4
+ * codecs and drawing; this module has no DOM globals and remains Hermes-safe.
5
+ */
6
+ import type { BrowserVideoPacket } from "../shared/browser-video";
7
+
8
+ export interface DecodedBrowserVideoFrame {
9
+ readonly timestamp: number;
10
+ readonly displayWidth: number;
11
+ readonly displayHeight: number;
12
+ close(): void;
13
+ }
14
+ export interface BrowserVideoCodec {
15
+ readonly decodeQueueSize: number;
16
+ configure(config: {
17
+ codec: string;
18
+ codedWidth: number;
19
+ codedHeight: number;
20
+ description?: Uint8Array;
21
+ optimizeForLatency: boolean;
22
+ }): void;
23
+ decode(chunk: unknown): void;
24
+ close(): void;
25
+ }
26
+ export interface BrowserVideoDecodeEnvironment {
27
+ createDecoder(callbacks: {
28
+ output(frame: DecodedBrowserVideoFrame): void;
29
+ error(error: unknown): void;
30
+ /** Native queue capacity can free without producing a frame. */
31
+ dequeue(): void;
32
+ }): BrowserVideoCodec;
33
+ /** Native bridges already carry base64; avoid decoding and re-encoding each packet in Hermes. */
34
+ createEncodedChunk?(input: {
35
+ type: "key" | "delta";
36
+ timestamp: number;
37
+ dataBase64: string;
38
+ }): unknown;
39
+ createChunk(input: { type: "key" | "delta"; timestamp: number; data: Uint8Array }): unknown;
40
+ decodeBase64(data: string): Uint8Array;
41
+ /** Schedule a draw on the next local paint; return an idempotent cancellation. */
42
+ scheduleDraw(draw: () => void): () => void;
43
+ draw(frame: DecodedBrowserVideoFrame): void | Promise<void>;
44
+ }
45
+ export interface BrowserVideoDecoderOptions {
46
+ environment: BrowserVideoDecodeEnvironment;
47
+ isCurrent(packet: BrowserVideoPacket, mutationEpoch: number): boolean;
48
+ /** Optional exact current source/document/geometry check, independent of a
49
+ * local press epoch. Allows continuous visual decode without granting input. */
50
+ isSourceCurrent?(packet: BrowserVideoPacket): boolean;
51
+ /** Carries the original read epoch; caller separately admits input authority. */
52
+ onPresented(packet: BrowserVideoPacket, mutationEpoch: number): void;
53
+ onNeedKeyFrame(): void;
54
+ onError(error: unknown): void;
55
+ }
56
+
57
+ const MAX_PENDING_FRAMES = 64;
58
+ const MAX_DECODE_QUEUE = 4;
59
+ const MAX_BUFFERED_BASE64_CHARACTERS = 8 * 1024 * 1024;
60
+
61
+ /** Decode a bounded continuous source chain. A caller opting into source-only
62
+ * visuals must keep the original epoch when separately admitting input. */
63
+ export function createBrowserVideoDecoder(options: BrowserVideoDecoderOptions) {
64
+ const { environment } = options;
65
+ const canPresent = (packet: BrowserVideoPacket, epoch: number) =>
66
+ options.isSourceCurrent ? options.isSourceCurrent(packet) : options.isCurrent(packet, epoch);
67
+ let closed = false;
68
+ let codec: BrowserVideoCodec | null = null;
69
+ let decoderGeneration = 0;
70
+ let streamKey: string | null = null;
71
+ let sequence = 0;
72
+ let lastPresentedTimestamp = -1;
73
+ let scheduled: (() => void) | null = null;
74
+ let painting = false;
75
+ let paint: { frame: DecodedBrowserVideoFrame; packet: BrowserVideoPacket; epoch: number } | null =
76
+ null;
77
+ const pending = new Map<number, { packet: BrowserVideoPacket; epoch: number }>();
78
+ const queued: { packet: BrowserVideoPacket; epoch: number }[] = [];
79
+ let bufferedCharacters = 0;
80
+ let draining = false;
81
+
82
+ const reset = () => {
83
+ decoderGeneration += 1;
84
+ scheduled?.();
85
+ scheduled = null;
86
+ paint?.frame.close();
87
+ paint = null;
88
+ pending.clear();
89
+ queued.length = 0;
90
+ bufferedCharacters = 0;
91
+ try {
92
+ codec?.close();
93
+ } catch {
94
+ /* Closing an errored decoder is best-effort. */
95
+ }
96
+ codec = null;
97
+ streamKey = null;
98
+ sequence = 0;
99
+ lastPresentedTimestamp = -1;
100
+ };
101
+
102
+ const fail = (error: unknown) => {
103
+ if (closed) return;
104
+ reset();
105
+ options.onError(error);
106
+ };
107
+
108
+ /** Preserve the dependency chain while the native decoder is busy. Both actual
109
+ * output and dequeue wake this bounded FIFO; no timer or repeated key reset is
110
+ * needed merely because a relay delivered a normal packet batch at once. */
111
+ const drain = () => {
112
+ if (draining || closed) return;
113
+ draining = true;
114
+ try {
115
+ while (codec && codec.decodeQueueSize < MAX_DECODE_QUEUE && queued.length) {
116
+ const entry = queued.shift()!;
117
+ if (!canPresent(entry.packet, entry.epoch)) {
118
+ reset();
119
+ options.onNeedKeyFrame();
120
+ return;
121
+ }
122
+ pending.set(entry.packet.timestampUs, entry);
123
+ let chunk: unknown;
124
+ if (environment.createEncodedChunk) {
125
+ chunk = environment.createEncodedChunk({
126
+ type: entry.packet.type,
127
+ timestamp: entry.packet.timestampUs,
128
+ dataBase64: entry.packet.dataBase64,
129
+ });
130
+ } else {
131
+ chunk = environment.createChunk({
132
+ type: entry.packet.type,
133
+ timestamp: entry.packet.timestampUs,
134
+ data: environment.decodeBase64(entry.packet.dataBase64),
135
+ });
136
+ }
137
+ codec.decode(chunk);
138
+ }
139
+ } catch (error) {
140
+ fail(error);
141
+ } finally {
142
+ draining = false;
143
+ }
144
+ };
145
+
146
+ const output = (frame: DecodedBrowserVideoFrame, generation: number) => {
147
+ if (generation !== decoderGeneration) {
148
+ frame.close();
149
+ return;
150
+ }
151
+ const entry = pending.get(frame.timestamp);
152
+ pending.delete(frame.timestamp);
153
+ if (entry) bufferedCharacters -= entry.packet.dataBase64.length;
154
+ drain();
155
+ if (
156
+ closed ||
157
+ generation !== decoderGeneration ||
158
+ !entry ||
159
+ !canPresent(entry.packet, entry.epoch) ||
160
+ frame.timestamp <= lastPresentedTimestamp
161
+ ) {
162
+ frame.close();
163
+ return;
164
+ }
165
+ if (frame.displayWidth !== entry.packet.width || frame.displayHeight !== entry.packet.height) {
166
+ frame.close();
167
+ fail(new Error("Decoded video dimensions do not match the current browser view."));
168
+ return;
169
+ }
170
+ if (paint && frame.timestamp <= paint.frame.timestamp) {
171
+ frame.close();
172
+ return;
173
+ }
174
+ // Keep only the newest decoded frame while the local compositor is delayed.
175
+ paint?.frame.close();
176
+ paint = { frame, packet: entry.packet, epoch: entry.epoch };
177
+ schedulePaint();
178
+ };
179
+
180
+ /** Native paint crosses an asynchronous bridge. Serialize presentations so a
181
+ * newer frame cannot replace the pixels while an older receipt is admitted. */
182
+ const schedulePaint = () => {
183
+ if (scheduled || painting || !paint || closed) return;
184
+ const generation = decoderGeneration;
185
+ scheduled = environment.scheduleDraw(() => {
186
+ scheduled = null;
187
+ const selected = paint;
188
+ paint = null;
189
+ if (!selected) return;
190
+ const complete = () => {
191
+ if (
192
+ !closed &&
193
+ generation === decoderGeneration &&
194
+ canPresent(selected.packet, selected.epoch)
195
+ ) {
196
+ lastPresentedTimestamp = selected.frame.timestamp;
197
+ options.onPresented(selected.packet, selected.epoch);
198
+ }
199
+ };
200
+ const release = () => {
201
+ selected.frame.close();
202
+ painting = false;
203
+ schedulePaint();
204
+ };
205
+ try {
206
+ if (
207
+ closed ||
208
+ generation !== decoderGeneration ||
209
+ !canPresent(selected.packet, selected.epoch)
210
+ ) {
211
+ release();
212
+ return;
213
+ }
214
+ const result = environment.draw(selected.frame);
215
+ if (!result) {
216
+ complete();
217
+ release();
218
+ return;
219
+ }
220
+ painting = true;
221
+ void (async () => {
222
+ try {
223
+ await result;
224
+ complete();
225
+ } catch (error) {
226
+ if (!closed && generation === decoderGeneration) fail(error);
227
+ } finally {
228
+ release();
229
+ }
230
+ })();
231
+ } catch (error) {
232
+ if (generation === decoderGeneration) fail(error);
233
+ release();
234
+ }
235
+ });
236
+ };
237
+
238
+ return {
239
+ /** Returns false when a new keyframe is needed; never feeds a decoder an unknown delta chain. */
240
+ receive(packet: BrowserVideoPacket, mutationEpoch: number): boolean {
241
+ if (closed || !canPresent(packet, mutationEpoch)) return false;
242
+ const identity = JSON.stringify([
243
+ packet.streamId,
244
+ packet.captureGeneration,
245
+ packet.codec,
246
+ packet.width,
247
+ packet.height,
248
+ packet.frame.sessionId,
249
+ packet.frame.runtimeId,
250
+ packet.frame.captureEpoch,
251
+ packet.frame.navigationGeneration,
252
+ packet.frame.viewportGeneration,
253
+ ]);
254
+ if (streamKey !== identity) reset();
255
+ if (codec && packet.sequence <= sequence) return true;
256
+ // A complete new key can replace unsubmitted old pictures under decode
257
+ // pressure. Preserve the visible canvas and original receipt/epoch checks;
258
+ // a delta can never perform this catch-up because it needs its old chain.
259
+ if (codec && packet.type === "key" && queued.length > 0) reset();
260
+ if (
261
+ (codec && packet.sequence !== sequence + 1) ||
262
+ pending.size + queued.length >= MAX_PENDING_FRAMES ||
263
+ bufferedCharacters + packet.dataBase64.length > MAX_BUFFERED_BASE64_CHARACTERS
264
+ ) {
265
+ reset();
266
+ }
267
+ if (!codec && packet.type !== "key") {
268
+ options.onNeedKeyFrame();
269
+ return false;
270
+ }
271
+ try {
272
+ if (!codec) {
273
+ const generation = decoderGeneration;
274
+ codec = environment.createDecoder({
275
+ output: (frame) => output(frame, generation),
276
+ error: (error) => {
277
+ if (generation === decoderGeneration) fail(error);
278
+ },
279
+ dequeue: () => {
280
+ if (generation === decoderGeneration) drain();
281
+ },
282
+ });
283
+ codec.configure({
284
+ codec: packet.codec,
285
+ codedWidth: packet.width,
286
+ codedHeight: packet.height,
287
+ ...(packet.descriptionBase64
288
+ ? { description: environment.decodeBase64(packet.descriptionBase64) }
289
+ : {}),
290
+ optimizeForLatency: true,
291
+ });
292
+ streamKey = identity;
293
+ }
294
+ sequence = packet.sequence;
295
+ bufferedCharacters += packet.dataBase64.length;
296
+ queued.push({ packet, epoch: mutationEpoch });
297
+ drain();
298
+ return codec !== null;
299
+ } catch (error) {
300
+ fail(error);
301
+ return false;
302
+ }
303
+ },
304
+ reset,
305
+ close() {
306
+ if (closed) return;
307
+ closed = true;
308
+ reset();
309
+ },
310
+ };
311
+ }
@@ -0,0 +1,21 @@
1
+ /** Web renderer boundary. Other clients keep the JPEG path; the host SDK this plugin
2
+ * is pinned to publishes no encoded-video surface. */
3
+ import { useEffect } from "react";
4
+ import { type StyleProp, View, type ViewStyle } from "react-native";
5
+ import * as Web from "./web";
6
+
7
+ export type BrowserVideoCanvasNode = Web.BrowserVideoCanvasNode;
8
+ export const supportsBrowserVideo = Web.supportsBrowserVideo;
9
+ export const bindBrowserVideoVisibility = Web.bindBrowserVideoVisibility;
10
+ export const createBrowserVideoEnvironment = Web.createBrowserVideoEnvironment;
11
+
12
+ interface SurfaceProps {
13
+ canvasRef(value: unknown): void;
14
+ style: StyleProp<ViewStyle>;
15
+ }
16
+
17
+ /** Pointer handling stays on the sibling touch overlay, never inside the canvas host. */
18
+ export function BrowserVideoSurface({ canvasRef, style }: SurfaceProps) {
19
+ useEffect(() => () => canvasRef(null), [canvasRef]);
20
+ return <View ref={canvasRef} style={style} pointerEvents="none" accessible={false} />;
21
+ }
@@ -0,0 +1,45 @@
1
+ /** Real EventTarget envelopes exercise host suspension without a decoder or DOM shim. */
2
+ import { expect, it, vi } from "vitest";
3
+
4
+ vi.mock("react-native", () => ({ Platform: { OS: "web" } }));
5
+
6
+ import { type BrowserVideoCanvasNode, bindBrowserVideoVisibility } from "./web";
7
+
8
+ it("tracks hidden documents and page-cache suspension and removes every listener", () => {
9
+ const document = Object.assign(new EventTarget(), { hidden: false });
10
+ let intersection: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
11
+ const disconnected = vi.fn();
12
+ const view = Object.assign(new EventTarget(), {
13
+ IntersectionObserver: class {
14
+ constructor(callback: (entries: { isIntersecting: boolean }[]) => void) {
15
+ intersection = callback;
16
+ }
17
+ observe() {}
18
+ disconnect = disconnected;
19
+ },
20
+ });
21
+ const changes: boolean[] = [];
22
+ const cleanup = bindBrowserVideoVisibility(
23
+ {
24
+ getClientRects: () => [1],
25
+ ownerDocument: Object.assign(document, { defaultView: view }),
26
+ } as unknown as BrowserVideoCanvasNode,
27
+ (value) => changes.push(value),
28
+ );
29
+ document.hidden = true;
30
+ document.dispatchEvent(new Event("visibilitychange"));
31
+ document.hidden = false;
32
+ view.dispatchEvent(new Event("pagehide"));
33
+ document.dispatchEvent(new Event("visibilitychange"));
34
+ view.dispatchEvent(new Event("pageshow"));
35
+ expect(changes).toEqual([true, false, false, false, true]);
36
+ intersection!([{ isIntersecting: false }]);
37
+ intersection!([{ isIntersecting: true }]);
38
+ expect(changes.slice(-2)).toEqual([false, true]);
39
+ cleanup();
40
+ document.hidden = true;
41
+ document.dispatchEvent(new Event("visibilitychange"));
42
+ view.dispatchEvent(new Event("pageshow"));
43
+ expect(changes).toHaveLength(7);
44
+ expect(disconnected).toHaveBeenCalledOnce();
45
+ });
@@ -0,0 +1,111 @@
1
+ import { QueryClient, QueryObserver } from "@tanstack/react-query";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ createBrowserViewerRecovery,
5
+ isBrowserClosedError,
6
+ isExpiredBrowserViewerError,
7
+ } from "./browser-viewer-recovery";
8
+
9
+ const expired = Object.assign(
10
+ new Error(
11
+ "Request failed: Viewer token is invalid or expired requestType=plugin.rpc.invoke.request code=handler_error",
12
+ ),
13
+ { code: "handler_error" },
14
+ );
15
+ const observation = {
16
+ identity: "host-a/workspace-a",
17
+ viewerToken: "old-viewer",
18
+ failedViewerToken: "old-viewer",
19
+ captureError: expired,
20
+ pending: false,
21
+ };
22
+
23
+ describe("expired browser viewing recovery", () => {
24
+ it("recognizes explicit closure without treating it as an expired viewer", () => {
25
+ const closed = Object.assign(
26
+ new Error(
27
+ "Request failed: Browser is closed requestType=plugin.rpc.invoke.request code=handler_error",
28
+ ),
29
+ { code: "handler_error" },
30
+ );
31
+ expect(isBrowserClosedError(closed)).toBe(true);
32
+ expect(isExpiredBrowserViewerError(closed)).toBe(false);
33
+ expect(isBrowserClosedError(expired)).toBe(false);
34
+ });
35
+ it("matches the actual SDK handler-error suffix but not transport, control or arbitrary failures", () => {
36
+ expect(isExpiredBrowserViewerError(expired)).toBe(true);
37
+ expect(
38
+ isExpiredBrowserViewerError(
39
+ Object.assign(new Error("Request failed: Viewer token is invalid or expired"), {
40
+ code: "handler_error",
41
+ }),
42
+ ),
43
+ ).toBe(true);
44
+ expect(isExpiredBrowserViewerError(new Error("Viewer token is invalid or expired"))).toBe(true);
45
+ for (const error of [
46
+ new Error("Control lease is invalid or expired"),
47
+ new Error("Browser frame is stale"),
48
+ new Error("Bridge disconnected"),
49
+ Object.assign(new Error("Viewer token is invalid or expired"), { code: "unknown_outcome" }),
50
+ Object.assign(new Error("Viewer token is invalid or expired"), { code: "rpc_timeout" }),
51
+ { code: "handler_error", message: "Other error: Viewer token is invalid or expired" },
52
+ ])
53
+ expect(isExpiredBrowserViewerError(error)).toBe(false);
54
+ });
55
+
56
+ it("reattaches once for each expired token and leaves failures for explicit reconnect", () => {
57
+ const claim = createBrowserViewerRecovery();
58
+ expect(claim({ ...observation, pending: true })).toBe(false);
59
+ expect(claim(observation)).toBe(true);
60
+ expect(claim(observation)).toBe(false);
61
+ expect(claim({ ...observation, pending: true })).toBe(false);
62
+ expect(claim({ ...observation, viewerToken: "new", failedViewerToken: "new" })).toBe(true);
63
+ });
64
+
65
+ it("ignores obsolete token errors, missing attachments and unrelated current capture errors", () => {
66
+ const claim = createBrowserViewerRecovery();
67
+ expect(claim({ ...observation, viewerToken: "replacement" })).toBe(false);
68
+ expect(claim({ ...observation, viewerToken: null })).toBe(false);
69
+ expect(claim({ ...observation, captureError: new Error("Timeout") })).toBe(false);
70
+ expect(claim(observation)).toBe(true);
71
+ });
72
+
73
+ it("permits a distinct scope's attachment while retaining per-token loop protection", () => {
74
+ const claim = createBrowserViewerRecovery();
75
+ expect(claim(observation)).toBe(true);
76
+ expect(claim(observation)).toBe(false);
77
+ expect(claim({ ...observation, identity: "host-b/workspace-a" })).toBe(true);
78
+ expect(claim({ ...observation, identity: "host-b/workspace-a" })).toBe(false);
79
+ });
80
+ });
81
+
82
+ it("the actual capture query observer discards old token errors when its token key changes", async () => {
83
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
84
+ const replacement = Promise.withResolvers<string>();
85
+ const observer = new QueryObserver(client, {
86
+ queryKey: ["shared-browser", "capture", "old-viewer"],
87
+ queryFn: async (): Promise<string> => {
88
+ throw expired;
89
+ },
90
+ });
91
+ const unsubscribe = observer.subscribe(() => {});
92
+ try {
93
+ await observer.refetch();
94
+ expect(observer.getCurrentResult().error).toBe(expired);
95
+ observer.setOptions({
96
+ queryKey: ["shared-browser", "capture", "new-viewer"],
97
+ queryFn: () => replacement.promise,
98
+ retry: false,
99
+ });
100
+ expect(observer.getCurrentResult().error).toBeNull();
101
+ expect(observer.getCurrentResult().status).toBe("pending");
102
+ replacement.resolve("fresh capture");
103
+ await observer.refetch({ cancelRefetch: false });
104
+ expect(observer.getCurrentResult().error).toBeNull();
105
+ expect(observer.getCurrentResult().data).toBe("fresh capture");
106
+ } finally {
107
+ replacement.resolve("cleanup");
108
+ unsubscribe();
109
+ client.clear();
110
+ }
111
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Recover viewing after its short-lived viewer token expires during background
3
+ * suspension. Only a viewing capture/video error can use this policy; mutation failure must
4
+ * never trigger action replay, control acquisition or lease takeover.
5
+ */
6
+ const VIEWER_EXPIRED_MESSAGE = "Viewer token is invalid or expired";
7
+ const BROWSER_CLOSED_MESSAGE = "Browser is closed";
8
+
9
+ /** Recognize the server's explicit close fence without confusing it with a transport failure. */
10
+ export function isBrowserClosedError(error: unknown): boolean {
11
+ if (!error || typeof error !== "object") return false;
12
+ const value = error as { message?: unknown; code?: unknown };
13
+ if (typeof value.message !== "string") return false;
14
+ if (value.code !== undefined && value.code !== "handler_error") return false;
15
+ const message = value.message.startsWith("Request failed: ")
16
+ ? value.message.slice("Request failed: ".length)
17
+ : value.message;
18
+ return (
19
+ message === BROWSER_CLOSED_MESSAGE ||
20
+ (value.code === "handler_error" && message.startsWith(`${BROWSER_CLOSED_MESSAGE} requestType=`))
21
+ );
22
+ }
23
+
24
+ /** Match the owned handler error, including Paseo's documented RPC error suffix. */
25
+ export function isExpiredBrowserViewerError(error: unknown): boolean {
26
+ if (!error || typeof error !== "object") return false;
27
+ const value = error as { message?: unknown; code?: unknown };
28
+ if (typeof value.message !== "string") return false;
29
+ if (value.code !== undefined && value.code !== "handler_error") return false;
30
+ // Daemon session.js applies this exact prefix before the client appends
31
+ // requestType/code. Arbitrary error text never grants automatic recovery.
32
+ const message =
33
+ value.code === "handler_error" && value.message.startsWith("Request failed: ")
34
+ ? value.message.slice("Request failed: ".length)
35
+ : value.message;
36
+ if (message === VIEWER_EXPIRED_MESSAGE) return true;
37
+ return (
38
+ value.code === "handler_error" && message.startsWith(`${VIEWER_EXPIRED_MESSAGE} requestType=`)
39
+ );
40
+ }
41
+
42
+ export interface BrowserViewerRecoveryObservation {
43
+ /** Host/workspace scope of the mounted panel, never a printer or page identity. */
44
+ identity: string;
45
+ viewerToken: string | null;
46
+ /** Exact token of the failed capture query or video reader; an old viewer cannot recover its replacement. */
47
+ failedViewerToken: string | null;
48
+ captureError: unknown;
49
+ pending: boolean;
50
+ }
51
+
52
+ /**
53
+ * Claim at most one automatic viewing reattachment per expired token. Failure
54
+ * keeps manual Reconnect available. A new token or panel scope permits its own
55
+ * later recovery; this helper neither issues input nor holds browser authority.
56
+ */
57
+ export function createBrowserViewerRecovery() {
58
+ let scope: string | null = null;
59
+ let attemptedToken: string | null = null;
60
+ return (observation: BrowserViewerRecoveryObservation): boolean => {
61
+ if (scope !== observation.identity) {
62
+ scope = observation.identity;
63
+ attemptedToken = null;
64
+ }
65
+ const token = observation.viewerToken;
66
+ if (
67
+ observation.pending ||
68
+ !token ||
69
+ observation.failedViewerToken !== token ||
70
+ attemptedToken === token ||
71
+ !isExpiredBrowserViewerError(observation.captureError)
72
+ ) {
73
+ return false;
74
+ }
75
+ attemptedToken = token;
76
+ return true;
77
+ };
78
+ }