@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.249.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +32 -2
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,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 which captured frame input may target. After an input is submitted
5
- * and again when it settles, the actionable frame is dropped and the epoch
6
- * advances, so captures that began earlier are ignored. Input stays blocked
7
- * until a capture taken after settlement is accepted. The last accepted image
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 class FrameLifecycle {
11
- epoch = 0;
12
- /** Frame input may target; null while a fresh capture is awaited. */
13
- frame: BrowserFrame | null = null;
14
- /** Last accepted frame, kept on screen while input is blocked. */
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
- get busy(): boolean {
19
- return this.gate !== "idle";
20
- }
15
+ const bump = () => {
16
+ epoch += 1;
17
+ };
18
+ const drop = () => {
19
+ frame = null;
20
+ visible = null;
21
+ };
21
22
 
22
- bump(): void {
23
- this.epoch += 1;
24
- }
25
-
26
- isCurrent(captureEpoch: number): boolean {
27
- return captureEpoch === this.epoch;
28
- }
29
-
30
- /** Claims the input slot; false if an input is already in flight or awaiting a fresh frame. */
31
- begin(): boolean {
32
- if (this.busy) return false;
33
- this.gate = "pending";
34
- this.frame = null;
35
- this.bump();
36
- return true;
37
- }
38
-
39
- /** Called when an input succeeds or fails. Never replays the action. */
40
- settle(): void {
41
- this.frame = null;
42
- this.bump();
43
- this.gate = "refreshing";
44
- }
45
-
46
- /** Accepts a capture started at `captureEpoch`; false if it predates the latest boundary. */
47
- accept(captureEpoch: number, frame: BrowserFrame): boolean {
48
- if (!this.isCurrent(captureEpoch)) return false;
49
- this.frame = frame;
50
- this.visible = frame;
51
- if (this.gate === "refreshing") this.gate = "idle";
52
- return true;
53
- }
54
-
55
- /** Forgets all frames, e.g. when the session no longer matches them. */
56
- drop(): void {
57
- this.frame = null;
58
- this.visible = null;
59
- }
60
-
61
- reset(): void {
62
- this.drop();
63
- this.gate = "idle";
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
  }
@@ -123,9 +123,8 @@ function createSidebarItem(client: PluginClientContext) {
123
123
  };
124
124
  }
125
125
 
126
- /** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
126
+ /** Sidebar footer row listing open sessions. */
127
127
  export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
