@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.244.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 (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Single-flight encoded-video reader. RPC waits never overlap per viewer; canceled
3
+ * replies are discarded and cannot restore input against old pixels. Only web
4
+ * clients decode video, and only when the host enabled it; others use the image path.
5
+ * Document/AppState suspension releases the decoder and stops reads. A bounded
6
+ * in-flight SDK read cannot be aborted; its result is discarded before resuming.
7
+ * Read failures get three delayed attempts, with failure cleared only by paint.
8
+ * Exhaustion stops traffic until an explicit rate-limited viewing retry; expiry stays
9
+ * owned by the separate viewer reattachment policy.
10
+ */
11
+
12
+ import { useRpc } from "@getpaseo/plugin/client";
13
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
14
+ import { AppState } from "react-native";
15
+ import type { BrowserState, Viewport } from "../shared/browser";
16
+ import { type BrowserVideoPacket, readBrowserVideoRpc } from "../shared/browser-video";
17
+ import { createBrowserVideoDecoder } from "./browser-video-decoder";
18
+ import {
19
+ type BrowserVideoCanvasNode,
20
+ bindBrowserVideoVisibility,
21
+ createBrowserVideoEnvironment,
22
+ supportsBrowserVideo,
23
+ } from "./browser-video-surface";
24
+ import { isExpiredBrowserViewerError } from "./browser-viewer-recovery";
25
+
26
+ interface BrowserVideoOptions {
27
+ viewerToken: string | null;
28
+ quality: "low" | "medium" | "high";
29
+ bitrate?: import("../shared/video-settings").VideoEncoderSettings["bitrate"];
30
+ fps?: import("../shared/video-settings").VideoEncoderSettings["fps"];
31
+ /** Explicit host panel suspension, when supplied. Unmount also stops decoding. */
32
+ active?: boolean;
33
+ epoch(): number;
34
+ viewport(): Viewport | null;
35
+ isCurrent(packet: BrowserVideoPacket, epoch: number): boolean;
36
+ /** Display may hold a previous document, never previous viewer/source/geometry. */
37
+ isDisplayCurrent?(packet: BrowserVideoPacket): boolean;
38
+ acceptState(state: BrowserState): boolean;
39
+ onPresented(packet: BrowserVideoPacket, epoch: number): void;
40
+ }
41
+
42
+ /** Admit encoded tab video only after a current frame is painted. Hidden hosts
43
+ * revoke video input synchronously; resuming requires a new decoder/key frame. */
44
+ export function useBrowserVideo(options: BrowserVideoOptions) {
45
+ const read = useRpc(readBrowserVideoRpc);
46
+ const readRef = useRef(read);
47
+ readRef.current = read;
48
+ const pendingRead = useRef<Promise<
49
+ import("../shared/browser-video").BrowserVideoReadReply
50
+ > | null>(null);
51
+ const latest = useRef(options);
52
+ latest.current = options;
53
+ const [node, setNode] = useState<BrowserVideoCanvasNode | null>(null);
54
+ const videoGeneration = useRef(0);
55
+ const authorityRevision = useRef(0);
56
+ const displayedPacket = useRef<BrowserVideoPacket | null>(null);
57
+ const [presentation, setPresentation] = useState<{
58
+ packet: BrowserVideoPacket;
59
+ epoch: number;
60
+ viewport: Viewport | null;
61
+ generation: number;
62
+ authorityRevision: number;
63
+ } | null>(null);
64
+ const fallbackCounter = useRef(0);
65
+ const fallbackRef = useRef(0);
66
+ const [fallbackRevision, setFallbackRevision] = useState(0);
67
+ const requestFallback = () => {
68
+ if (fallbackRef.current) return;
69
+ authorityRevision.current += 1;
70
+ frontRef.current = null;
71
+ fallbackRef.current = ++fallbackCounter.current;
72
+ setFallbackRevision(fallbackRef.current);
73
+ };
74
+ /** Source/document/geometry validity: enough to decode and paint a packet. */
75
+ const sourceCurrent = (packet: BrowserVideoPacket, epoch: number) =>
76
+ latest.current.isCurrent(packet, epoch);
77
+ /** Input eligibility: source validity plus a host receipt. A display-only packet
78
+ * (actionable === false) may be decoded and painted but never admits a press. */
79
+ const inputCurrent = (packet: BrowserVideoPacket, epoch: number) =>
80
+ packet.actionable !== false && sourceCurrent(packet, epoch);
81
+ const displayCurrent = (packet: BrowserVideoPacket) =>
82
+ latest.current.isDisplayCurrent
83
+ ? latest.current.isDisplayCurrent(packet)
84
+ : latest.current.isCurrent(packet, latest.current.epoch());
85
+ const front = presentation?.packet ?? null;
86
+ const setFront = (value: BrowserVideoPacket | null, epoch = 0) => {
87
+ displayedPacket.current = value;
88
+ if (!value || inputCurrent(value, epoch)) {
89
+ fallbackRef.current = 0;
90
+ setFallbackRevision(0);
91
+ }
92
+ setPresentation(
93
+ value
94
+ ? {
95
+ packet: value,
96
+ epoch,
97
+ viewport: latest.current.viewport(),
98
+ generation: videoGeneration.current,
99
+ authorityRevision: authorityRevision.current,
100
+ }
101
+ : null,
102
+ );
103
+ };
104
+ const frontRef = useRef<BrowserVideoPacket | null>(null);
105
+ const visible = useRef(false);
106
+ const hostActive = useRef(false);
107
+ const [active, setActive] = useState(false);
108
+ const [failure, setFailure] = useState<{ error: unknown; viewerToken: string | null } | null>(
109
+ null,
110
+ );
111
+ const error = failure?.error ?? null;
112
+ const refresh = useRef<() => void>(() => {});
113
+ const retry = useRef<() => void>(() => {});
114
+ const canvasRef = useCallback((value: unknown) => {
115
+ setNode(value as BrowserVideoCanvasNode | null);
116
+ }, []);
117
+
118
+ useLayoutEffect(() => {
119
+ if (
120
+ hostActive.current &&
121
+ presentation &&
122
+ presentation.generation === videoGeneration.current &&
123
+ // A mutation epoch revokes input, but the same source's last video pixels
124
+ // remain a truthful visual hold while its next admitted frame is decoded.
125
+ displayCurrent(presentation.packet)
126
+ ) {
127
+ visible.current = true;
128
+ if (
129
+ !fallbackRef.current &&
130
+ presentation.authorityRevision === authorityRevision.current &&
131
+ inputCurrent(presentation.packet, presentation.epoch)
132
+ ) {
133
+ frontRef.current = presentation.packet;
134
+ latest.current.onPresented(presentation.packet, presentation.epoch);
135
+ } else {
136
+ frontRef.current = null;
137
+ }
138
+ } else {
139
+ visible.current = false;
140
+ frontRef.current = null;
141
+ if (presentation) setFront(null);
142
+ }
143
+ }, [presentation, options.isCurrent]);
144
+
145
+ useEffect(() => {
146
+ // An old token's late read must never reattach the replacement viewer.
147
+ const setError = (error: unknown) =>
148
+ setFailure(error === null ? null : { error, viewerToken: options.viewerToken });
149
+ frontRef.current = null;
150
+ setFront(null);
151
+ setError(null);
152
+ if (!node || !options.viewerToken || !supportsBrowserVideo()) return;
153
+ let runningCleanup: (() => void) | null = null;
154
+ let documentActive = false;
155
+ let applicationActive = AppState.currentState == null || AppState.currentState === "active";
156
+ const start = () => {
157
+ videoGeneration.current += 1;
158
+ const target = createBrowserVideoEnvironment(node);
159
+ if (!target) return null;
160
+ let stopped = false;
161
+ let readExhausted = false;
162
+ let readRetries = 0;
163
+ let retryWaiting = false;
164
+ let retryNotBefore = 0;
165
+ let explicitRetryTimer: ReturnType<typeof setTimeout> | null = null;
166
+ let lastPaintedAt = Date.now();
167
+ let lastQuietReadAt = 0;
168
+ let streamId: string | null = null;
169
+ let sequence = 0;
170
+ let keyRequested = true;
171
+ let wake: (() => void) | null = null;
172
+ const decoder = createBrowserVideoDecoder({
173
+ environment: target.environment,
174
+ isCurrent: (packet, epoch) => !stopped && latest.current.isCurrent(packet, epoch),
175
+ // Same source/document packets preserve the codec's dependency chain,
176
+ // including a reply that began before a local press. They carry that old
177
+ // read epoch through paint and cannot authorize another press. A display-only
178
+ // (non-actionable) packet is still source-valid and must keep decoding.
179
+ isSourceCurrent: (packet) => !stopped && sourceCurrent(packet, latest.current.epoch()),
180
+ onPresented(packet, epoch) {
181
+ if (stopped) return;
182
+ lastPaintedAt = Date.now();
183
+ readRetries = 0;
184
+ setError(null);
185
+ // Once this canvas is visible, its pixels change before React commits.
186
+ // Publish authority synchronously with that draw, never the old front.
187
+ if (visible.current && inputCurrent(packet, epoch)) {
188
+ frontRef.current = packet;
189
+ latest.current.onPresented(packet, epoch);
190
+ } else {
191
+ frontRef.current = null;
192
+ }
193
+ setFront(packet, epoch);
194
+ },
195
+ onNeedKeyFrame() {
196
+ keyRequested = true;
197
+ },
198
+ onError(failure) {
199
+ if (stopped) return;
200
+ stopped = true;
201
+ frontRef.current = null;
202
+ setFront(null);
203
+ setError(failure);
204
+ wake?.();
205
+ },
206
+ });
207
+ const watchdog = setInterval(() => {
208
+ if (Date.now() - Math.max(lastPaintedAt, lastQuietReadAt) <= 2500 || !visible.current)
209
+ return;
210
+ // Request a new image, but hold the already painted canvas until that
211
+ // exact request is decoded. Display retention grants no input receipt.
212
+ if (!fallbackRef.current) {
213
+ requestFallback();
214
+ decoder.reset();
215
+ sequence = 0;
216
+ streamId = null;
217
+ keyRequested = true;
218
+ }
219
+ }, 500);
220
+ const pause = (milliseconds: number) =>
221
+ new Promise<void>((resolve) => {
222
+ const timer = setTimeout(() => {
223
+ wake = null;
224
+ resolve();
225
+ }, milliseconds);
226
+ wake = () => {
227
+ clearTimeout(timer);
228
+ wake = null;
229
+ resolve();
230
+ };
231
+ });
232
+ refresh.current = () => {
233
+ // Refresh cannot bypass backoff or rearm an exhausted reader
234
+ // just because another input finished.
235
+ if (retryWaiting || readExhausted) return;
236
+ // Input settlement wakes viewing without forcing another keyframe.
237
+ // Source changes, chain gaps, resets and watchdog recovery request keys.
238
+ wake?.();
239
+ };
240
+ const retryFailedReader = () => {
241
+ if (stopped) return;
242
+ // Reserved for explicit viewing recovery, such as successful navigation.
243
+ // Expired viewers and decoder failures do not acquire a new read budget.
244
+ if (!readExhausted) {
245
+ refresh.current();
246
+ return;
247
+ }
248
+ if (retryWaiting || explicitRetryTimer !== null) return;
249
+ const remaining = retryNotBefore - Date.now();
250
+ if (remaining > 0) {
251
+ // Preserve one successful-navigation retry intent through cooldown.
252
+ // This timer belongs to this exact viewer/visibility incarnation.
253
+ explicitRetryTimer = setTimeout(() => {
254
+ explicitRetryTimer = null;
255
+ retryFailedReader();
256
+ }, remaining);
257
+ return;
258
+ }
259
+ runningCleanup?.();
260
+ runningCleanup = null;
261
+ synchronize();
262
+ };
263
+ retry.current = retryFailedReader;
264
+ /** Recovery applies only to reads, never native input or decoder errors.
265
+ * A successful empty reply does not replenish the budget: fresh paint does. */
266
+ const recoverRead = async (failure: unknown): Promise<boolean> => {
267
+ if (stopped || isExpiredBrowserViewerError(failure)) return false;
268
+ visible.current = false;
269
+ frontRef.current = null;
270
+ setFront(null);
271
+ setError(failure);
272
+ decoder.reset();
273
+ sequence = 0;
274
+ streamId = null;
275
+ keyRequested = true;
276
+ const delay = [500, 1500, 3000][readRetries];
277
+ if (delay === undefined) {
278
+ readExhausted = true;
279
+ retryNotBefore = Date.now() + 3000;
280
+ return false;
281
+ }
282
+ readRetries += 1;
283
+ retryWaiting = true;
284
+ try {
285
+ await pause(delay);
286
+ } finally {
287
+ retryWaiting = false;
288
+ }
289
+ return !stopped;
290
+ };
291
+ const pump = async () => {
292
+ try {
293
+ while (!stopped) {
294
+ // Quality/viewer replacement waits for the old bounded RPC. The SDK
295
+ // cannot abort it; overlapping requests would violate server admission.
296
+ if (pendingRead.current) await pendingRead.current.catch(() => undefined);
297
+ if (stopped) break;
298
+ const epoch = latest.current.epoch();
299
+ const requestKeyFrame = keyRequested;
300
+ keyRequested = false;
301
+ const request = readRef.current({
302
+ viewerToken: options.viewerToken!,
303
+ quality: options.quality,
304
+ ...(options.bitrate === undefined ? {} : { bitrate: options.bitrate }),
305
+ ...(options.fps === undefined ? {} : { fps: options.fps }),
306
+ streamId,
307
+ afterSequence: sequence,
308
+ waitMs: 250,
309
+ requestKeyFrame,
310
+ });
311
+ pendingRead.current = request;
312
+ let reply: import("../shared/browser-video").BrowserVideoReadReply;
313
+ try {
314
+ reply = await request;
315
+ } catch (failure) {
316
+ if (pendingRead.current === request) pendingRead.current = null;
317
+ if (await recoverRead(failure)) continue;
318
+ throw failure;
319
+ } finally {
320
+ if (pendingRead.current === request) pendingRead.current = null;
321
+ }
322
+ if (stopped) break;
323
+ if (!latest.current.acceptState(reply.state)) continue;
324
+ // An unchanged tab may produce no native frames. Only a healthy
325
+ // same-stream read with every received packet already painted can
326
+ // renew the quiet-picture hold. Reset, stale authority and a stuck
327
+ // decoder still fall through to the ordinary stall watchdog.
328
+ // Input revocation removes frontRef, but leaves these pixels visible.
329
+ // Liveness may retain them without restoring their input authority.
330
+ const painted = displayedPacket.current;
331
+ if (
332
+ reply.status === "waiting" &&
333
+ painted &&
334
+ reply.streamId === painted.streamId &&
335
+ painted.sequence === sequence &&
336
+ latest.current.isCurrent(painted, epoch)
337
+ ) {
338
+ lastQuietReadAt = Date.now();
339
+ }
340
+ if (reply.status === "unsupported") {
341
+ if (reply.reasonCode === "video-disabled") {
342
+ // Host policy, not a failure: stop reading, release the decoder and
343
+ // canvas, and leave presentation to the ordinary image path.
344
+ stopped = true;
345
+ clearInterval(watchdog);
346
+ visible.current = false;
347
+ frontRef.current = null;
348
+ setFront(null);
349
+ target.dispose();
350
+ break;
351
+ }
352
+ if (reply.reasonCode === "encoder-capacity") {
353
+ // Capacity is a typed, viewing-only soft failure. Keep JPEG
354
+ // fallback visible while old idle cohorts can release their slot.
355
+ visible.current = false;
356
+ frontRef.current = null;
357
+ setFront(null);
358
+ setError(new Error(reply.reason ?? "Video profiles are busy"));
359
+ decoder.reset();
360
+ sequence = 0;
361
+ streamId = null;
362
+ keyRequested = true;
363
+ await pause(2500);
364
+ continue;
365
+ }
366
+ const failure = new Error(reply.reason ?? "Browser video is unavailable");
367
+ if (await recoverRead(failure)) continue;
368
+ throw failure;
369
+ }
370
+ if (reply.status === "reset" && reply.packets.length === 0) {
371
+ // A reset revokes input receipts immediately, including a paint
372
+ // already queued for React commit. Display ownership is separate.
373
+ authorityRevision.current += 1;
374
+ frontRef.current = null;
375
+ const displayed = displayedPacket.current;
376
+ if (!displayed || !displayCurrent(displayed)) {
377
+ visible.current = false;
378
+ setFront(null);
379
+ }
380
+ // Retain same-cohort pixels until fresh paint or the existing stall
381
+ // watchdog. Old JPEG underneath must not become input authority.
382
+ decoder.reset();
383
+ sequence = 0;
384
+ streamId = null;
385
+ keyRequested = true;
386
+ } else {
387
+ streamId = reply.streamId;
388
+ for (const packet of reply.packets) {
389
+ sequence = packet.sequence;
390
+ if (!decoder.receive(packet, epoch)) keyRequested = true;
391
+ }
392
+ }
393
+ // An unchanged tab can produce no native frames. Bound
394
+ // empty/recovery polling and yield even when a reply is already buffered.
395
+ await pause(reply.packets.length ? 0 : 40);
396
+ }
397
+ } catch (failure) {
398
+ if (!stopped) {
399
+ frontRef.current = null;
400
+ setFront(null);
401
+ setError(failure);
402
+ }
403
+ } finally {
404
+ decoder.close();
405
+ }
406
+ };
407
+ void pump();
408
+ return () => {
409
+ stopped = true;
410
+ videoGeneration.current += 1;
411
+ clearInterval(watchdog);
412
+ if (explicitRetryTimer !== null) clearTimeout(explicitRetryTimer);
413
+ explicitRetryTimer = null;
414
+ visible.current = false;
415
+ wake?.();
416
+ decoder.close();
417
+ target.dispose();
418
+ refresh.current = () => {};
419
+ retry.current = () => {};
420
+ frontRef.current = null;
421
+ setFront(null);
422
+ };
423
+ };
424
+ const synchronize = () => {
425
+ const active = documentActive && applicationActive && options.active !== false;
426
+ hostActive.current = active;
427
+ setActive(active);
428
+ if (!active) {
429
+ // Revoke pixels synchronously with real host suspension. Any uncancelable
430
+ // bounded SDK read is discarded, and cannot overlap the next incarnation.
431
+ runningCleanup?.();
432
+ runningCleanup = null;
433
+ visible.current = false;
434
+ frontRef.current = null;
435
+ setFront(null);
436
+ } else if (!runningCleanup) {
437
+ // A resumed decoder has no inherited stream/sequence and requests a key
438
+ // frame. The existing epoch/frame checks still decide input authority.
439
+ runningCleanup = start();
440
+ }
441
+ };
442
+ const unbind = bindBrowserVideoVisibility(node, (active) => {
443
+ documentActive = active;
444
+ synchronize();
445
+ });
446
+ const subscription = AppState.addEventListener("change", (state) => {
447
+ applicationActive = state === "active";
448
+ synchronize();
449
+ });
450
+ return () => {
451
+ hostActive.current = false;
452
+ unbind();
453
+ subscription.remove();
454
+ runningCleanup?.();
455
+ runningCleanup = null;
456
+ };
457
+ }, [node, options.viewerToken, options.quality, options.active, options.bitrate, options.fps]);
458
+
459
+ return {
460
+ active,
461
+ canvasRef,
462
+ front,
463
+ frontViewport: presentation?.viewport ?? null,
464
+ frontRef,
465
+ fallbackRevision,
466
+ /** Retire the held canvas only after the corresponding fallback painted. */
467
+ completeFallback: (revision: number) => {
468
+ if (revision && revision === fallbackRef.current) {
469
+ visible.current = false;
470
+ frontRef.current = null;
471
+ setFront(null);
472
+ }
473
+ },
474
+ error,
475
+ errorViewerToken: failure?.viewerToken ?? null,
476
+ refresh: () => refresh.current(),
477
+ /** Explicit viewing retry; ordinary input/image refresh never rearms exhaustion. */
478
+ retry: () => retry.current(),
479
+ supported: supportsBrowserVideo(),
480
+ };
481
+ }
@@ -0,0 +1,26 @@
1
+ /** Attach viewing only after the current viewing token expires; never replay control or input. */
2
+ import { useEffect, useState } from "react";
3
+ import {
4
+ type BrowserViewerRecoveryObservation,
5
+ createBrowserViewerRecovery,
6
+ } from "./browser-viewer-recovery";
7
+
8
+ interface ViewerRecoveryOptions extends BrowserViewerRecoveryObservation {
9
+ reconnect(): void;
10
+ }
11
+
12
+ /** Bounded recovery follows the existing manual Reconnect path and its lifecycle fences. */
13
+ export function useBrowserViewerRecovery(options: ViewerRecoveryOptions): void {
14
+ const [claim] = useState(createBrowserViewerRecovery);
15
+ useEffect(() => {
16
+ if (claim(options)) options.reconnect();
17
+ }, [
18
+ claim,
19
+ options.identity,
20
+ options.viewerToken,
21
+ options.failedViewerToken,
22
+ options.captureError,
23
+ options.pending,
24
+ options.reconnect,
25
+ ]);
26
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Revisioned host favorites. Stars use the same settings/CAS service as neighboring
3
+ * plugins; conflicts stay visible until reload, and no write is replayed. An old
4
+ * host's completion cannot clear a newer host's pending action or expose its error.
5
+ */
6
+ import { useSettings } from "@getpaseo/plugin/client";
7
+ import { useRef, useState } from "react";
8
+ import {
9
+ type BrowserDisplayPreferences,
10
+ browserDisplayPreferences,
11
+ } from "../shared/browser-display-preferences";
12
+ import { DEFAULT_CAPTURE_QUALITY } from "../shared/capture-settings";
13
+ import type { DevicePresetId } from "../shared/device-presets";
14
+ import {
15
+ normalizeResolutionFavorites,
16
+ orderedResolutionFavorites,
17
+ toggleResolutionFavorite,
18
+ } from "../shared/resolution-menu";
19
+ import { DEFAULT_VIDEO_BITRATE, DEFAULT_VIDEO_FPS } from "../shared/video-settings";
20
+
21
+ /** Call with the surface's host.id; persistence itself is scoped by Paseo. */
22
+ export function useResolutionFavorites(hostId: string) {
23
+ const settings = useSettings(browserDisplayPreferences);
24
+ const currentHost = useRef(hostId);
25
+ currentHost.current = hostId;
26
+ const pending = useRef<{ hostId: string } | null>(null);
27
+ const [pendingHost, setPendingHost] = useState<string | null>(null);
28
+ const saving = settings.saving || pendingHost === hostId;
29
+ const disabled = settings.status !== "ready" || saving;
30
+ const favoritePresetIds =
31
+ settings.status === "ready"
32
+ ? orderedResolutionFavorites(settings.values.favoritePresetIds)
33
+ : [];
34
+ const error =
35
+ settings.status === "error" || settings.status === "invalid"
36
+ ? settings.error
37
+ : settings.saveError;
38
+
39
+ const savePreferences = async (
40
+ update: (values: BrowserDisplayPreferences) => BrowserDisplayPreferences,
41
+ ): Promise<boolean> => {
42
+ if (
43
+ currentHost.current !== hostId ||
44
+ settings.status !== "ready" ||
45
+ settings.saving ||
46
+ pending.current?.hostId === hostId
47
+ )
48
+ return false;
49
+ const operation = { hostId };
50
+ pending.current = operation;
51
+ setPendingHost(hostId);
52
+ try {
53
+ const saved = await settings.save(update(settings.values), settings.revision);
54
+ return currentHost.current === hostId && saved;
55
+ } finally {
56
+ if (pending.current === operation) {
57
+ pending.current = null;
58
+ setPendingHost(null);
59
+ }
60
+ }
61
+ };
62
+
63
+ return {
64
+ favoritePresetIds,
65
+ captureQuality:
66
+ settings.status === "ready" ? settings.values.captureQuality : DEFAULT_CAPTURE_QUALITY,
67
+ videoBitrate:
68
+ settings.status === "ready" ? settings.values.videoBitrate : DEFAULT_VIDEO_BITRATE,
69
+ videoFps: settings.status === "ready" ? settings.values.videoFps : DEFAULT_VIDEO_FPS,
70
+ loading: settings.status === "loading",
71
+ saving,
72
+ disabled,
73
+ error,
74
+ toggleFavorite: (id: DevicePresetId) => {
75
+ if (normalizeResolutionFavorites([id]).length === 0) return Promise.resolve(false);
76
+ return savePreferences((values) => ({
77
+ ...values,
78
+ favoritePresetIds: toggleResolutionFavorite(values.favoritePresetIds, id),
79
+ }));
80
+ },
81
+ changeQuality: (captureQuality: BrowserDisplayPreferences["captureQuality"]) =>
82
+ savePreferences((values) => ({ ...values, captureQuality })),
83
+ changeVideoBitrate: (videoBitrate: BrowserDisplayPreferences["videoBitrate"]) =>
84
+ savePreferences((values) => ({ ...values, videoBitrate })),
85
+ changeVideoFps: (videoFps: BrowserDisplayPreferences["videoFps"]) =>
86
+ savePreferences((values) => ({ ...values, videoFps })),
87
+ reload: settings.reload,
88
+ resetPreferences: settings.reset,
89
+ };
90
+ }