@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.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/npm-shrinkwrap.json +7 -7
- 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,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered, bounded live browser input. A channel starts from decoded front-frame
|
|
3
|
+
* authority and continues held motion, releases and keys on exact control/document/
|
|
4
|
+
* geometry with opaque server identity/sequence. Every independent press carries
|
|
5
|
+
* the frame receipt visible when it was made, or waits a bounded time for a newer
|
|
6
|
+
* one; no earlier admission is retained across idle closure.
|
|
7
|
+
* Only replaceable motion with unchanged pressed identities and wheel deltas coalesce.
|
|
8
|
+
* Press/release and touch identity changes remain ordered. No uncertain mutation is replayed.
|
|
9
|
+
*/
|
|
10
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
11
|
+
import type {
|
|
12
|
+
BrowserCursor,
|
|
13
|
+
BrowserGestureEvent,
|
|
14
|
+
BrowserState,
|
|
15
|
+
beginBrowserGestureRpc,
|
|
16
|
+
endBrowserGestureRpc,
|
|
17
|
+
updateBrowserGestureRpc,
|
|
18
|
+
} from "../shared/browser";
|
|
19
|
+
|
|
20
|
+
export type BrowserGestureAuthority = Omit<RpcInput<typeof beginBrowserGestureRpc>, "pointerKind">;
|
|
21
|
+
/** Control incarnation for an already admitted opaque channel, with no frame receipt. */
|
|
22
|
+
export type BrowserGestureControl = Omit<BrowserGestureAuthority, "target">;
|
|
23
|
+
export type BrowserGesturePoint = Extract<BrowserGestureEvent, { kind: "move" }>["point"];
|
|
24
|
+
export type BrowserTouchPoint = Extract<BrowserGestureEvent, { kind: "touch" }>["points"][number];
|
|
25
|
+
/** Preserve the admission union while using schema-normalized state defaults. */
|
|
26
|
+
type NormalizedAdmission<T> = T extends unknown
|
|
27
|
+
? Omit<T, "state"> & { state: BrowserState }
|
|
28
|
+
: never;
|
|
29
|
+
export interface BrowserGestureTransport {
|
|
30
|
+
begin(
|
|
31
|
+
input: RpcInput<typeof beginBrowserGestureRpc>,
|
|
32
|
+
): Promise<NormalizedAdmission<RpcOutput<typeof beginBrowserGestureRpc>>>;
|
|
33
|
+
update(input: RpcInput<typeof updateBrowserGestureRpc>): Promise<
|
|
34
|
+
Omit<RpcOutput<typeof updateBrowserGestureRpc>, "state"> & {
|
|
35
|
+
state: BrowserState;
|
|
36
|
+
}
|
|
37
|
+
>;
|
|
38
|
+
end(input: RpcInput<typeof endBrowserGestureRpc>): Promise<
|
|
39
|
+
Omit<RpcOutput<typeof endBrowserGestureRpc>, "state"> & {
|
|
40
|
+
state: BrowserState;
|
|
41
|
+
}
|
|
42
|
+
>;
|
|
43
|
+
}
|
|
44
|
+
interface QueueOptions {
|
|
45
|
+
transport: BrowserGestureTransport;
|
|
46
|
+
authority(): BrowserGestureAuthority | null;
|
|
47
|
+
/** Exact channel/grant continuity. Every admission and press still requires a decoded authority(). */
|
|
48
|
+
controlAuthority?(): BrowserGestureControl | null;
|
|
49
|
+
onState(state: BrowserState): void;
|
|
50
|
+
onCursor(cursor: BrowserCursor | null): void;
|
|
51
|
+
onError(error: unknown): void;
|
|
52
|
+
onFinish(): void;
|
|
53
|
+
/** Reset local held contacts after an acknowledged navigation, without cancelling this queue again. */
|
|
54
|
+
onNavigationComplete?(): void;
|
|
55
|
+
/** Resolves only after a different actionable decoded receipt commits, never visual-only pixels or a timer. */
|
|
56
|
+
waitForFrame?(afterFrameId: string, maxWaitMs?: number): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
type Command = { event: BrowserGestureEvent } | { end: true };
|
|
59
|
+
interface Channel {
|
|
60
|
+
authority: BrowserGestureAuthority;
|
|
61
|
+
pointerKind: "mouse" | "touch";
|
|
62
|
+
gestureId: string;
|
|
63
|
+
sequence: number;
|
|
64
|
+
heldButtons: Set<string>;
|
|
65
|
+
heldKeys: Set<string>;
|
|
66
|
+
touchCount: number;
|
|
67
|
+
/** Conservative monotonic deadline measured before the last acknowledged RPC started. */
|
|
68
|
+
idleNotAfter: number;
|
|
69
|
+
}
|
|
70
|
+
/** Retire empty client channels before the native service's five-second idle bound. */
|
|
71
|
+
export const BROWSER_INPUT_IDLE_MS = 4_000;
|
|
72
|
+
const MAX_QUEUED_COMMANDS = 64;
|
|
73
|
+
const MAX_WHEEL_DELTA = 4_000;
|
|
74
|
+
const MAX_FRAME_ADMISSION_ATTEMPTS = 3;
|
|
75
|
+
const FRAME_ADMISSION_WAIT_MS = 4_000;
|
|
76
|
+
|
|
77
|
+
/** Compare control/route incarnation, never the changing decoded frame identity. */
|
|
78
|
+
function sameAuthority(a: BrowserGestureControl, b: BrowserGestureControl | null): boolean {
|
|
79
|
+
return Boolean(
|
|
80
|
+
b &&
|
|
81
|
+
a.viewerToken === b.viewerToken &&
|
|
82
|
+
a.controlToken === b.controlToken &&
|
|
83
|
+
a.expected.sessionId === b.expected.sessionId &&
|
|
84
|
+
a.expected.runtimeId === b.expected.runtimeId &&
|
|
85
|
+
a.expected.bridgeEpoch === b.expected.bridgeEpoch &&
|
|
86
|
+
a.expected.navigationGeneration === b.expected.navigationGeneration &&
|
|
87
|
+
a.expected.viewportGeneration === b.expected.viewportGeneration,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
/** A new independent press needs its own decoded receipt; held contacts continue theirs. */
|
|
91
|
+
function needsFreshPress(event: BrowserGestureEvent, channel: Channel): boolean {
|
|
92
|
+
return (
|
|
93
|
+
event.kind === "down" ||
|
|
94
|
+
(event.kind === "touch" && event.type === "start" && channel.touchCount === 0)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
function transportContext(authority: BrowserGestureAuthority) {
|
|
98
|
+
return {
|
|
99
|
+
viewerToken: authority.viewerToken,
|
|
100
|
+
controlToken: authority.controlToken,
|
|
101
|
+
expected: authority.expected,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An idle channel can reopen only after every physical press has been acknowledged as released. */
|
|
106
|
+
function hasHeldInput(channel: Channel): boolean {
|
|
107
|
+
return channel.heldButtons.size > 0 || channel.heldKeys.size > 0 || channel.touchCount > 0;
|
|
108
|
+
}
|
|
109
|
+
/** A qualified reply completes old input; it never authorizes input on the new page. */
|
|
110
|
+
function isAcknowledgedNavigation(original: BrowserGestureAuthority, state: BrowserState): boolean {
|
|
111
|
+
return (
|
|
112
|
+
state.status === "ready" &&
|
|
113
|
+
state.controller === "self" &&
|
|
114
|
+
state.sessionId === original.expected.sessionId &&
|
|
115
|
+
state.runtimeId === original.expected.runtimeId &&
|
|
116
|
+
state.bridgeEpoch === original.expected.bridgeEpoch &&
|
|
117
|
+
state.viewportGeneration === original.expected.viewportGeneration &&
|
|
118
|
+
state.navigationGeneration > original.expected.navigationGeneration
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One in-flight RPC, with fail-closed cancellation on pressure or authority loss. */
|
|
123
|
+
export function createBrowserInputQueue(options: QueueOptions) {
|
|
124
|
+
let epoch = 0;
|
|
125
|
+
let running = false;
|
|
126
|
+
let channel: Channel | null = null;
|
|
127
|
+
let commands: Command[] = [];
|
|
128
|
+
// Receipt used by the last acknowledged input. The server revokes it, so the
|
|
129
|
+
// next press needs a different decoded frame even while the old one is displayed.
|
|
130
|
+
let invalidatedFrameId: string | null = null;
|
|
131
|
+
// Buffer real physical edges while begin is pending. This context grants no
|
|
132
|
+
// reusable admission and publishes nothing until the server acknowledges it.
|
|
133
|
+
let pendingAdmission: { authority: BrowserGestureAuthority; epoch: number } | null = null;
|
|
134
|
+
|
|
135
|
+
const cancelChannel = async (old: Channel) => {
|
|
136
|
+
try {
|
|
137
|
+
await options.transport.end({
|
|
138
|
+
...transportContext(old.authority),
|
|
139
|
+
gestureId: old.gestureId,
|
|
140
|
+
sequence: old.sequence,
|
|
141
|
+
cancel: true,
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Best-effort cleanup, never retry physical input. Server's bounded idle
|
|
145
|
+
// expiry releases an uncertain channel if the cleanup reply is lost.
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const cancel = () => {
|
|
149
|
+
epoch += 1;
|
|
150
|
+
commands = [];
|
|
151
|
+
invalidatedFrameId = null;
|
|
152
|
+
pendingAdmission = null;
|
|
153
|
+
const old = channel;
|
|
154
|
+
channel = null;
|
|
155
|
+
options.onCursor(null);
|
|
156
|
+
if (old && !running) void cancelChannel(old);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Read target-free continuity only when the caller explicitly provides it. */
|
|
160
|
+
const currentControl = () => {
|
|
161
|
+
if (options.controlAuthority) return options.controlAuthority();
|
|
162
|
+
return options.authority();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Decoded authority only counts while it is the same control incarnation. */
|
|
166
|
+
const admissionAuthority = () => {
|
|
167
|
+
const control = currentControl();
|
|
168
|
+
const decoded = options.authority();
|
|
169
|
+
return control && decoded && sameAuthority(decoded, control) ? decoded : null;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const drain = async () => {
|
|
173
|
+
if (running) return;
|
|
174
|
+
running = true;
|
|
175
|
+
const currentEpoch = epoch;
|
|
176
|
+
let owned = channel;
|
|
177
|
+
try {
|
|
178
|
+
while (commands.length && currentEpoch === epoch) {
|
|
179
|
+
const command = commands.shift();
|
|
180
|
+
if (!command) break;
|
|
181
|
+
if (
|
|
182
|
+
owned &&
|
|
183
|
+
performance.now() >= owned.idleNotAfter &&
|
|
184
|
+
!hasHeldInput(owned) &&
|
|
185
|
+
sameAuthority(owned.authority, currentControl())
|
|
186
|
+
) {
|
|
187
|
+
// Background suspension can delay the hook's idle timer beyond native
|
|
188
|
+
// expiry. Never send an unsent event or a late normal end to that ID;
|
|
189
|
+
// beginGesture cancels the old channel and needs a fresh decoded receipt.
|
|
190
|
+
owned = null;
|
|
191
|
+
channel = null;
|
|
192
|
+
options.onFinish();
|
|
193
|
+
}
|
|
194
|
+
if ("end" in command) {
|
|
195
|
+
if (!owned) continue;
|
|
196
|
+
// A pointer release must not release a still-held keyboard chord.
|
|
197
|
+
if (hasHeldInput(owned)) continue;
|
|
198
|
+
const ending = owned;
|
|
199
|
+
if (!sameAuthority(ending.authority, currentControl())) {
|
|
200
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
201
|
+
}
|
|
202
|
+
const result = await options.transport.end({
|
|
203
|
+
...transportContext(ending.authority),
|
|
204
|
+
gestureId: ending.gestureId,
|
|
205
|
+
sequence: ending.sequence,
|
|
206
|
+
cancel: false,
|
|
207
|
+
});
|
|
208
|
+
owned = null;
|
|
209
|
+
channel = null;
|
|
210
|
+
if (currentEpoch !== epoch || !sameAuthority(ending.authority, currentControl())) break;
|
|
211
|
+
options.onState(result.state);
|
|
212
|
+
if (!sameAuthority(ending.authority, currentControl())) {
|
|
213
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
214
|
+
}
|
|
215
|
+
// Normal channel completion keeps the last qualified hover cursor.
|
|
216
|
+
// A null end receipt denotes cleanup, not a new pointer location.
|
|
217
|
+
if (result.cursor !== null) options.onCursor(result.cursor);
|
|
218
|
+
options.onFinish();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let current = admissionAuthority();
|
|
223
|
+
const control = currentControl();
|
|
224
|
+
if (!control) throw new Error("Active browser control is required.");
|
|
225
|
+
const pointerKind =
|
|
226
|
+
command.event.kind === "touch"
|
|
227
|
+
? "touch"
|
|
228
|
+
: command.event.kind === "key" || command.event.kind === "text"
|
|
229
|
+
? (owned?.pointerKind ?? "mouse")
|
|
230
|
+
: "mouse";
|
|
231
|
+
if (owned && !sameAuthority(owned.authority, control)) {
|
|
232
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
233
|
+
}
|
|
234
|
+
if (owned && owned.pointerKind !== pointerKind) {
|
|
235
|
+
// Changing input devices is ordinary on touch laptops. Close only this
|
|
236
|
+
// exact old channel before sending the still-unsent event on a new one.
|
|
237
|
+
if (hasHeldInput(owned)) {
|
|
238
|
+
await cancelChannel(owned);
|
|
239
|
+
} else {
|
|
240
|
+
// Empty mouse/touch modality changes retain only ACKed same-context
|
|
241
|
+
// geometry. Unknown closure still clears it through the error path.
|
|
242
|
+
const result = await options.transport.end({
|
|
243
|
+
...transportContext(owned.authority),
|
|
244
|
+
gestureId: owned.gestureId,
|
|
245
|
+
sequence: owned.sequence,
|
|
246
|
+
cancel: false,
|
|
247
|
+
});
|
|
248
|
+
if (currentEpoch !== epoch || !sameAuthority(owned.authority, currentControl())) break;
|
|
249
|
+
options.onState(result.state);
|
|
250
|
+
}
|
|
251
|
+
owned = null;
|
|
252
|
+
channel = null;
|
|
253
|
+
if (currentEpoch !== epoch) break;
|
|
254
|
+
if (!sameAuthority(control, currentControl())) {
|
|
255
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!owned) {
|
|
259
|
+
current = admissionAuthority();
|
|
260
|
+
if (!current) throw new Error("A decoded frame and active browser control are required.");
|
|
261
|
+
const admissionDeadline = Date.now() + FRAME_ADMISSION_WAIT_MS;
|
|
262
|
+
const pending = { authority: current, epoch: currentEpoch };
|
|
263
|
+
pendingAdmission = pending;
|
|
264
|
+
try {
|
|
265
|
+
if (invalidatedFrameId === current.target.frameId && options.waitForFrame) {
|
|
266
|
+
// The visible receipt was spent by earlier input. Beginning on it can
|
|
267
|
+
// only return stale-frame, so wait (bounded) for newer decoded pixels.
|
|
268
|
+
const waitingAuthority = current;
|
|
269
|
+
await options.waitForFrame(current.target.frameId, FRAME_ADMISSION_WAIT_MS);
|
|
270
|
+
if (currentEpoch !== epoch) break;
|
|
271
|
+
// Replacement control during the wait ends this attempt without begin.
|
|
272
|
+
const after = options.authority();
|
|
273
|
+
if (!after || !sameAuthority(waitingAuthority, after)) break;
|
|
274
|
+
current = after;
|
|
275
|
+
}
|
|
276
|
+
for (let attempt = 0; attempt < MAX_FRAME_ADMISSION_ATTEMPTS; attempt += 1) {
|
|
277
|
+
if (Date.now() >= admissionDeadline) break;
|
|
278
|
+
const sentAuthority = current;
|
|
279
|
+
const startedAt = performance.now();
|
|
280
|
+
const result = await options.transport.begin({ ...sentAuthority, pointerKind });
|
|
281
|
+
if (!("admission" in result)) {
|
|
282
|
+
owned = {
|
|
283
|
+
authority: sentAuthority,
|
|
284
|
+
pointerKind,
|
|
285
|
+
gestureId: result.gestureId,
|
|
286
|
+
sequence: result.nextSequence,
|
|
287
|
+
heldButtons: new Set(),
|
|
288
|
+
heldKeys: new Set(),
|
|
289
|
+
touchCount: 0,
|
|
290
|
+
idleNotAfter: startedAt + BROWSER_INPUT_IDLE_MS,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (currentEpoch !== epoch || !sameAuthority(sentAuthority, currentControl())) break;
|
|
294
|
+
options.onState(result.state);
|
|
295
|
+
if (!sameAuthority(sentAuthority, currentControl())) break;
|
|
296
|
+
if (owned) {
|
|
297
|
+
channel = owned;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
// Only this validated non-publication receipt permits another begin.
|
|
301
|
+
// A runtime/transport exception never retries an action. Captures
|
|
302
|
+
// already decoding may also be revoked, so admission remains bounded.
|
|
303
|
+
if (attempt === MAX_FRAME_ADMISSION_ATTEMPTS - 1) break;
|
|
304
|
+
options.onFinish();
|
|
305
|
+
const latest = options.authority();
|
|
306
|
+
if (!latest || latest.target.frameId === sentAuthority.target.frameId) {
|
|
307
|
+
if (!options.waitForFrame) throw new Error("Waiting for a current decoded frame.");
|
|
308
|
+
const remaining = admissionDeadline - Date.now();
|
|
309
|
+
if (remaining <= 0) break;
|
|
310
|
+
await options.waitForFrame(sentAuthority.target.frameId, remaining);
|
|
311
|
+
}
|
|
312
|
+
if (currentEpoch !== epoch) break;
|
|
313
|
+
current = options.authority();
|
|
314
|
+
if (!current || !sameAuthority(sentAuthority, current)) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"Browser input context changed. Release the gesture and try again.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
// A cancelled incarnation must not clear another pending owner.
|
|
322
|
+
if (pendingAdmission === pending) pendingAdmission = null;
|
|
323
|
+
}
|
|
324
|
+
if (currentEpoch !== epoch || !sameAuthority(current, currentControl())) break;
|
|
325
|
+
if (!owned)
|
|
326
|
+
throw new Error(
|
|
327
|
+
"Waiting for a current decoded frame. Release the gesture and try again.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Initial decoded admission pins this human channel. Subsequent physical
|
|
332
|
+
// edges use that exact control/document/geometry, without waiting for pixels.
|
|
333
|
+
if (!sameAuthority(owned.authority, currentControl())) {
|
|
334
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
335
|
+
}
|
|
336
|
+
let pressTarget: BrowserGestureAuthority["target"] | undefined;
|
|
337
|
+
if (needsFreshPress(command.event, owned)) {
|
|
338
|
+
let fresh = options.authority();
|
|
339
|
+
if (fresh && invalidatedFrameId === fresh.target.frameId && options.waitForFrame) {
|
|
340
|
+
// Earlier input revoked the visible receipt. Wait a bounded time for a
|
|
341
|
+
// newer decoded one; elapsed time alone never admits a press, and the
|
|
342
|
+
// server refuses a spent or expired receipt regardless.
|
|
343
|
+
await options.waitForFrame(fresh.target.frameId);
|
|
344
|
+
if (currentEpoch !== epoch) break;
|
|
345
|
+
fresh = options.authority();
|
|
346
|
+
}
|
|
347
|
+
if (fresh) {
|
|
348
|
+
if (!sameAuthority(owned.authority, fresh)) {
|
|
349
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
350
|
+
}
|
|
351
|
+
pressTarget = fresh.target;
|
|
352
|
+
} else if (owned.authority.target.frameId !== invalidatedFrameId) {
|
|
353
|
+
// Display was revoked after admission but before this first press; the
|
|
354
|
+
// receipt begin just validated is still unspent. The server re-checks it.
|
|
355
|
+
pressTarget = owned.authority.target;
|
|
356
|
+
} else {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Waiting for a current decoded frame. Release the gesture and try again.",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (command.event.kind === "key" && command.event.type === "down")
|
|
363
|
+
owned.heldKeys.add(command.event.code);
|
|
364
|
+
if (command.event.kind === "key" && command.event.type === "up")
|
|
365
|
+
owned.heldKeys.delete(command.event.code);
|
|
366
|
+
if (command.event.kind === "touch") owned.touchCount = command.event.points.length;
|
|
367
|
+
if (command.event.kind === "down") owned.heldButtons.add(command.event.button);
|
|
368
|
+
if (command.event.kind === "up") owned.heldButtons.delete(command.event.button);
|
|
369
|
+
// Mirror the server: everything except hover revokes the visible receipt.
|
|
370
|
+
if (
|
|
371
|
+
(command.event.kind !== "move" && command.event.kind !== "leave") ||
|
|
372
|
+
owned.heldButtons.size > 0
|
|
373
|
+
) {
|
|
374
|
+
invalidatedFrameId =
|
|
375
|
+
options.authority()?.target.frameId ?? owned.authority.target.frameId;
|
|
376
|
+
}
|
|
377
|
+
const expectedNextSequence = owned.sequence + 1;
|
|
378
|
+
const startedAt = performance.now();
|
|
379
|
+
const result = await options.transport.update({
|
|
380
|
+
...transportContext(owned.authority),
|
|
381
|
+
gestureId: owned.gestureId,
|
|
382
|
+
sequence: owned.sequence,
|
|
383
|
+
event: command.event,
|
|
384
|
+
...(pressTarget ? { target: pressTarget } : {}),
|
|
385
|
+
});
|
|
386
|
+
owned.sequence = result.nextSequence;
|
|
387
|
+
owned.idleNotAfter = startedAt + BROWSER_INPUT_IDLE_MS;
|
|
388
|
+
if (currentEpoch !== epoch || !sameAuthority(owned.authority, currentControl())) break;
|
|
389
|
+
if (result.gestureId !== owned.gestureId)
|
|
390
|
+
throw new Error("Browser gesture identity changed.");
|
|
391
|
+
if (result.completion === "navigation") {
|
|
392
|
+
if (
|
|
393
|
+
!isAcknowledgedNavigation(owned.authority, result.state) ||
|
|
394
|
+
result.nextSequence !== expectedNextSequence
|
|
395
|
+
) {
|
|
396
|
+
throw new Error("Browser navigation completion identity changed.");
|
|
397
|
+
}
|
|
398
|
+
// Native publication and cleanup are already acknowledged. Discard
|
|
399
|
+
// queued releases/typing from the old document before projecting the
|
|
400
|
+
// new state, which intentionally invalidates the old decoded frame.
|
|
401
|
+
epoch += 1;
|
|
402
|
+
commands = [];
|
|
403
|
+
invalidatedFrameId = null;
|
|
404
|
+
channel = null;
|
|
405
|
+
owned = null;
|
|
406
|
+
options.onCursor(null);
|
|
407
|
+
options.onNavigationComplete?.();
|
|
408
|
+
options.onState(result.state);
|
|
409
|
+
options.onFinish();
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
options.onState(result.state);
|
|
413
|
+
if (!sameAuthority(owned.authority, currentControl())) {
|
|
414
|
+
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
415
|
+
}
|
|
416
|
+
options.onCursor(result.cursor);
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (currentEpoch === epoch) {
|
|
420
|
+
epoch += 1;
|
|
421
|
+
commands = [];
|
|
422
|
+
channel = null;
|
|
423
|
+
invalidatedFrameId = null;
|
|
424
|
+
options.onCursor(null);
|
|
425
|
+
options.onError(error);
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
if (owned && (currentEpoch !== epoch || !sameAuthority(owned.authority, currentControl()))) {
|
|
429
|
+
if (currentEpoch === epoch) {
|
|
430
|
+
epoch += 1;
|
|
431
|
+
commands = [];
|
|
432
|
+
options.onCursor(null);
|
|
433
|
+
}
|
|
434
|
+
await cancelChannel(owned);
|
|
435
|
+
if (channel === owned) channel = null;
|
|
436
|
+
}
|
|
437
|
+
running = false;
|
|
438
|
+
if (commands.length) void drain();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const enqueue = (event: BrowserGestureEvent) => {
|
|
443
|
+
const continuity =
|
|
444
|
+
channel?.authority ?? (pendingAdmission?.epoch === epoch ? pendingAdmission.authority : null);
|
|
445
|
+
if (continuity ? !sameAuthority(continuity, currentControl()) : !admissionAuthority()) {
|
|
446
|
+
cancel();
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
// Keep leave behind the queued release, ahead of normal channel cleanup.
|
|
450
|
+
const trailing = commands.at(-1);
|
|
451
|
+
if (event.kind === "leave" && trailing && "end" in trailing) commands.pop();
|
|
452
|
+
const previous = commands.at(-1);
|
|
453
|
+
if (previous && "event" in previous) {
|
|
454
|
+
if (event.kind === "move" && previous.event.kind === "move") {
|
|
455
|
+
previous.event = event;
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (
|
|
459
|
+
event.kind === "touch" &&
|
|
460
|
+
previous.event.kind === "touch" &&
|
|
461
|
+
event.type === "move" &&
|
|
462
|
+
previous.event.type === "move" &&
|
|
463
|
+
event.points.length === previous.event.points.length &&
|
|
464
|
+
event.points.every(
|
|
465
|
+
(point) =>
|
|
466
|
+
previous.event.kind === "touch" &&
|
|
467
|
+
previous.event.points.some((other) => other.id === point.id),
|
|
468
|
+
)
|
|
469
|
+
) {
|
|
470
|
+
previous.event = event;
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (
|
|
474
|
+
event.kind === "scroll" &&
|
|
475
|
+
previous.event.kind === "scroll" &&
|
|
476
|
+
event.modifiers === previous.event.modifiers
|
|
477
|
+
) {
|
|
478
|
+
const deltaX = previous.event.deltaX + event.deltaX;
|
|
479
|
+
const deltaY = previous.event.deltaY + event.deltaY;
|
|
480
|
+
if (Math.abs(deltaX) <= MAX_WHEEL_DELTA && Math.abs(deltaY) <= MAX_WHEEL_DELTA) {
|
|
481
|
+
previous.event = { ...event, deltaX, deltaY };
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (commands.length >= MAX_QUEUED_COMMANDS) {
|
|
487
|
+
cancel();
|
|
488
|
+
options.onError(
|
|
489
|
+
new Error("Browser input cannot keep up. Release the gesture and try again."),
|
|
490
|
+
);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
commands.push({ event });
|
|
494
|
+
void drain();
|
|
495
|
+
return true;
|
|
496
|
+
};
|
|
497
|
+
const finish = () => {
|
|
498
|
+
const last = commands.at(-1);
|
|
499
|
+
if (last && "end" in last) return;
|
|
500
|
+
commands.push({ end: true });
|
|
501
|
+
void drain();
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return { enqueue, finish, cancel };
|
|
505
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { browserMenuPlacement, browserSubmenuPlacement } from "./browser-menu-placement";
|
|
3
|
+
|
|
4
|
+
describe("bounded toolbar menus", () => {
|
|
5
|
+
it("aligns under a desktop trigger without exceeding the pane", () => {
|
|
6
|
+
expect(
|
|
7
|
+
browserMenuPlacement(
|
|
8
|
+
{ width: 800, height: 600 },
|
|
9
|
+
{ x: 740, y: 8, width: 28, height: 28 },
|
|
10
|
+
240,
|
|
11
|
+
),
|
|
12
|
+
).toEqual({ x: 468, y: 40, width: 300, height: 240 });
|
|
13
|
+
});
|
|
14
|
+
it("fits a narrow phone pane and bounds a long favorites list for scrolling", () => {
|
|
15
|
+
const rect = browserMenuPlacement(
|
|
16
|
+
{ width: 280, height: 240 },
|
|
17
|
+
{ x: 20, y: 8, width: 28, height: 28 },
|
|
18
|
+
900,
|
|
19
|
+
)!;
|
|
20
|
+
expect(rect).toEqual({ x: 8, y: 40, width: 264, height: 192 });
|
|
21
|
+
});
|
|
22
|
+
it("opens above a lower anchor when there is more space there", () => {
|
|
23
|
+
expect(
|
|
24
|
+
browserMenuPlacement(
|
|
25
|
+
{ width: 400, height: 300 },
|
|
26
|
+
{ x: 340, y: 260, width: 28, height: 28 },
|
|
27
|
+
160,
|
|
28
|
+
),
|
|
29
|
+
).toEqual({ x: 68, y: 96, width: 300, height: 160 });
|
|
30
|
+
});
|
|
31
|
+
it("refuses unknown or invalid measured geometry", () => {
|
|
32
|
+
for (const bad of [0, -1, Infinity, NaN]) {
|
|
33
|
+
expect(
|
|
34
|
+
browserMenuPlacement(
|
|
35
|
+
{ width: bad, height: 600 },
|
|
36
|
+
{ x: 20, y: 8, width: 28, height: 28 },
|
|
37
|
+
240,
|
|
38
|
+
),
|
|
39
|
+
).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
browserMenuPlacement(
|
|
42
|
+
{ width: 800, height: 600 },
|
|
43
|
+
{ x: 20, y: 8, width: bad, height: 28 },
|
|
44
|
+
240,
|
|
45
|
+
),
|
|
46
|
+
).toBeNull();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("bounded child menu placement", () => {
|
|
52
|
+
it("opens beside a parent with enough room", () => {
|
|
53
|
+
expect(
|
|
54
|
+
browserSubmenuPlacement(
|
|
55
|
+
{ width: 900, height: 600 },
|
|
56
|
+
{ x: 8, y: 40, width: 300, height: 100 },
|
|
57
|
+
{ x: 8, y: 80, width: 300, height: 36 },
|
|
58
|
+
200,
|
|
59
|
+
),
|
|
60
|
+
).toEqual({ x: 312, y: 80, width: 240, height: 200 });
|
|
61
|
+
});
|
|
62
|
+
it("flips left and clamps a long submenu for internal scrolling", () => {
|
|
63
|
+
expect(
|
|
64
|
+
browserSubmenuPlacement(
|
|
65
|
+
{ width: 800, height: 300 },
|
|
66
|
+
{ x: 468, y: 40, width: 300, height: 100 },
|
|
67
|
+
{ x: 468, y: 260, width: 300, height: 36 },
|
|
68
|
+
900,
|
|
69
|
+
),
|
|
70
|
+
).toEqual({ x: 224, y: 8, width: 240, height: 284 });
|
|
71
|
+
});
|
|
72
|
+
it("refuses unknown pane or row geometry", () => {
|
|
73
|
+
expect(
|
|
74
|
+
browserSubmenuPlacement(
|
|
75
|
+
{ width: 0, height: 300 },
|
|
76
|
+
{ x: 0, y: 0, width: 100, height: 80 },
|
|
77
|
+
{ x: 0, y: 20, width: 100, height: 30 },
|
|
78
|
+
160,
|
|
79
|
+
),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
expect(
|
|
82
|
+
browserSubmenuPlacement(
|
|
83
|
+
{ width: 800, height: 300 },
|
|
84
|
+
{ x: 0, y: 0, width: 100, height: 80 },
|
|
85
|
+
{ x: 0, y: NaN, width: 100, height: 30 },
|
|
86
|
+
160,
|
|
87
|
+
),
|
|
88
|
+
).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Panel-relative menu placement; the pane already excludes the host's safe areas. */
|
|
2
|
+
export interface MenuRect {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Anchor to the trigger's right edge and prefer below, with bounded internal scrolling. */
|
|
10
|
+
export function browserMenuPlacement(
|
|
11
|
+
pane: { width: number; height: number },
|
|
12
|
+
anchor: MenuRect,
|
|
13
|
+
preferredHeight: number,
|
|
14
|
+
): MenuRect | null {
|
|
15
|
+
const values = [
|
|
16
|
+
pane.width,
|
|
17
|
+
pane.height,
|
|
18
|
+
anchor.x,
|
|
19
|
+
anchor.y,
|
|
20
|
+
anchor.width,
|
|
21
|
+
anchor.height,
|
|
22
|
+
preferredHeight,
|
|
23
|
+
];
|
|
24
|
+
if (
|
|
25
|
+
values.some((value) => !Number.isFinite(value)) ||
|
|
26
|
+
pane.width <= 16 ||
|
|
27
|
+
pane.height <= 16 ||
|
|
28
|
+
anchor.width <= 0 ||
|
|
29
|
+
anchor.height <= 0 ||
|
|
30
|
+
preferredHeight <= 0
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const margin = 8;
|
|
36
|
+
const gap = 4;
|
|
37
|
+
const width = Math.min(300, pane.width - margin * 2);
|
|
38
|
+
const x = Math.max(
|
|
39
|
+
margin,
|
|
40
|
+
Math.min(pane.width - margin - width, anchor.x + anchor.width - width),
|
|
41
|
+
);
|
|
42
|
+
const below = Math.max(0, pane.height - margin - anchor.y - anchor.height - gap);
|
|
43
|
+
const above = Math.max(0, anchor.y - margin - gap);
|
|
44
|
+
const useBelow = below >= preferredHeight || below >= above;
|
|
45
|
+
const height = Math.min(preferredHeight, useBelow ? below : above, pane.height - margin * 2);
|
|
46
|
+
if (height < 1) return null;
|
|
47
|
+
|
|
48
|
+
const y = useBelow ? anchor.y + anchor.height + gap : anchor.y - gap - height;
|
|
49
|
+
return { x, y: Math.max(margin, Math.min(pane.height - margin - height, y)), width, height };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Place a flyout beside its parent, flip left when needed, and clamp to the pane. */
|
|
53
|
+
export function browserSubmenuPlacement(
|
|
54
|
+
pane: { width: number; height: number },
|
|
55
|
+
parent: MenuRect,
|
|
56
|
+
row: MenuRect,
|
|
57
|
+
preferredHeight: number,
|
|
58
|
+
): MenuRect | null {
|
|
59
|
+
const values = [pane.width, pane.height, parent.x, parent.width, row.y, preferredHeight];
|
|
60
|
+
if (
|
|
61
|
+
values.some((value) => !Number.isFinite(value)) ||
|
|
62
|
+
pane.width <= 16 ||
|
|
63
|
+
pane.height <= 16 ||
|
|
64
|
+
preferredHeight <= 0
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const width = Math.min(240, pane.width - 16);
|
|
69
|
+
const height = Math.min(preferredHeight, pane.height - 16);
|
|
70
|
+
const right = parent.x + parent.width + 4;
|
|
71
|
+
const x = right + width <= pane.width - 8 ? right : parent.x - width - 4;
|
|
72
|
+
return {
|
|
73
|
+
x: Math.max(8, Math.min(pane.width - 8 - width, x)),
|
|
74
|
+
y: Math.max(8, Math.min(pane.height - 8 - height, row.y)),
|
|
75
|
+
width,
|
|
76
|
+
height,
|
|
77
|
+
};
|
|
78
|
+
}
|