@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.237.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -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 +1201 -0
- package/client/browser-input-queue.ts +505 -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-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 +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- 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 +370 -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 +991 -0
- package/client/use-browser-video.ts +474 -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 -0
- package/package.json +9 -5
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -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 +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -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 +305 -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/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 +80 -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,23 @@
|
|
|
1
|
+
/** Filled and outlined favorite indicators with identical cross-platform font geometry. */
|
|
2
|
+
import { Text } from "react-native";
|
|
3
|
+
|
|
4
|
+
/** The host Icon API exposes stroke only; paired system glyphs provide real fill on phones too. */
|
|
5
|
+
export function FavoriteStar({
|
|
6
|
+
filled,
|
|
7
|
+
color,
|
|
8
|
+
size = 18,
|
|
9
|
+
}: {
|
|
10
|
+
filled: boolean;
|
|
11
|
+
color: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<Text
|
|
16
|
+
accessibilityElementsHidden
|
|
17
|
+
importantForAccessibility="no"
|
|
18
|
+
style={{ color, fontSize: size, lineHeight: size + 2, textAlign: "center" }}
|
|
19
|
+
>
|
|
20
|
+
{filled ? "\u2605" : "\u2606"}
|
|
21
|
+
</Text>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,65 +1,68 @@
|
|
|
1
|
-
import type { BrowserFrame } from "../shared/browser";
|
|
1
|
+
import type { BrowserFrameAuthority as BrowserFrame } from "../shared/browser-video";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tracks
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* stays visible meanwhile.
|
|
4
|
+
* Tracks actionable captures across discrete input settlement. The decoded
|
|
5
|
+
* image remains visible while admission waits for a capture begun after settle.
|
|
6
|
+
* A factory keeps this upstream policy compatible with Paseo's mobile Hermes
|
|
7
|
+
* compiler, which rejects the emitted class declaration in client bundles.
|
|
9
8
|
*/
|
|
10
|
-
export
|
|
11
|
-
epoch = 0;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
visible: BrowserFrame | null = null;
|
|
16
|
-
private gate: "idle" | "pending" | "refreshing" = "idle";
|
|
9
|
+
export function createFrameLifecycle() {
|
|
10
|
+
let epoch = 0;
|
|
11
|
+
let frame: BrowserFrame | null = null;
|
|
12
|
+
let visible: BrowserFrame | null = null;
|
|
13
|
+
let gate: "idle" | "pending" | "refreshing" = "idle";
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
15
|
+
const bump = () => {
|
|
16
|
+
epoch += 1;
|
|
17
|
+
};
|
|
18
|
+
const drop = () => {
|
|
19
|
+
frame = null;
|
|
20
|
+
visible = null;
|
|
21
|
+
};
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
23
|
+
return {
|
|
24
|
+
get epoch() {
|
|
25
|
+
return epoch;
|
|
26
|
+
},
|
|
27
|
+
get frame() {
|
|
28
|
+
return frame;
|
|
29
|
+
},
|
|
30
|
+
get visible() {
|
|
31
|
+
return visible;
|
|
32
|
+
},
|
|
33
|
+
get busy() {
|
|
34
|
+
return gate !== "idle";
|
|
35
|
+
},
|
|
36
|
+
bump,
|
|
37
|
+
isCurrent(captureEpoch: number) {
|
|
38
|
+
return captureEpoch === epoch;
|
|
39
|
+
},
|
|
40
|
+
/** Blocks further discrete input until settlement and a fresh decoded frame. */
|
|
41
|
+
begin() {
|
|
42
|
+
if (gate !== "idle") return false;
|
|
43
|
+
gate = "pending";
|
|
44
|
+
frame = null;
|
|
45
|
+
bump();
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
/** Advances even on failure; an uncertain action is never replayed. */
|
|
49
|
+
settle() {
|
|
50
|
+
frame = null;
|
|
51
|
+
bump();
|
|
52
|
+
gate = "refreshing";
|
|
53
|
+
},
|
|
54
|
+
/** A capture begun before settlement cannot restore admission. */
|
|
55
|
+
accept(captureEpoch: number, captured: BrowserFrame) {
|
|
56
|
+
if (captureEpoch !== epoch) return false;
|
|
57
|
+
frame = captured;
|
|
58
|
+
visible = captured;
|
|
59
|
+
if (gate === "refreshing") gate = "idle";
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
drop,
|
|
63
|
+
reset() {
|
|
64
|
+
drop();
|
|
65
|
+
gate = "idle";
|
|
66
|
+
},
|
|
67
|
+
};
|
|
65
68
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("react", () => ({
|
|
11
|
+
useRef(value: unknown) {
|
|
12
|
+
fixture.ref ??= { current: value };
|
|
13
|
+
return fixture.ref;
|
|
14
|
+
},
|
|
15
|
+
useEffect(operation: () => (() => void) | undefined, dependencies: unknown[]) {
|
|
16
|
+
if (
|
|
17
|
+
fixture.dependencies &&
|
|
18
|
+
dependencies.every((value, index) => Object.is(value, fixture.dependencies?.[index]))
|
|
19
|
+
) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
fixture.cleanup?.();
|
|
23
|
+
fixture.dependencies = dependencies;
|
|
24
|
+
fixture.cleanup = operation() ?? null;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import type { CaptureDensity } from "../shared/capture-density";
|
|
29
|
+
import { useBrowserAutoCaptureDensity } from "./use-browser-auto-capture-density";
|
|
30
|
+
|
|
31
|
+
const apply = vi.fn();
|
|
32
|
+
const defaults = {
|
|
33
|
+
identity: "host/workspace/viewer/control",
|
|
34
|
+
enabled: true,
|
|
35
|
+
canChange: true,
|
|
36
|
+
target: 2 as CaptureDensity | null,
|
|
37
|
+
currentDensity: 1,
|
|
38
|
+
apply,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
fixture.ref = null;
|
|
43
|
+
fixture.dependencies = null;
|
|
44
|
+
fixture.cleanup = null;
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
vi.useFakeTimers();
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
fixture.cleanup?.();
|
|
50
|
+
vi.useRealTimers();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("debounces adjustments and does not repeat acknowledged density changes", () => {
|
|
54
|
+
useBrowserAutoCaptureDensity(defaults);
|
|
55
|
+
vi.advanceTimersByTime(249);
|
|
56
|
+
expect(apply).not.toHaveBeenCalled();
|
|
57
|
+
vi.advanceTimersByTime(1);
|
|
58
|
+
expect(apply).toHaveBeenCalledExactlyOnceWith(2);
|
|
59
|
+
useBrowserAutoCaptureDensity({ ...defaults, currentDensity: 2 });
|
|
60
|
+
vi.advanceTimersByTime(1000);
|
|
61
|
+
expect(apply).toHaveBeenCalledOnce();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("cancels queued work for observers, hidden panels, pending mutations and manual choices", () => {
|
|
65
|
+
for (const override of [{ canChange: false }, { enabled: false }, { target: null }]) {
|
|
66
|
+
useBrowserAutoCaptureDensity(defaults);
|
|
67
|
+
vi.advanceTimersByTime(100);
|
|
68
|
+
useBrowserAutoCaptureDensity({ ...defaults, ...override });
|
|
69
|
+
vi.advanceTimersByTime(500);
|
|
70
|
+
expect(apply).not.toHaveBeenCalled();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("waits while Fill viewport owns a combined size and density transition", () => {
|
|
75
|
+
useBrowserAutoCaptureDensity({ ...defaults, canChange: false });
|
|
76
|
+
vi.advanceTimersByTime(500);
|
|
77
|
+
expect(apply).not.toHaveBeenCalled();
|
|
78
|
+
useBrowserAutoCaptureDensity({ ...defaults, currentDensity: 2 });
|
|
79
|
+
vi.advanceTimersByTime(500);
|
|
80
|
+
expect(apply).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("drops superseded screen measurements and uses the latest scoped action", () => {
|
|
84
|
+
useBrowserAutoCaptureDensity(defaults);
|
|
85
|
+
vi.advanceTimersByTime(100);
|
|
86
|
+
useBrowserAutoCaptureDensity({ ...defaults, target: 1 });
|
|
87
|
+
vi.advanceTimersByTime(500);
|
|
88
|
+
expect(apply).not.toHaveBeenCalled();
|
|
89
|
+
const latest = vi.fn();
|
|
90
|
+
useBrowserAutoCaptureDensity({ ...defaults, apply: latest });
|
|
91
|
+
vi.advanceTimersByTime(250);
|
|
92
|
+
expect(latest).toHaveBeenCalledExactlyOnceWith(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("cancels on workspace replacement and unmount", () => {
|
|
96
|
+
useBrowserAutoCaptureDensity(defaults);
|
|
97
|
+
useBrowserAutoCaptureDensity({ ...defaults, identity: "other/workspace", enabled: false });
|
|
98
|
+
vi.advanceTimersByTime(500);
|
|
99
|
+
expect(apply).not.toHaveBeenCalled();
|
|
100
|
+
useBrowserAutoCaptureDensity(defaults);
|
|
101
|
+
fixture.cleanup?.();
|
|
102
|
+
vi.advanceTimersByTime(500);
|
|
103
|
+
expect(apply).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Debounced automatic detail adjustments, with no observer writes or failure retry loop. */
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import type { CaptureDensity } from "../shared/capture-density";
|
|
4
|
+
|
|
5
|
+
interface Options {
|
|
6
|
+
identity: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
canChange: boolean;
|
|
9
|
+
target: CaptureDensity | null;
|
|
10
|
+
currentDensity: number;
|
|
11
|
+
apply(density: CaptureDensity): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Cancel pending adjustments when scope, ownership, manual choice or geometry changes. */
|
|
15
|
+
export function useBrowserAutoCaptureDensity(options: Options): void {
|
|
16
|
+
const latest = useRef(options);
|
|
17
|
+
latest.current = options;
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!options.enabled || !options.canChange || options.target === null) return;
|
|
20
|
+
if (options.target === options.currentDensity) return;
|
|
21
|
+
const density = options.target;
|
|
22
|
+
const identity = options.identity;
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
const current = latest.current;
|
|
25
|
+
if (
|
|
26
|
+
current.identity === identity &&
|
|
27
|
+
current.enabled &&
|
|
28
|
+
current.canChange &&
|
|
29
|
+
current.target === density &&
|
|
30
|
+
current.currentDensity !== density
|
|
31
|
+
) {
|
|
32
|
+
current.apply(density);
|
|
33
|
+
}
|
|
34
|
+
}, 250);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [
|
|
37
|
+
options.identity,
|
|
38
|
+
options.enabled,
|
|
39
|
+
options.canChange,
|
|
40
|
+
options.target,
|
|
41
|
+
options.currentDensity,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live input queue per mounted canvas, shared by native responders and real
|
|
3
|
+
* web listeners. Ownership changes cancel old channels; decode-only frame updates
|
|
4
|
+
* do not. Empty channels close after four seconds; their acknowledged geometry
|
|
5
|
+
* can reopen only within the same control/document/layout. Cursor replies are
|
|
6
|
+
* consumed only by the queue incarnation that sent
|
|
7
|
+
* them. The existing capture polling keeps playback running during a drag.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
AppState,
|
|
12
|
+
type GestureResponderEvent,
|
|
13
|
+
PanResponder,
|
|
14
|
+
Platform,
|
|
15
|
+
type View,
|
|
16
|
+
} from "react-native";
|
|
17
|
+
import type { BrowserCursor, BrowserState } from "../shared/browser";
|
|
18
|
+
import { createBrowserCanvasInput } from "./browser-canvas-input";
|
|
19
|
+
import type { CanvasKeyboardEvent } from "./browser-canvas-keyboard";
|
|
20
|
+
import { bindBrowserCanvasKeyboard, type KeyboardCanvasNode } from "./browser-canvas-keyboard-web";
|
|
21
|
+
import {
|
|
22
|
+
BROWSER_INPUT_IDLE_MS,
|
|
23
|
+
type BrowserGestureAuthority,
|
|
24
|
+
type BrowserGesturePoint,
|
|
25
|
+
type BrowserGestureTransport,
|
|
26
|
+
type BrowserTouchPoint,
|
|
27
|
+
createBrowserInputQueue,
|
|
28
|
+
} from "./browser-input-queue";
|
|
29
|
+
import { type BrowserCanvasNode, bindBrowserCanvasWeb, setBrowserCanvasCursor } from "./web";
|
|
30
|
+
|
|
31
|
+
interface CanvasOptions {
|
|
32
|
+
authority(): BrowserGestureAuthority | null;
|
|
33
|
+
controlAuthority?(): import("./browser-input-queue").BrowserGestureControl | null;
|
|
34
|
+
transport: BrowserGestureTransport;
|
|
35
|
+
/** Control/session/generations/viewport/display rectangle, excluding frameId. */
|
|
36
|
+
ownershipKey: string;
|
|
37
|
+
/** Actual input-admitted paint publication, separate from the visually displayed frame. */
|
|
38
|
+
decodedFrameId: string | null;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
displaySize: { width: number; height: number } | null;
|
|
41
|
+
viewport: { width: number; height: number } | null;
|
|
42
|
+
onState(state: BrowserState): void;
|
|
43
|
+
onError(error: unknown): void;
|
|
44
|
+
onFinish(): void;
|
|
45
|
+
onPoint(point: BrowserGesturePoint): void;
|
|
46
|
+
onActivity(active: boolean): void;
|
|
47
|
+
onInputBoundary(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Native touch coordinates are relative to the controlled overlay, with stable ID mapping. */
|
|
51
|
+
function nativePoints(
|
|
52
|
+
event: GestureResponderEvent,
|
|
53
|
+
size: CanvasOptions["displaySize"],
|
|
54
|
+
ids: Map<string, number>,
|
|
55
|
+
): BrowserTouchPoint[] {
|
|
56
|
+
if (!size || size.width <= 0 || size.height <= 0) return [];
|
|
57
|
+
return event.nativeEvent.touches.map((touch) => {
|
|
58
|
+
let id = ids.get(touch.identifier);
|
|
59
|
+
if (id === undefined) {
|
|
60
|
+
id = ids.size;
|
|
61
|
+
ids.set(touch.identifier, id);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
x: Math.max(0, Math.min(size.width, touch.locationX)),
|
|
66
|
+
y: Math.max(0, Math.min(size.height, touch.locationY)),
|
|
67
|
+
width: size.width,
|
|
68
|
+
height: size.height,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Attach the same authority-preserving input model to native View or web host events. */
|
|
74
|
+
export function useBrowserCanvasInput(options: CanvasOptions) {
|
|
75
|
+
const current = useRef(options);
|
|
76
|
+
current.current = options;
|
|
77
|
+
const nodeRef = useRef<unknown>(null);
|
|
78
|
+
const [node, setNode] = useState<unknown>(null);
|
|
79
|
+
const alive = useRef(true);
|
|
80
|
+
const inputModel = useRef<ReturnType<typeof createBrowserCanvasInput> | null>(null);
|
|
81
|
+
const keyboard = useRef<ReturnType<typeof bindBrowserCanvasKeyboard> | null>(null);
|
|
82
|
+
const cursorVisible = useRef(false);
|
|
83
|
+
const frameWaiters = useRef(
|
|
84
|
+
new Set<{
|
|
85
|
+
afterFrameId: string;
|
|
86
|
+
resolve(): void;
|
|
87
|
+
reject(error: Error): void;
|
|
88
|
+
}>(),
|
|
89
|
+
);
|
|
90
|
+
const rejectFrameWaiters = () => {
|
|
91
|
+
for (const waiter of frameWaiters.current)
|
|
92
|
+
waiter.reject(new Error("Browser input context changed."));
|
|
93
|
+
frameWaiters.current.clear();
|
|
94
|
+
};
|
|
95
|
+
const [queue] = useState(() =>
|
|
96
|
+
createBrowserInputQueue({
|
|
97
|
+
transport: {
|
|
98
|
+
begin: (input) => current.current.transport.begin(input),
|
|
99
|
+
update: (input) => current.current.transport.update(input),
|
|
100
|
+
end: (input) => current.current.transport.end(input),
|
|
101
|
+
},
|
|
102
|
+
authority: () => (alive.current ? current.current.authority() : null),
|
|
103
|
+
controlAuthority: () => {
|
|
104
|
+
if (!alive.current) return null;
|
|
105
|
+
if (current.current.controlAuthority) return current.current.controlAuthority();
|
|
106
|
+
return current.current.authority();
|
|
107
|
+
},
|
|
108
|
+
onState: (state) => {
|
|
109
|
+
if (alive.current) current.current.onState(state);
|
|
110
|
+
},
|
|
111
|
+
onCursor: (cursor: BrowserCursor | null) => {
|
|
112
|
+
if (cursorVisible.current) setBrowserCanvasCursor(nodeRef.current, cursor);
|
|
113
|
+
},
|
|
114
|
+
onError: (error) => {
|
|
115
|
+
// Queue owns cleanup; reset local contacts without recursively cancelling.
|
|
116
|
+
inputModel.current?.reset();
|
|
117
|
+
keyboard.current?.reset();
|
|
118
|
+
cursorVisible.current = false;
|
|
119
|
+
setBrowserCanvasCursor(nodeRef.current, null);
|
|
120
|
+
clearIdle();
|
|
121
|
+
if (alive.current) current.current.onError(error);
|
|
122
|
+
},
|
|
123
|
+
onNavigationComplete: () => {
|
|
124
|
+
// Server already closed the acknowledged channel. Quarantine local
|
|
125
|
+
// holds before state projection; never replay their old-page release.
|
|
126
|
+
inputModel.current?.reset();
|
|
127
|
+
keyboard.current?.reset();
|
|
128
|
+
cursorVisible.current = false;
|
|
129
|
+
setBrowserCanvasCursor(nodeRef.current, null);
|
|
130
|
+
clearIdle();
|
|
131
|
+
rejectFrameWaiters();
|
|
132
|
+
},
|
|
133
|
+
onFinish: () => {
|
|
134
|
+
if (alive.current) current.current.onFinish();
|
|
135
|
+
},
|
|
136
|
+
waitForFrame(afterFrameId, maxWaitMs = 4_000) {
|
|
137
|
+
return new Promise<void>((resolve, reject) => {
|
|
138
|
+
const waiter = {
|
|
139
|
+
afterFrameId,
|
|
140
|
+
resolve: () => {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
frameWaiters.current.delete(waiter);
|
|
143
|
+
resolve();
|
|
144
|
+
},
|
|
145
|
+
reject: (error: Error) => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
frameWaiters.current.delete(waiter);
|
|
148
|
+
reject(error);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
// The existing server gesture idle bound is five seconds. Stop an unsent
|
|
152
|
+
// press before it expires; time alone never authorizes new image pixels.
|
|
153
|
+
const timeout = setTimeout(
|
|
154
|
+
() =>
|
|
155
|
+
waiter.reject(
|
|
156
|
+
new Error(
|
|
157
|
+
"Waiting for a current decoded frame. Release the gesture and try again.",
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
Math.min(4_000, maxWaitMs),
|
|
161
|
+
);
|
|
162
|
+
frameWaiters.current.add(waiter);
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
168
|
+
const clearIdle = () => {
|
|
169
|
+
if (idleTimer.current) clearTimeout(idleTimer.current);
|
|
170
|
+
idleTimer.current = null;
|
|
171
|
+
};
|
|
172
|
+
const [input] = useState(() =>
|
|
173
|
+
createBrowserCanvasInput({
|
|
174
|
+
enabled: () => alive.current && current.current.enabled,
|
|
175
|
+
viewport: () => current.current.viewport,
|
|
176
|
+
enqueue(event) {
|
|
177
|
+
clearIdle();
|
|
178
|
+
// Queue snapshots the actual decoded admission before a local epoch bump.
|
|
179
|
+
// Already admitted edges continue on their opaque channel independently.
|
|
180
|
+
const accepted = queue.enqueue(event);
|
|
181
|
+
if (
|
|
182
|
+
accepted &&
|
|
183
|
+
(event.kind === "down" || (event.kind === "touch" && event.type === "start"))
|
|
184
|
+
) {
|
|
185
|
+
current.current.onInputBoundary();
|
|
186
|
+
}
|
|
187
|
+
if (event.kind === "move" || event.kind === "scroll") {
|
|
188
|
+
idleTimer.current = setTimeout(() => {
|
|
189
|
+
// Never end a slow held drag just because the user stopped moving.
|
|
190
|
+
if (!input.isHoldingMouse()) {
|
|
191
|
+
current.current.onActivity(false);
|
|
192
|
+
queue.finish();
|
|
193
|
+
}
|
|
194
|
+
}, BROWSER_INPUT_IDLE_MS);
|
|
195
|
+
}
|
|
196
|
+
return accepted;
|
|
197
|
+
},
|
|
198
|
+
finish: () => {
|
|
199
|
+
clearIdle();
|
|
200
|
+
// Release was enqueued immediately. Keep the empty exact channel for
|
|
201
|
+
// ordinary repeat input, closing before the server's five-second idle bound.
|
|
202
|
+
idleTimer.current = setTimeout(() => queue.finish(), BROWSER_INPUT_IDLE_MS);
|
|
203
|
+
},
|
|
204
|
+
cancel: () => {
|
|
205
|
+
clearIdle();
|
|
206
|
+
keyboard.current?.reset();
|
|
207
|
+
cursorVisible.current = false;
|
|
208
|
+
setBrowserCanvasCursor(nodeRef.current, null);
|
|
209
|
+
queue.cancel();
|
|
210
|
+
rejectFrameWaiters();
|
|
211
|
+
},
|
|
212
|
+
onPoint: (point) => current.current.onPoint(point),
|
|
213
|
+
onActivity: (active) => {
|
|
214
|
+
if (alive.current) current.current.onActivity(active);
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
inputModel.current = input;
|
|
220
|
+
|
|
221
|
+
const enqueueKeyboard = useCallback(
|
|
222
|
+
(event: CanvasKeyboardEvent) => {
|
|
223
|
+
clearIdle();
|
|
224
|
+
const accepted = queue.enqueue(event);
|
|
225
|
+
if (accepted) {
|
|
226
|
+
if (event.kind === "text" || (event.type === "down" && !event.repeat)) {
|
|
227
|
+
current.current.onInputBoundary();
|
|
228
|
+
}
|
|
229
|
+
current.current.onActivity(true);
|
|
230
|
+
}
|
|
231
|
+
return accepted;
|
|
232
|
+
},
|
|
233
|
+
[queue],
|
|
234
|
+
);
|
|
235
|
+
const finishKeyboard = useCallback(() => {
|
|
236
|
+
current.current.onActivity(false);
|
|
237
|
+
// Keep one channel through ordinary inter-key gaps. New typing clears this
|
|
238
|
+
// timer, while blur/control loss cancels immediately through the input model.
|
|
239
|
+
clearIdle();
|
|
240
|
+
idleTimer.current = setTimeout(() => queue.finish(), BROWSER_INPUT_IDLE_MS);
|
|
241
|
+
}, [queue]);
|
|
242
|
+
/** Insert an explicitly committed draft once; false means nothing was admitted. */
|
|
243
|
+
const composeText = useCallback(
|
|
244
|
+
(text: string): boolean => {
|
|
245
|
+
if (!alive.current || !current.current.enabled || !text) return false;
|
|
246
|
+
if (text.length > 16_000) {
|
|
247
|
+
current.current.onError(
|
|
248
|
+
new Error("Text is too long. Send up to 16,000 characters at once."),
|
|
249
|
+
);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const accepted = enqueueKeyboard({ kind: "text", text });
|
|
253
|
+
if (accepted) finishKeyboard();
|
|
254
|
+
return accepted;
|
|
255
|
+
},
|
|
256
|
+
[enqueueKeyboard, finishKeyboard],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const previousOwnership = useRef(options.ownershipKey);
|
|
260
|
+
useLayoutEffect(() => {
|
|
261
|
+
if (previousOwnership.current === options.ownershipKey) return;
|
|
262
|
+
previousOwnership.current = options.ownershipKey;
|
|
263
|
+
input.cancel();
|
|
264
|
+
}, [input, options.ownershipKey]);
|
|
265
|
+
useLayoutEffect(() => {
|
|
266
|
+
for (const waiter of frameWaiters.current) {
|
|
267
|
+
const admitted = current.current.authority();
|
|
268
|
+
if (
|
|
269
|
+
options.decodedFrameId &&
|
|
270
|
+
admitted?.target.frameId === options.decodedFrameId &&
|
|
271
|
+
options.decodedFrameId !== waiter.afterFrameId
|
|
272
|
+
) {
|
|
273
|
+
waiter.resolve();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}, [options.decodedFrameId]);
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
alive.current = true;
|
|
279
|
+
const subscription = AppState.addEventListener("change", (state) => {
|
|
280
|
+
if (state !== "active") input.cancel();
|
|
281
|
+
});
|
|
282
|
+
return () => {
|
|
283
|
+
alive.current = false;
|
|
284
|
+
input.cancel();
|
|
285
|
+
subscription.remove();
|
|
286
|
+
};
|
|
287
|
+
}, [input]);
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (Platform.OS !== "web" || !node || typeof node !== "object" || !("addEventListener" in node))
|
|
290
|
+
return;
|
|
291
|
+
return bindBrowserCanvasWeb(
|
|
292
|
+
node as BrowserCanvasNode,
|
|
293
|
+
input,
|
|
294
|
+
() => alive.current && current.current.enabled,
|
|
295
|
+
(visible) => {
|
|
296
|
+
cursorVisible.current = visible;
|
|
297
|
+
if (!visible) setBrowserCanvasCursor(nodeRef.current, null);
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
}, [input, node]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (Platform.OS !== "web" || !node || typeof node !== "object" || !("ownerDocument" in node))
|
|
304
|
+
return;
|
|
305
|
+
const binding = bindBrowserCanvasKeyboard(node as KeyboardCanvasNode, {
|
|
306
|
+
enabled: () => alive.current && current.current.enabled,
|
|
307
|
+
enqueue: enqueueKeyboard,
|
|
308
|
+
finish: finishKeyboard,
|
|
309
|
+
cancel: input.cancel,
|
|
310
|
+
onError: (error) => {
|
|
311
|
+
if (alive.current) current.current.onError(error);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
keyboard.current = binding;
|
|
315
|
+
return () => {
|
|
316
|
+
binding.dispose();
|
|
317
|
+
if (keyboard.current === binding) keyboard.current = null;
|
|
318
|
+
};
|
|
319
|
+
}, [input, node, enqueueKeyboard, finishKeyboard]);
|
|
320
|
+
|
|
321
|
+
const ids = useRef(new Map<string, number>());
|
|
322
|
+
const [panResponder] = useState(() =>
|
|
323
|
+
PanResponder.create({
|
|
324
|
+
onStartShouldSetPanResponder: (event) => {
|
|
325
|
+
if (Platform.OS === "web" || !current.current.enabled) return false;
|
|
326
|
+
// A newly granted responder must contain only genuinely new contacts.
|
|
327
|
+
// A second finger cannot import a first contact that began observe-only.
|
|
328
|
+
const changed = new Set(
|
|
329
|
+
(event.nativeEvent.changedTouches ?? []).map((touch) => touch.identifier),
|
|
330
|
+
);
|
|
331
|
+
return (
|
|
332
|
+
event.nativeEvent.touches.length > 0 &&
|
|
333
|
+
event.nativeEvent.touches.every((touch) => changed.has(touch.identifier))
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
// Moving a contact after lease acquisition is not a fresh start edge.
|
|
337
|
+
onMoveShouldSetPanResponder: () => false,
|
|
338
|
+
onPanResponderGrant: (event) => {
|
|
339
|
+
ids.current.clear();
|
|
340
|
+
input.touch("start", nativePoints(event, current.current.displaySize, ids.current));
|
|
341
|
+
},
|
|
342
|
+
onPanResponderStart: (event) =>
|
|
343
|
+
input.touch("start", nativePoints(event, current.current.displaySize, ids.current)),
|
|
344
|
+
onPanResponderMove: (event) =>
|
|
345
|
+
input.touch("move", nativePoints(event, current.current.displaySize, ids.current)),
|
|
346
|
+
onPanResponderEnd: (event) =>
|
|
347
|
+
input.touch("end", nativePoints(event, current.current.displaySize, ids.current)),
|
|
348
|
+
onPanResponderRelease: () => {
|
|
349
|
+
input.touch("end", []);
|
|
350
|
+
ids.current.clear();
|
|
351
|
+
},
|
|
352
|
+
onPanResponderTerminate: () => {
|
|
353
|
+
input.touch("cancel", []);
|
|
354
|
+
ids.current.clear();
|
|
355
|
+
},
|
|
356
|
+
onPanResponderTerminationRequest: () => false,
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
const canvasRef = useCallback((next: View | null) => {
|
|
360
|
+
nodeRef.current = next;
|
|
361
|
+
setNode(next);
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
canvasRef,
|
|
366
|
+
panHandlers: Platform.OS === "web" ? {} : panResponder.panHandlers,
|
|
367
|
+
cancel: input.cancel,
|
|
368
|
+
composeText,
|
|
369
|
+
};
|
|
370
|
+
}
|