@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.
- 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
|
@@ -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
|
+
}
|