@omercnet/paseo-shared-browser 1.2.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +2 -2
- package/README.md +316 -27
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +196 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1254 -0
- package/client/browser-input-queue.ts +519 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-currency.ts +45 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1650 -1579
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/sidebar.tsx +1 -2
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +375 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +1060 -0
- package/client/use-browser-video.ts +481 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -4
- package/npm-shrinkwrap.json +9 -9
- package/package.json +10 -5
- package/paseo-plugin.json +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1601 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1316 -151
- package/server/browser-video-input-authority.test.ts +211 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +340 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/tab-close-confirmation.test.ts +97 -0
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +83 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Density writes reuse controller snapshots and mutation settlement without preset substitution.
|
|
2
|
+
* The latest callback authority is checked before publication, and pending writes
|
|
3
|
+
* are never retried. A settings change preserves page/CSS/mode at the server seam.
|
|
4
|
+
*/
|
|
5
|
+
import { useMutation } from "@tanstack/react-query";
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
import {
|
|
8
|
+
type BrowserState,
|
|
9
|
+
type CaptureDensity,
|
|
10
|
+
canUseCaptureDensity,
|
|
11
|
+
type setCaptureDensityRpc,
|
|
12
|
+
} from "../shared/browser";
|
|
13
|
+
|
|
14
|
+
type Request = ReturnType<typeof setCaptureDensityRpc.input.parse>;
|
|
15
|
+
interface Options {
|
|
16
|
+
identity: string;
|
|
17
|
+
current(): {
|
|
18
|
+
state: BrowserState;
|
|
19
|
+
context: Omit<Request, "density">;
|
|
20
|
+
} | null;
|
|
21
|
+
change(input: Request): Promise<{ state: BrowserState }>;
|
|
22
|
+
beforeChange(): void;
|
|
23
|
+
onSuccess(state: BrowserState): void;
|
|
24
|
+
onError(error: unknown): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Retained menu callbacks recheck the current controller and actual CSS size before sending. */
|
|
28
|
+
export function useBrowserCaptureDensity(options: Options) {
|
|
29
|
+
const latest = useRef(options);
|
|
30
|
+
latest.current = options;
|
|
31
|
+
const active = useRef(false);
|
|
32
|
+
const mounted = useRef(true);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
mounted.current = true;
|
|
35
|
+
return () => {
|
|
36
|
+
mounted.current = false;
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
const isCurrent = (request: { input: Request; identity: string }) => {
|
|
40
|
+
const current = latest.current.current();
|
|
41
|
+
return (
|
|
42
|
+
mounted.current &&
|
|
43
|
+
request.identity === latest.current.identity &&
|
|
44
|
+
current !== null &&
|
|
45
|
+
current.state.controller === "self" &&
|
|
46
|
+
current.context.viewerToken === request.input.viewerToken &&
|
|
47
|
+
current.context.controlToken === request.input.controlToken &&
|
|
48
|
+
current.context.expected.sessionId === request.input.expected.sessionId &&
|
|
49
|
+
current.context.expected.runtimeId === request.input.expected.runtimeId &&
|
|
50
|
+
current.context.expected.bridgeEpoch === request.input.expected.bridgeEpoch
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
const mutation = useMutation({
|
|
54
|
+
mutationFn: (request: { input: Request; identity: string }) => options.change(request.input),
|
|
55
|
+
retry: false,
|
|
56
|
+
onSuccess: (result, request) => {
|
|
57
|
+
if (isCurrent(request)) latest.current.onSuccess(result.state);
|
|
58
|
+
},
|
|
59
|
+
onError: (error, request) => {
|
|
60
|
+
if (isCurrent(request)) latest.current.onError(error);
|
|
61
|
+
},
|
|
62
|
+
onSettled: () => {
|
|
63
|
+
active.current = false;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
pending: mutation.isPending,
|
|
68
|
+
select(density: CaptureDensity) {
|
|
69
|
+
const current = latest.current.current();
|
|
70
|
+
if (
|
|
71
|
+
!mounted.current ||
|
|
72
|
+
latest.current.identity !== options.identity ||
|
|
73
|
+
active.current ||
|
|
74
|
+
!current ||
|
|
75
|
+
current.state.controller !== "self" ||
|
|
76
|
+
current.state.status !== "ready" ||
|
|
77
|
+
!canUseCaptureDensity(current.state.viewport, density) ||
|
|
78
|
+
current.state.captureScale === density
|
|
79
|
+
) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
active.current = true;
|
|
83
|
+
latest.current.beforeChange();
|
|
84
|
+
mutation.mutate({ input: { ...current.context, density }, identity: options.identity });
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Apply the local device default only after this viewer first owns the shared browser. */
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import type { BrowserState } from "../shared/browser";
|
|
4
|
+
import {
|
|
5
|
+
createEmulationChoices,
|
|
6
|
+
type EmulationSelection,
|
|
7
|
+
readLocalDevice,
|
|
8
|
+
selectionMode,
|
|
9
|
+
} from "./browser-emulation-mode";
|
|
10
|
+
|
|
11
|
+
interface ModeOptions {
|
|
12
|
+
identity: string;
|
|
13
|
+
platform: "ios" | "android" | "web";
|
|
14
|
+
state: BrowserState | null;
|
|
15
|
+
canControl: boolean;
|
|
16
|
+
pending: boolean;
|
|
17
|
+
apply(selection: EmulationSelection): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Observers never mutate emulation; failed changes remain explicit errors and are not retried. */
|
|
21
|
+
export function useBrowserEmulationMode(options: ModeOptions) {
|
|
22
|
+
const owned = useRef({
|
|
23
|
+
identity: options.identity,
|
|
24
|
+
choices: createEmulationChoices(readLocalDevice(options.platform)),
|
|
25
|
+
});
|
|
26
|
+
if (owned.current.identity !== options.identity) {
|
|
27
|
+
owned.current = {
|
|
28
|
+
identity: options.identity,
|
|
29
|
+
choices: createEmulationChoices(readLocalDevice(options.platform)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const choices = owned.current.choices;
|
|
33
|
+
const presetId = options.state?.devicePresetId ?? null;
|
|
34
|
+
const mode = selectionMode(presetId);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (options.state) choices.remember(presetId);
|
|
37
|
+
}, [choices, presetId, Boolean(options.state)]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!options.canControl || options.pending || options.state?.status !== "ready") return;
|
|
40
|
+
const selection = choices.claimDefault(mode);
|
|
41
|
+
if (selection) options.apply(selection);
|
|
42
|
+
}, [choices, mode, options.canControl, options.pending, options.state?.status, options.apply]);
|
|
43
|
+
return {
|
|
44
|
+
mode,
|
|
45
|
+
toggle: () => {
|
|
46
|
+
if (
|
|
47
|
+
owned.current.choices !== choices ||
|
|
48
|
+
!options.canControl ||
|
|
49
|
+
options.pending ||
|
|
50
|
+
options.state?.status !== "ready"
|
|
51
|
+
)
|
|
52
|
+
return;
|
|
53
|
+
options.apply(choices.toggle(mode));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Exercise debounce and cancellation with the repository's lightweight hook harness. */
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const fixture = vi.hoisted(() => ({
|
|
5
|
+
ref: null as { current: unknown } | null,
|
|
6
|
+
dependencies: null as unknown[] | null,
|
|
7
|
+
cleanup: null as (() => void) | null,
|
|
8
|
+
deferEffects: false,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("react", () => ({
|
|
12
|
+
useRef(value: unknown) {
|
|
13
|
+
fixture.ref ??= { current: value };
|
|
14
|
+
return fixture.ref;
|
|
15
|
+
},
|
|
16
|
+
useEffect(operation: () => (() => void) | undefined, dependencies: unknown[]) {
|
|
17
|
+
if (fixture.deferEffects) return;
|
|
18
|
+
if (
|
|
19
|
+
fixture.dependencies &&
|
|
20
|
+
dependencies.every((value, index) => Object.is(value, fixture.dependencies?.[index]))
|
|
21
|
+
) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
fixture.cleanup?.();
|
|
25
|
+
fixture.dependencies = dependencies;
|
|
26
|
+
fixture.cleanup = operation() ?? null;
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { useBrowserFillViewport } from "./use-browser-fill-viewport";
|
|
31
|
+
|
|
32
|
+
const apply = vi.fn();
|
|
33
|
+
const defaults = {
|
|
34
|
+
identity: "host/workspace/viewer/control",
|
|
35
|
+
enabled: true,
|
|
36
|
+
canResize: true,
|
|
37
|
+
target: { width: 1000, height: 700 },
|
|
38
|
+
viewport: { width: 1280, height: 720 },
|
|
39
|
+
apply,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
fixture.ref = null;
|
|
44
|
+
fixture.dependencies = null;
|
|
45
|
+
fixture.cleanup = null;
|
|
46
|
+
fixture.deferEffects = false;
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fixture.cleanup?.();
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("coalesces rapid panel changes into one resize using the latest action", () => {
|
|
57
|
+
useBrowserFillViewport(defaults);
|
|
58
|
+
vi.advanceTimersByTime(100);
|
|
59
|
+
const latest = vi.fn();
|
|
60
|
+
useBrowserFillViewport({ ...defaults, target: { width: 900, height: 600 }, apply: latest });
|
|
61
|
+
vi.advanceTimersByTime(199);
|
|
62
|
+
expect(latest).not.toHaveBeenCalled();
|
|
63
|
+
vi.advanceTimersByTime(1);
|
|
64
|
+
expect(latest).toHaveBeenCalledOnce();
|
|
65
|
+
expect(apply).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("cancels queued work when control is released, a mutation starts, or following stops", () => {
|
|
69
|
+
for (const override of [{ canResize: false }, { enabled: false }, { target: null }]) {
|
|
70
|
+
useBrowserFillViewport(defaults);
|
|
71
|
+
vi.advanceTimersByTime(100);
|
|
72
|
+
useBrowserFillViewport({ ...defaults, ...override });
|
|
73
|
+
vi.advanceTimersByTime(300);
|
|
74
|
+
expect(apply).not.toHaveBeenCalled();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resumes after control returns without repeatedly resizing an acknowledged resolution", () => {
|
|
79
|
+
useBrowserFillViewport({ ...defaults, canResize: false });
|
|
80
|
+
vi.advanceTimersByTime(500);
|
|
81
|
+
expect(apply).not.toHaveBeenCalled();
|
|
82
|
+
useBrowserFillViewport(defaults);
|
|
83
|
+
vi.advanceTimersByTime(200);
|
|
84
|
+
expect(apply).toHaveBeenCalledOnce();
|
|
85
|
+
useBrowserFillViewport({ ...defaults, viewport: defaults.target });
|
|
86
|
+
vi.advanceTimersByTime(500);
|
|
87
|
+
expect(apply).toHaveBeenCalledOnce();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("cancels work when moving to another workspace or unmounting", () => {
|
|
91
|
+
useBrowserFillViewport(defaults);
|
|
92
|
+
vi.advanceTimersByTime(100);
|
|
93
|
+
useBrowserFillViewport({ ...defaults, identity: "other/workspace", enabled: false });
|
|
94
|
+
vi.advanceTimersByTime(300);
|
|
95
|
+
expect(apply).not.toHaveBeenCalled();
|
|
96
|
+
useBrowserFillViewport(defaults);
|
|
97
|
+
fixture.cleanup?.();
|
|
98
|
+
vi.advanceTimersByTime(300);
|
|
99
|
+
expect(apply).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rechecks render-time authority before effect cleanup can cancel a queued resize", () => {
|
|
103
|
+
useBrowserFillViewport(defaults);
|
|
104
|
+
fixture.deferEffects = true;
|
|
105
|
+
useBrowserFillViewport({ ...defaults, identity: "other/workspace", canResize: false });
|
|
106
|
+
vi.advanceTimersByTime(200);
|
|
107
|
+
expect(apply).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Follow local panel geometry only while this viewer owns the shared browser. */
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
interface FillViewportOptions {
|
|
5
|
+
identity: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
canResize: boolean;
|
|
8
|
+
target: { width: number; height: number } | null;
|
|
9
|
+
viewport: { width: number; height: number } | null;
|
|
10
|
+
apply(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Coalesce layout changes and cancel scheduled work on authority, scope, or mode
|
|
15
|
+
* changes. Unsupported sizes pause following; RPC failures are handled by the
|
|
16
|
+
* caller, which disables the mode rather than retrying indefinitely.
|
|
17
|
+
*/
|
|
18
|
+
export function useBrowserFillViewport(options: FillViewportOptions): void {
|
|
19
|
+
const latest = useRef(options);
|
|
20
|
+
latest.current = options;
|
|
21
|
+
const width = options.target?.width;
|
|
22
|
+
const height = options.target?.height;
|
|
23
|
+
const viewportWidth = options.viewport?.width;
|
|
24
|
+
const viewportHeight = options.viewport?.height;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (
|
|
28
|
+
!options.enabled ||
|
|
29
|
+
!options.canResize ||
|
|
30
|
+
width === undefined ||
|
|
31
|
+
height === undefined ||
|
|
32
|
+
(width === viewportWidth && height === viewportHeight)
|
|
33
|
+
) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const identity = options.identity;
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
// Recheck render-time authority even if effect cleanup has not run yet.
|
|
40
|
+
const current = latest.current;
|
|
41
|
+
if (
|
|
42
|
+
current.identity === identity &&
|
|
43
|
+
current.enabled &&
|
|
44
|
+
current.canResize &&
|
|
45
|
+
current.target?.width === width &&
|
|
46
|
+
current.target?.height === height &&
|
|
47
|
+
(current.viewport?.width !== width || current.viewport?.height !== height)
|
|
48
|
+
) {
|
|
49
|
+
current.apply();
|
|
50
|
+
}
|
|
51
|
+
}, 200);
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
}, [
|
|
54
|
+
options.identity,
|
|
55
|
+
options.enabled,
|
|
56
|
+
options.canResize,
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
viewportWidth,
|
|
60
|
+
viewportHeight,
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connects native Image decode callbacks to the bounded handoff model. The
|
|
3
|
+
* visible-frame ref is changed in a layout effect, after the image-layer commit,
|
|
4
|
+
* so transport metadata cannot authorize input against pixels not yet displayed.
|
|
5
|
+
*/
|
|
6
|
+
import { type RefObject, useCallback, useLayoutEffect, useState } from "react";
|
|
7
|
+
import type { BrowserFrame } from "../shared/browser";
|
|
8
|
+
import { createBrowserFrameBuffer, type FrameCandidate } from "./browser-frame-buffer";
|
|
9
|
+
|
|
10
|
+
/** Bind qualified captures and native callbacks to committed visible input authority. */
|
|
11
|
+
export function useBrowserFrameBuffer(
|
|
12
|
+
visibleFrameRef: RefObject<BrowserFrame | null>,
|
|
13
|
+
isCurrent: (candidate: FrameCandidate) => boolean,
|
|
14
|
+
) {
|
|
15
|
+
const [model] = useState(createBrowserFrameBuffer);
|
|
16
|
+
const [buffer, setBuffer] = useState(model.snapshot);
|
|
17
|
+
const frame =
|
|
18
|
+
buffer.front === null ? null : (buffer.layers[buffer.front]?.candidate.frame ?? null);
|
|
19
|
+
|
|
20
|
+
useLayoutEffect(() => {
|
|
21
|
+
visibleFrameRef.current = frame;
|
|
22
|
+
return () => {
|
|
23
|
+
visibleFrameRef.current = null;
|
|
24
|
+
};
|
|
25
|
+
}, [frame, visibleFrameRef]);
|
|
26
|
+
|
|
27
|
+
const discardObsoletePending = useCallback(() => {
|
|
28
|
+
const current = model.snapshot();
|
|
29
|
+
const pending = current.pending === null ? null : current.layers[current.pending];
|
|
30
|
+
if (pending && !isCurrent(pending.candidate)) {
|
|
31
|
+
setBuffer(model.invalidatePending());
|
|
32
|
+
}
|
|
33
|
+
}, [isCurrent, model]);
|
|
34
|
+
const reset = useCallback(() => {
|
|
35
|
+
visibleFrameRef.current = null;
|
|
36
|
+
setBuffer(model.reset());
|
|
37
|
+
}, [model, visibleFrameRef]);
|
|
38
|
+
const receive = useCallback(
|
|
39
|
+
(candidate: FrameCandidate) => {
|
|
40
|
+
if (!isCurrent(candidate)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const current = model.snapshot();
|
|
44
|
+
const pending = current.pending === null ? null : current.layers[current.pending];
|
|
45
|
+
if (pending && !isCurrent(pending.candidate)) {
|
|
46
|
+
model.invalidatePending();
|
|
47
|
+
}
|
|
48
|
+
setBuffer(model.offer(candidate));
|
|
49
|
+
},
|
|
50
|
+
[isCurrent, model],
|
|
51
|
+
);
|
|
52
|
+
const settled = useCallback(
|
|
53
|
+
(ticket: number, succeeded: boolean) => {
|
|
54
|
+
const current = model.snapshot();
|
|
55
|
+
const pending = current.pending === null ? null : current.layers[current.pending];
|
|
56
|
+
if (!pending || pending.ticket !== ticket || !isCurrent(pending.candidate)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
setBuffer(model.settle(ticket, succeeded, isCurrent));
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
[isCurrent, model],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return { buffer, frame, receive, settled, discardObsoletePending, reset };
|
|
66
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Own the viewer's JPEG query and refresh scheduling. A current painted video
|
|
3
|
+
* front owns presentation and viewer renewal, so image polling, focus and input
|
|
4
|
+
* refresh cannot create redundant screenshots. Clearing video resumes the same
|
|
5
|
+
* image fallback query; already-published bounded RPCs are never replayed.
|
|
6
|
+
*/
|
|
7
|
+
import type { RpcInput } from "@getpaseo/plugin";
|
|
8
|
+
import { CancelledError, useQuery } from "@tanstack/react-query";
|
|
9
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
10
|
+
import type { BrowserFrame, captureBrowserRpc } from "../shared/browser";
|
|
11
|
+
import type { CaptureQuality } from "../shared/capture-settings";
|
|
12
|
+
import { browserCaptureInterval } from "./browser-capture-cadence";
|
|
13
|
+
|
|
14
|
+
interface ImageCaptureOptions {
|
|
15
|
+
/** Hidden retained panels keep state but publish no new media requests. */
|
|
16
|
+
active?: boolean;
|
|
17
|
+
viewerToken: string | null;
|
|
18
|
+
quality: CaptureQuality;
|
|
19
|
+
activeInput: boolean;
|
|
20
|
+
/** Exact held-video fallback request, zero during normal image playback. */
|
|
21
|
+
fallbackRevision?: number;
|
|
22
|
+
videoOwnsPresentation: boolean;
|
|
23
|
+
/** Read actual current video authority at request/follow-up time, before React commits. */
|
|
24
|
+
hasVideoPresentation(): boolean;
|
|
25
|
+
refreshVideo(): void;
|
|
26
|
+
mutationEpoch(): number;
|
|
27
|
+
knownFrame(): BrowserFrame | null;
|
|
28
|
+
capture(
|
|
29
|
+
input: RpcInput<typeof captureBrowserRpc>,
|
|
30
|
+
): Promise<ReturnType<typeof captureBrowserRpc.output.parse>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Preserve captured query identity/epoch and the existing single-flight fallback refresh. */
|
|
34
|
+
export function useBrowserImageCapture(options: ImageCaptureOptions) {
|
|
35
|
+
const latest = useRef(options);
|
|
36
|
+
latest.current = options;
|
|
37
|
+
const mounted = useRef(true);
|
|
38
|
+
const inFlight = useRef(false);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
mounted.current = true;
|
|
41
|
+
return () => {
|
|
42
|
+
mounted.current = false;
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const query = useQuery({
|
|
47
|
+
queryKey: ["shared-browser", "capture", options.viewerToken, options.quality],
|
|
48
|
+
queryFn: async () => {
|
|
49
|
+
if (!options.viewerToken) throw new Error("The browser viewer is not attached.");
|
|
50
|
+
// A timer already queued before video paint must not publish a JPEG RPC.
|
|
51
|
+
if (
|
|
52
|
+
!mounted.current ||
|
|
53
|
+
latest.current.active === false ||
|
|
54
|
+
latest.current.viewerToken !== options.viewerToken ||
|
|
55
|
+
latest.current.quality !== options.quality ||
|
|
56
|
+
latest.current.videoOwnsPresentation ||
|
|
57
|
+
latest.current.hasVideoPresentation()
|
|
58
|
+
) {
|
|
59
|
+
throw new CancelledError({ silent: true, revert: true });
|
|
60
|
+
}
|
|
61
|
+
const mutationEpoch = options.mutationEpoch();
|
|
62
|
+
const fallbackRevision = latest.current.fallbackRevision ?? 0;
|
|
63
|
+
inFlight.current = true;
|
|
64
|
+
try {
|
|
65
|
+
const result = await options.capture({
|
|
66
|
+
viewerToken: options.viewerToken,
|
|
67
|
+
quality: options.quality,
|
|
68
|
+
// A held-video handoff needs an actual image payload, even when the
|
|
69
|
+
// underlying JPEG still matches the server's incremental cache.
|
|
70
|
+
knownFrameId: fallbackRevision ? null : (options.knownFrame()?.frameId ?? null),
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
...result,
|
|
74
|
+
mutationEpoch,
|
|
75
|
+
fallbackRevision,
|
|
76
|
+
viewerToken: options.viewerToken,
|
|
77
|
+
captureQuality: options.quality,
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
inFlight.current = false;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
enabled:
|
|
84
|
+
options.active !== false &&
|
|
85
|
+
Boolean(options.viewerToken) &&
|
|
86
|
+
!options.videoOwnsPresentation &&
|
|
87
|
+
!options.hasVideoPresentation(),
|
|
88
|
+
retry: false,
|
|
89
|
+
refetchInterval: (query) =>
|
|
90
|
+
options.active === false || options.videoOwnsPresentation
|
|
91
|
+
? false
|
|
92
|
+
: browserCaptureInterval(query.state.data?.state.status, options.activeInput),
|
|
93
|
+
refetchIntervalInBackground: false,
|
|
94
|
+
refetchOnWindowFocus: !options.videoOwnsPresentation,
|
|
95
|
+
staleTime: 0,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const refreshCapture = useCallback(() => {
|
|
99
|
+
const current = latest.current;
|
|
100
|
+
if (!mounted.current || current.active === false) return;
|
|
101
|
+
current.refreshVideo();
|
|
102
|
+
if (!current.viewerToken || current.videoOwnsPresentation || current.hasVideoPresentation())
|
|
103
|
+
return;
|
|
104
|
+
const requestWasInFlight = inFlight.current;
|
|
105
|
+
const viewerToken = current.viewerToken;
|
|
106
|
+
const quality = current.quality;
|
|
107
|
+
const request = query.refetch({ cancelRefetch: false });
|
|
108
|
+
if (requestWasInFlight) {
|
|
109
|
+
void request.then(() => {
|
|
110
|
+
const after = latest.current;
|
|
111
|
+
if (
|
|
112
|
+
!mounted.current ||
|
|
113
|
+
after.active === false ||
|
|
114
|
+
after.viewerToken !== viewerToken ||
|
|
115
|
+
after.quality !== quality ||
|
|
116
|
+
after.videoOwnsPresentation ||
|
|
117
|
+
after.hasVideoPresentation()
|
|
118
|
+
)
|
|
119
|
+
return;
|
|
120
|
+
void query.refetch({ cancelRefetch: false });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, [query.refetch]);
|
|
124
|
+
|
|
125
|
+
const retryFrameCapture = useCallback(() => {
|
|
126
|
+
const current = latest.current;
|
|
127
|
+
if (
|
|
128
|
+
mounted.current &&
|
|
129
|
+
current.active !== false &&
|
|
130
|
+
current.viewerToken &&
|
|
131
|
+
!current.videoOwnsPresentation &&
|
|
132
|
+
!current.hasVideoPresentation()
|
|
133
|
+
) {
|
|
134
|
+
void query.refetch({ cancelRefetch: false });
|
|
135
|
+
}
|
|
136
|
+
}, [query.refetch]);
|
|
137
|
+
|
|
138
|
+
return { query, refreshCapture, retryFrameCapture };
|
|
139
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Exercise actual publication/settlement callbacks across render and mount boundaries. */
|
|
2
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const fixture = vi.hoisted(() => ({
|
|
5
|
+
slots: [] as { current: unknown }[],
|
|
6
|
+
index: 0,
|
|
7
|
+
cleanup: null as (() => void) | null,
|
|
8
|
+
options: null as unknown,
|
|
9
|
+
submissions: [] as unknown[],
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("react", () => ({
|
|
12
|
+
useRef(value: unknown) {
|
|
13
|
+
const index = fixture.index++;
|
|
14
|
+
if (!fixture.slots[index]) fixture.slots[index] = { current: value };
|
|
15
|
+
return fixture.slots[index];
|
|
16
|
+
},
|
|
17
|
+
useLayoutEffect(operation: () => () => void) {
|
|
18
|
+
if (!fixture.cleanup) fixture.cleanup = operation();
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("@tanstack/react-query", () => ({
|
|
22
|
+
useMutation(options: unknown) {
|
|
23
|
+
fixture.options = options;
|
|
24
|
+
return { isPending: false, mutate: (request: unknown) => fixture.submissions.push(request) };
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { useBrowserScopedMutation } from "./use-browser-scoped-mutation";
|
|
29
|
+
|
|
30
|
+
const send = vi.fn(async (input: string) => ({ value: input }));
|
|
31
|
+
const success = vi.fn();
|
|
32
|
+
const failure = vi.fn();
|
|
33
|
+
function render(identity = "host/workspace/viewer/control", currentIdentity?: () => string) {
|
|
34
|
+
fixture.index = 0;
|
|
35
|
+
const hook = useBrowserScopedMutation({
|
|
36
|
+
identity,
|
|
37
|
+
...(currentIdentity ? { currentIdentity } : {}),
|
|
38
|
+
mutationFn: send,
|
|
39
|
+
onSuccess: success,
|
|
40
|
+
onError: failure,
|
|
41
|
+
});
|
|
42
|
+
const options = fixture.options as {
|
|
43
|
+
mutationFn(request: { input: string; identity: string }): Promise<{ value: string }>;
|
|
44
|
+
onSuccess(result: { value: string }, request: { input: string; identity: string }): void;
|
|
45
|
+
onError(error: unknown, request: { input: string; identity: string }): void;
|
|
46
|
+
retry: boolean;
|
|
47
|
+
};
|
|
48
|
+
return { hook, options };
|
|
49
|
+
}
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
fixture.slots = [];
|
|
52
|
+
fixture.index = 0;
|
|
53
|
+
fixture.cleanup = null;
|
|
54
|
+
fixture.submissions = [];
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
it("captures submitted identity, keeps current settlement and never retries", async () => {
|
|
58
|
+
const { hook, options } = render();
|
|
59
|
+
hook.mutate("chosen");
|
|
60
|
+
const request = fixture.submissions[0] as { input: string; identity: string };
|
|
61
|
+
const result = await options.mutationFn(request);
|
|
62
|
+
options.onSuccess(result, request);
|
|
63
|
+
expect(send).toHaveBeenCalledExactlyOnceWith("chosen");
|
|
64
|
+
expect(success).toHaveBeenCalledExactlyOnceWith(result);
|
|
65
|
+
expect(options.retry).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
for (const replacement of [
|
|
68
|
+
"new-host/workspace/viewer/control",
|
|
69
|
+
"host/new-workspace/viewer/control",
|
|
70
|
+
"host/workspace/new-viewer/control",
|
|
71
|
+
"host/workspace/viewer/new-control",
|
|
72
|
+
]) {
|
|
73
|
+
it(`discards published success and failure after ${replacement}`, async () => {
|
|
74
|
+
const previous = render();
|
|
75
|
+
previous.hook.mutate("published");
|
|
76
|
+
const request = fixture.submissions[0] as { input: string; identity: string };
|
|
77
|
+
await previous.options.mutationFn(request);
|
|
78
|
+
const latest = render(replacement);
|
|
79
|
+
expect(() => latest.options.mutationFn(request)).toThrow("context changed");
|
|
80
|
+
latest.options.onSuccess({ value: "old" }, request);
|
|
81
|
+
latest.options.onError(new Error("old"), request);
|
|
82
|
+
previous.hook.mutate("retained callback");
|
|
83
|
+
expect(success).not.toHaveBeenCalled();
|
|
84
|
+
expect(failure).not.toHaveBeenCalled();
|
|
85
|
+
expect(fixture.submissions).toHaveLength(1);
|
|
86
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
it("refuses stale queued publication and ignores callbacks after unmount", async () => {
|
|
90
|
+
const { hook, options } = render();
|
|
91
|
+
hook.mutate("unsent");
|
|
92
|
+
const request = fixture.submissions[0] as { input: string; identity: string };
|
|
93
|
+
fixture.cleanup?.();
|
|
94
|
+
expect(() => options.mutationFn(request)).toThrow("context changed");
|
|
95
|
+
options.onSuccess({ value: "late" }, request);
|
|
96
|
+
options.onError(new Error("late"), request);
|
|
97
|
+
hook.mutate("after unmount");
|
|
98
|
+
expect(send).not.toHaveBeenCalled();
|
|
99
|
+
expect(success).not.toHaveBeenCalled();
|
|
100
|
+
expect(failure).not.toHaveBeenCalled();
|
|
101
|
+
expect(fixture.submissions).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("synchronous authority observations revoke settlement before another render", async () => {
|
|
105
|
+
let observed = "host/workspace/viewer/control";
|
|
106
|
+
const { hook, options } = render(observed, () => observed);
|
|
107
|
+
hook.mutate("published");
|
|
108
|
+
const request = fixture.submissions[0] as { input: string; identity: string };
|
|
109
|
+
await options.mutationFn(request);
|
|
110
|
+
observed = "host/workspace/viewer/new-ownership";
|
|
111
|
+
options.onSuccess({ value: "late" }, request);
|
|
112
|
+
options.onError(new Error("late"), request);
|
|
113
|
+
hook.mutate("not yet rendered");
|
|
114
|
+
expect(success).not.toHaveBeenCalled();
|
|
115
|
+
expect(failure).not.toHaveBeenCalled();
|
|
116
|
+
expect(fixture.submissions).toHaveLength(1);
|
|
117
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|