128
- if (typeof client.addSidebarFooterItem !== "function") return () => {};
129
128
  return client.addSidebarFooterItem({
130
129
  id: "shared-browser-sessions",
131
130
  title: "Shared Browser",
@@ -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,375 @@
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
+ /** The host acknowledged an input that revoked every earlier frame receipt. */
49
+ onInputAcknowledged?(): void;
50
+ }
51
+
52
+ /** Native touch coordinates are relative to the controlled overlay, with stable ID mapping. */
53
+ function nativePoints(
54
+ event: GestureResponderEvent,
55
+ size: CanvasOptions["displaySize"],
56
+ ids: Map<string, number>,
57
+ ): BrowserTouchPoint[] {
58
+ if (!size || size.width <= 0 || size.height <= 0) return [];
59
+ return event.nativeEvent.touches.map((touch) => {
60
+ let id = ids.get(touch.identifier);
61
+ if (id === undefined) {
62
+ id = ids.size;
63
+ ids.set(touch.identifier, id);
64
+ }
65
+ return {
66
+ id,
67
+ x: Math.max(0, Math.min(size.width, touch.locationX)),
68
+ y: Math.max(0, Math.min(size.height, touch.locationY)),
69
+ width: size.width,
70
+ height: size.height,
71
+ };
72
+ });
73
+ }
74
+
75
+ /** Attach the same authority-preserving input model to native View or web host events. */
76
+ export function useBrowserCanvasInput(options: CanvasOptions) {
77
+ const current = useRef(options);
78
+ current.current = options;
79
+ const nodeRef = useRef<unknown>(null);
80
+ const [node, setNode] = useState<unknown>(null);
81
+ const alive = useRef(true);
82
+ const inputModel = useRef<ReturnType<typeof createBrowserCanvasInput> | null>(null);
83
+ const keyboard = useRef<ReturnType<typeof bindBrowserCanvasKeyboard> | null>(null);
84
+ const cursorVisible = useRef(false);
85
+ const frameWaiters = useRef(
86
+ new Set<{
87
+ afterFrameId: string;
88
+ resolve(): void;
89
+ reject(error: Error): void;
90
+ }>(),
91
+ );
92
+ const rejectFrameWaiters = () => {
93
+ for (const waiter of frameWaiters.current)
94
+ waiter.reject(new Error("Browser input context changed."));
95
+ frameWaiters.current.clear();
96
+ };
97
+ const [queue] = useState(() =>
98
+ createBrowserInputQueue({
99
+ transport: {
100
+ begin: (input) => current.current.transport.begin(input),
101
+ update: (input) => current.current.transport.update(input),
102
+ end: (input) => current.current.transport.end(input),
103
+ },
104
+ authority: () => (alive.current ? current.current.authority() : null),
105
+ controlAuthority: () => {
106
+ if (!alive.current) return null;
107
+ if (current.current.controlAuthority) return current.current.controlAuthority();
108
+ return current.current.authority();
109
+ },
110
+ onState: (state) => {
111
+ if (alive.current) current.current.onState(state);
112
+ },
113
+ onCursor: (cursor: BrowserCursor | null) => {
114
+ if (cursorVisible.current) setBrowserCanvasCursor(nodeRef.current, cursor);
115
+ },
116
+ onError: (error) => {
117
+ // Queue owns cleanup; reset local contacts without recursively cancelling.
118
+ inputModel.current?.reset();
119
+ keyboard.current?.reset();
120
+ cursorVisible.current = false;
121
+ setBrowserCanvasCursor(nodeRef.current, null);
122
+ clearIdle();
123
+ if (alive.current) current.current.onError(error);
124
+ },
125
+ onAcknowledged: () => {
126
+ if (alive.current) current.current.onInputAcknowledged?.();
127
+ },
128
+ onNavigationComplete: () => {
129
+ // Server already closed the acknowledged channel. Quarantine local
130
+ // holds before state projection; never replay their old-page release.
131
+ inputModel.current?.reset();
132
+ keyboard.current?.reset();
133
+ cursorVisible.current = false;
134
+ setBrowserCanvasCursor(nodeRef.current, null);
135
+ clearIdle();
136
+ rejectFrameWaiters();
137
+ },
138
+ onFinish: () => {
139
+ if (alive.current) current.current.onFinish();
140
+ },
141
+ waitForFrame(afterFrameId, maxWaitMs = 4_000) {
142
+ return new Promise<void>((resolve, reject) => {
143
+ const waiter = {
144
+ afterFrameId,
145
+ resolve: () => {
146
+ clearTimeout(timeout);
147
+ frameWaiters.current.delete(waiter);
148
+ resolve();
149
+ },
150
+ reject: (error: Error) => {
151
+ clearTimeout(timeout);
152
+ frameWaiters.current.delete(waiter);
153
+ reject(error);
154
+ },
155
+ };
156
+ // The existing server gesture idle bound is five seconds. Stop an unsent
157
+ // press before it expires; time alone never authorizes new image pixels.
158
+ const timeout = setTimeout(
159
+ () =>
160
+ waiter.reject(
161
+ new Error(
162
+ "Waiting for a current decoded frame. Release the gesture and try again.",
163
+ ),
164
+ ),
165
+ Math.min(4_000, maxWaitMs),
166
+ );
167
+ frameWaiters.current.add(waiter);
168
+ });
169
+ },
170
+ }),
171
+ );
172
+ const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
173
+ const clearIdle = () => {
174
+ if (idleTimer.current) clearTimeout(idleTimer.current);
175
+ idleTimer.current = null;
176
+ };
177
+ const [input] = useState(() =>
178
+ createBrowserCanvasInput({
179
+ enabled: () => alive.current && current.current.enabled,
180
+ viewport: () => current.current.viewport,
181
+ enqueue(event) {
182
+ clearIdle();
183
+ // Queue snapshots the actual decoded admission before a local epoch bump.
184
+ // Already admitted edges continue on their opaque channel independently.
185
+ const accepted = queue.enqueue(event);
186
+ if (
187
+ accepted &&
188
+ (event.kind === "down" || (event.kind === "touch" && event.type === "start"))
189
+ ) {
190
+ current.current.onInputBoundary();
191
+ }
192
+ if (event.kind === "move" || event.kind === "scroll") {
193
+ idleTimer.current = setTimeout(() => {
194
+ // Never end a slow held drag just because the user stopped moving.
195
+ if (!input.isHoldingMouse()) {
196
+ current.current.onActivity(false);
197
+ queue.finish();
198
+ }
199
+ }, BROWSER_INPUT_IDLE_MS);
200
+ }
201
+ return accepted;
202
+ },
203
+ finish: () => {
204
+ clearIdle();
205
+ // Release was enqueued immediately. Keep the empty exact channel for
206
+ // ordinary repeat input, closing before the server's five-second idle bound.
207
+ idleTimer.current = setTimeout(() => queue.finish(), BROWSER_INPUT_IDLE_MS);
208
+ },
209
+ cancel: () => {
210
+ clearIdle();
211
+ keyboard.current?.reset();
212
+ cursorVisible.current = false;
213
+ setBrowserCanvasCursor(nodeRef.current, null);
214
+ queue.cancel();
215
+ rejectFrameWaiters();
216
+ },
217
+ onPoint: (point) => current.current.onPoint(point),
218
+ onActivity: (active) => {
219
+ if (alive.current) current.current.onActivity(active);
220
+ },
221
+ }),
222
+ );
223
+
224
+ inputModel.current = input;
225
+
226
+ const enqueueKeyboard = useCallback(
227
+ (event: CanvasKeyboardEvent) => {
228
+ clearIdle();
229
+ const accepted = queue.enqueue(event);
230
+ if (accepted) {
231
+ if (event.kind === "text" || (event.type === "down" && !event.repeat)) {
232
+ current.current.onInputBoundary();
233
+ }
234
+ current.current.onActivity(true);
235
+ }
236
+ return accepted;
237
+ },
238
+ [queue],
239
+ );
240
+ const finishKeyboard = useCallback(() => {
241
+ current.current.onActivity(false);
242
+ // Keep one channel through ordinary inter-key gaps. New typing clears this
243
+ // timer, while blur/control loss cancels immediately through the input model.
244
+ clearIdle();
245
+ idleTimer.current = setTimeout(() => queue.finish(), BROWSER_INPUT_IDLE_MS);
246
+ }, [queue]);
247
+ /** Insert an explicitly committed draft once; false means nothing was admitted. */
248
+ const composeText = useCallback(
249
+ (text: string): boolean => {
250
+ if (!alive.current || !current.current.enabled || !text) return false;
251
+ if (text.length > 16_000) {
252
+ current.current.onError(
253
+ new Error("Text is too long. Send up to 16,000 characters at once."),
254
+ );
255
+ return false;
256
+ }
257
+ const accepted = enqueueKeyboard({ kind: "text", text });
258
+ if (accepted) finishKeyboard();
259
+ return accepted;
260
+ },
261
+ [enqueueKeyboard, finishKeyboard],
262
+ );
263
+
264
+ const previousOwnership = useRef(options.ownershipKey);
265
+ useLayoutEffect(() => {
266
+ if (previousOwnership.current === options.ownershipKey) return;
267
+ previousOwnership.current = options.ownershipKey;
268
+ input.cancel();
269
+ }, [input, options.ownershipKey]);
270
+ useLayoutEffect(() => {
271
+ for (const waiter of frameWaiters.current) {
272
+ const admitted = current.current.authority();
273
+ if (
274
+ options.decodedFrameId &&
275
+ admitted?.target.frameId === options.decodedFrameId &&
276
+ options.decodedFrameId !== waiter.afterFrameId
277
+ ) {
278
+ waiter.resolve();
279
+ }
280
+ }
281
+ }, [options.decodedFrameId]);
282
+ useEffect(() => {
283
+ alive.current = true;
284
+ const subscription = AppState.addEventListener("change", (state) => {
285
+ if (state !== "active") input.cancel();
286
+ });
287
+ return () => {
288
+ alive.current = false;
289
+ input.cancel();
290
+ subscription.remove();
291
+ };
292
+ }, [input]);
293
+ useEffect(() => {
294
+ if (Platform.OS !== "web" || !node || typeof node !== "object" || !("addEventListener" in node))
295
+ return;
296
+ return bindBrowserCanvasWeb(
297
+ node as BrowserCanvasNode,
298
+ input,
299
+ () => alive.current && current.current.enabled,
300
+ (visible) => {
301
+ cursorVisible.current = visible;
302
+ if (!visible) setBrowserCanvasCursor(nodeRef.current, null);
303
+ },
304
+ );
305
+ }, [input, node]);
306
+
307
+ useEffect(() => {
308
+ if (Platform.OS !== "web" || !node || typeof node !== "object" || !("ownerDocument" in node))
309
+ return;
310
+ const binding = bindBrowserCanvasKeyboard(node as KeyboardCanvasNode, {
311
+ enabled: () => alive.current && current.current.enabled,
312
+ enqueue: enqueueKeyboard,
313
+ finish: finishKeyboard,
314
+ cancel: input.cancel,
315
+ onError: (error) => {
316
+ if (alive.current) current.current.onError(error);
317
+ },
318
+ });
319
+ keyboard.current = binding;
320
+ return () => {
321
+ binding.dispose();
322
+ if (keyboard.current === binding) keyboard.current = null;
323
+ };
324
+ }, [input, node, enqueueKeyboard, finishKeyboard]);
325
+
326
+ const ids = useRef(new Map<string, number>());
327
+ const [panResponder] = useState(() =>
328
+ PanResponder.create({
329
+ onStartShouldSetPanResponder: (event) => {
330
+ if (Platform.OS === "web" || !current.current.enabled) return false;
331
+ // A newly granted responder must contain only genuinely new contacts.
332
+ // A second finger cannot import a first contact that began observe-only.
333
+ const changed = new Set(
334
+ (event.nativeEvent.changedTouches ?? []).map((touch) => touch.identifier),
335
+ );
336
+ return (
337
+ event.nativeEvent.touches.length > 0 &&
338
+ event.nativeEvent.touches.every((touch) => changed.has(touch.identifier))
339
+ );
340
+ },
341
+ // Moving a contact after lease acquisition is not a fresh start edge.
342
+ onMoveShouldSetPanResponder: () => false,
343
+ onPanResponderGrant: (event) => {
344
+ ids.current.clear();
345
+ input.touch("start", nativePoints(event, current.current.displaySize, ids.current));
346
+ },
347
+ onPanResponderStart: (event) =>
348
+ input.touch("start", nativePoints(event, current.current.displaySize, ids.current)),
349
+ onPanResponderMove: (event) =>
350
+ input.touch("move", nativePoints(event, current.current.displaySize, ids.current)),
351
+ onPanResponderEnd: (event) =>
352
+ input.touch("end", nativePoints(event, current.current.displaySize, ids.current)),
353
+ onPanResponderRelease: () => {
354
+ input.touch("end", []);
355
+ ids.current.clear();
356
+ },
357
+ onPanResponderTerminate: () => {
358
+ input.touch("cancel", []);
359
+ ids.current.clear();
360
+ },
361
+ onPanResponderTerminationRequest: () => false,
362
+ }),
363
+ );
364
+ const canvasRef = useCallback((next: View | null) => {
365
+ nodeRef.current = next;
366
+ setNode(next);
367
+ }, []);
368
+
369
+ return {
370
+ canvasRef,
371
+ panHandlers: Platform.OS === "web" ? {} : panResponder.panHandlers,
372
+ cancel: input.cancel,
373
+ composeText,
374
+ };
375
+ }