@omercnet/paseo-shared-browser 1.2.2-next.239.1 → 1.2.2-next.240.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/STREAMING_DESIGN.md +6 -1
- package/client/browser-input-queue.test.ts +53 -0
- package/client/browser-input-queue.ts +22 -8
- package/client/browser-video-currency.ts +45 -0
- package/client/browser.tsx +17 -19
- package/client/use-browser-canvas-input.ts +5 -0
- package/client/use-browser-video.test.ts +72 -3
- package/client/use-browser-video.ts +13 -6
- package/package.json +1 -1
- package/server/browser-policy.ts +7 -4
- package/server/browser-video-input-authority.test.ts +3 -0
- package/shared/browser-video.ts +3 -0
package/STREAMING_DESIGN.md
CHANGED
|
@@ -69,7 +69,12 @@ admit clicks indefinitely. Motion, releases and keys of a held gesture use the o
|
|
|
69
69
|
channel without waiting for new pixels. Agent commands retain strict fresh-frame
|
|
70
70
|
targeting.
|
|
71
71
|
|
|
72
|
-
A visible frame is not automatically an actionable receipt.
|
|
72
|
+
A visible frame is not automatically an actionable receipt. The host flags each
|
|
73
|
+
video packet `actionable` or not (a capture older than the last acknowledged input
|
|
74
|
+
is painted but never a receipt), and the viewer starts a new epoch when the host
|
|
75
|
+
acknowledges a receipt-revoking input, so reads answered before that revocation
|
|
76
|
+
cannot authorize the next press. After an acknowledged press the viewer has no
|
|
77
|
+
actionable video receipt until a newer packet paints; a following press waits for it. Delayed reads may
|
|
73
78
|
continue painting within the same source and geometry while retaining their
|
|
74
79
|
original input revision. Initial-admission waiters wake only for actionable
|
|
75
80
|
pixels. A document or viewport transition, controller loss, cancellation,
|
|
@@ -60,6 +60,7 @@ function fixture(
|
|
|
60
60
|
const errors: unknown[] = [];
|
|
61
61
|
const cursors: unknown[] = [];
|
|
62
62
|
const navigationCompleted: boolean[] = [];
|
|
63
|
+
const acknowledgements: string[] = [];
|
|
63
64
|
const states: BrowserState[] = [];
|
|
64
65
|
let projectState: ((state: BrowserState) => void) | null = null;
|
|
65
66
|
let finished = 0;
|
|
@@ -94,6 +95,7 @@ function fixture(
|
|
|
94
95
|
finished += 1;
|
|
95
96
|
},
|
|
96
97
|
onNavigationComplete: () => navigationCompleted.push(true),
|
|
98
|
+
onAcknowledged: () => acknowledgements.push("ack"),
|
|
97
99
|
...(waitForFrame ? { waitForFrame } : {}),
|
|
98
100
|
});
|
|
99
101
|
return {
|
|
@@ -105,6 +107,7 @@ function fixture(
|
|
|
105
107
|
beginGate,
|
|
106
108
|
transport,
|
|
107
109
|
navigationCompleted,
|
|
110
|
+
acknowledgements,
|
|
108
111
|
states,
|
|
109
112
|
finishes: () => finished,
|
|
110
113
|
projectState(callback: (state: BrowserState) => void) {
|
|
@@ -1062,6 +1065,56 @@ it.each(["viewer", "controller", "session", "runtime", "bridge", "document", "vi
|
|
|
1062
1065
|
},
|
|
1063
1066
|
);
|
|
1064
1067
|
|
|
1068
|
+
it("reports host acknowledgement of every receipt-revoking input, never plain hover", async () => {
|
|
1069
|
+
const f = fixture(undefined, true);
|
|
1070
|
+
f.queue.enqueue(move(1));
|
|
1071
|
+
f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
|
|
1072
|
+
await flush();
|
|
1073
|
+
f.queue.enqueue(move(2));
|
|
1074
|
+
await flush();
|
|
1075
|
+
expect(f.acknowledgements).toEqual([]);
|
|
1076
|
+
f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 });
|
|
1077
|
+
await flush();
|
|
1078
|
+
expect(f.acknowledgements).toEqual(["ack"]);
|
|
1079
|
+
f.queue.enqueue(move(3)); // a held drag keeps revoking
|
|
1080
|
+
f.queue.enqueue({ kind: "up", button: "left", point: point(3), clickCount: 1 });
|
|
1081
|
+
await flush();
|
|
1082
|
+
expect(f.acknowledgements).toHaveLength(3);
|
|
1083
|
+
f.queue.enqueue({ kind: "scroll", point: point(3), deltaX: 0, deltaY: 5 });
|
|
1084
|
+
await flush();
|
|
1085
|
+
expect(f.acknowledgements).toHaveLength(4);
|
|
1086
|
+
f.queue.cancel();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("waits for the next decoded frame when video revoked its receipt, then presses with that frame", async () => {
|
|
1090
|
+
// Video input authority disappears (null) right after an acknowledged press and
|
|
1091
|
+
// returns only when the next packet paints; the queue must wait, not refuse.
|
|
1092
|
+
let waited = 0;
|
|
1093
|
+
const f: ReturnType<typeof fixture> = fixture(async (afterFrameId) => {
|
|
1094
|
+
waited += 1;
|
|
1095
|
+
expect(afterFrameId).toBe(authority.target.frameId);
|
|
1096
|
+
f.changeAuthority(freshAuthority);
|
|
1097
|
+
}, true);
|
|
1098
|
+
f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
|
|
1099
|
+
f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
|
|
1100
|
+
await flush();
|
|
1101
|
+
f.queue.enqueue({ kind: "up", button: "left", point: point(1), clickCount: 1 });
|
|
1102
|
+
await flush();
|
|
1103
|
+
f.changeAuthority(null);
|
|
1104
|
+
expect(f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 })).toBe(
|
|
1105
|
+
true,
|
|
1106
|
+
);
|
|
1107
|
+
await flush();
|
|
1108
|
+
expect(waited).toBe(1);
|
|
1109
|
+
expect(f.errors).toEqual([]);
|
|
1110
|
+
expect(f.sent.map((input) => input.event.kind)).toEqual(["down", "up", "down"]);
|
|
1111
|
+
expect(f.sent.map((input) => input.target?.frameId)).toEqual([
|
|
1112
|
+
authority.target.frameId,
|
|
1113
|
+
undefined,
|
|
1114
|
+
freshAuthority.target.frameId,
|
|
1115
|
+
]);
|
|
1116
|
+
f.queue.cancel();
|
|
1117
|
+
});
|
|
1065
1118
|
it("gives each independent touch contact its own decoded receipt; fingers within a gesture and text reuse the channel", async () => {
|
|
1066
1119
|
const f: ReturnType<typeof fixture> = fixture(async () => {
|
|
1067
1120
|
f.changeAuthority(freshAuthority);
|
|
@@ -52,6 +52,13 @@ interface QueueOptions {
|
|
|
52
52
|
onFinish(): void;
|
|
53
53
|
/** Reset local held contacts after an acknowledged navigation, without cancelling this queue again. */
|
|
54
54
|
onNavigationComplete?(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Called after the host acknowledges a non-hover event. Receipts decoded from
|
|
57
|
+
* anything requested before this point predate the input's effect, so the viewer
|
|
58
|
+
* must stop treating them as actionable even if they were requested after the
|
|
59
|
+
* local press began.
|
|
60
|
+
*/
|
|
61
|
+
onAcknowledged?(): void;
|
|
55
62
|
/** Resolves only after a different actionable decoded receipt commits, never visual-only pixels or a timer. */
|
|
56
63
|
waitForFrame?(afterFrameId: string, maxWaitMs?: number): Promise<void>;
|
|
57
64
|
}
|
|
@@ -336,11 +343,17 @@ export function createBrowserInputQueue(options: QueueOptions) {
|
|
|
336
343
|
let pressTarget: BrowserGestureAuthority["target"] | undefined;
|
|
337
344
|
if (needsFreshPress(command.event, owned)) {
|
|
338
345
|
let fresh = options.authority();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
346
|
+
// Video revokes its receipt as soon as input is acknowledged, leaving no
|
|
347
|
+
// actionable authority until the next frame paints; JPEG keeps the spent
|
|
348
|
+
// frame visible. Both mean this press needs a newer decoded receipt.
|
|
349
|
+
const visibleId = fresh?.target.frameId ?? owned.authority.target.frameId;
|
|
350
|
+
const spent = fresh
|
|
351
|
+
? invalidatedFrameId === fresh.target.frameId
|
|
352
|
+
: invalidatedFrameId !== null;
|
|
353
|
+
if (spent && options.waitForFrame) {
|
|
354
|
+
// Wait a bounded time; elapsed time alone never admits a press, and the
|
|
342
355
|
// server refuses a spent or expired receipt regardless.
|
|
343
|
-
await options.waitForFrame(
|
|
356
|
+
await options.waitForFrame(visibleId);
|
|
344
357
|
if (currentEpoch !== epoch) break;
|
|
345
358
|
fresh = options.authority();
|
|
346
359
|
}
|
|
@@ -349,7 +362,7 @@ export function createBrowserInputQueue(options: QueueOptions) {
|
|
|
349
362
|
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
350
363
|
}
|
|
351
364
|
pressTarget = fresh.target;
|
|
352
|
-
} else if (
|
|
365
|
+
} else if (!spent) {
|
|
353
366
|
// Display was revoked after admission but before this first press; the
|
|
354
367
|
// receipt begin just validated is still unspent. The server re-checks it.
|
|
355
368
|
pressTarget = owned.authority.target;
|
|
@@ -367,10 +380,10 @@ export function createBrowserInputQueue(options: QueueOptions) {
|
|
|
367
380
|
if (command.event.kind === "down") owned.heldButtons.add(command.event.button);
|
|
368
381
|
if (command.event.kind === "up") owned.heldButtons.delete(command.event.button);
|
|
369
382
|
// Mirror the server: everything except hover revokes the visible receipt.
|
|
370
|
-
|
|
383
|
+
const revokesReceipts =
|
|
371
384
|
(command.event.kind !== "move" && command.event.kind !== "leave") ||
|
|
372
|
-
owned.heldButtons.size > 0
|
|
373
|
-
) {
|
|
385
|
+
owned.heldButtons.size > 0;
|
|
386
|
+
if (revokesReceipts) {
|
|
374
387
|
invalidatedFrameId =
|
|
375
388
|
options.authority()?.target.frameId ?? owned.authority.target.frameId;
|
|
376
389
|
}
|
|
@@ -413,6 +426,7 @@ export function createBrowserInputQueue(options: QueueOptions) {
|
|
|
413
426
|
if (!sameAuthority(owned.authority, currentControl())) {
|
|
414
427
|
throw new Error("Browser input context changed. Release the gesture and try again.");
|
|
415
428
|
}
|
|
429
|
+
if (revokesReceipts) options.onAcknowledged?.();
|
|
416
430
|
options.onCursor(result.cursor);
|
|
417
431
|
}
|
|
418
432
|
} catch (error) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Source, document and geometry validity of a viewer frame, shared by the panel and its tests. */
|
|
2
|
+
import type { BrowserState } from "../shared/browser";
|
|
3
|
+
import type { BrowserFrameAuthority, BrowserVideoPacket } from "../shared/browser-video";
|
|
4
|
+
|
|
5
|
+
export function isFrameCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
|
|
6
|
+
return (
|
|
7
|
+
frame.sessionId === state.sessionId &&
|
|
8
|
+
(!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
|
|
9
|
+
(frame.captureEpoch === undefined ||
|
|
10
|
+
state.bridgeEpoch === undefined ||
|
|
11
|
+
frame.captureEpoch === state.bridgeEpoch) &&
|
|
12
|
+
frame.navigationGeneration === state.navigationGeneration &&
|
|
13
|
+
frame.viewportGeneration === state.viewportGeneration
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VideoSourceContext {
|
|
18
|
+
mounted: boolean;
|
|
19
|
+
viewerActive: boolean;
|
|
20
|
+
epoch: number;
|
|
21
|
+
committedViewport: { sessionId: string; generation: number } | null;
|
|
22
|
+
state: BrowserState | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a packet may be decoded and painted. This is source validity only: a
|
|
27
|
+
* display-only packet (actionable === false) is still a link in the codec's delta
|
|
28
|
+
* chain, so the input-only flag must never be consulted here. Input admission is
|
|
29
|
+
* the video hook's separate inputCurrent guard.
|
|
30
|
+
*/
|
|
31
|
+
export function isVideoSourceCurrent(
|
|
32
|
+
context: VideoSourceContext,
|
|
33
|
+
packet: BrowserVideoPacket,
|
|
34
|
+
epoch: number,
|
|
35
|
+
): boolean {
|
|
36
|
+
return (
|
|
37
|
+
context.mounted &&
|
|
38
|
+
context.viewerActive &&
|
|
39
|
+
epoch === context.epoch &&
|
|
40
|
+
context.committedViewport?.sessionId === packet.frame.sessionId &&
|
|
41
|
+
context.committedViewport?.generation === packet.frame.viewportGeneration &&
|
|
42
|
+
context.state !== null &&
|
|
43
|
+
isFrameCurrent(packet.frame, context.state)
|
|
44
|
+
);
|
|
45
|
+
}
|
package/client/browser.tsx
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
BrowserMenuSeparator,
|
|
85
85
|
BrowserToolbarMenu,
|
|
86
86
|
} from "./browser-toolbar-menu";
|
|
87
|
+
import { isFrameCurrent, isVideoSourceCurrent } from "./browser-video-currency";
|
|
87
88
|
import { BrowserVideoSurface } from "./browser-video-surface";
|
|
88
89
|
import { isBrowserClosedError, isExpiredBrowserViewerError } from "./browser-viewer-recovery";
|
|
89
90
|
import { createFrameLifecycle } from "./frame-lifecycle";
|
|
@@ -182,18 +183,6 @@ function isFrameDisplayCurrent(frame: BrowserFrameAuthority, state: BrowserState
|
|
|
182
183
|
);
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
function isFrameCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
|
|
186
|
-
return (
|
|
187
|
-
frame.sessionId === state.sessionId &&
|
|
188
|
-
(!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
|
|
189
|
-
(frame.captureEpoch === undefined ||
|
|
190
|
-
state.bridgeEpoch === undefined ||
|
|
191
|
-
frame.captureEpoch === state.bridgeEpoch) &&
|
|
192
|
-
frame.navigationGeneration === state.navigationGeneration &&
|
|
193
|
-
frame.viewportGeneration === state.viewportGeneration
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
186
|
export function SharedBrowserPanel({
|
|
198
187
|
theme,
|
|
199
188
|
host,
|
|
@@ -505,13 +494,17 @@ export function SharedBrowserPanel({
|
|
|
505
494
|
epoch: () => inputLifecycle.epoch,
|
|
506
495
|
viewport: () => stateRef.current?.viewport ?? null,
|
|
507
496
|
isCurrent: (packet, epoch) =>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
497
|
+
isVideoSourceCurrent(
|
|
498
|
+
{
|
|
499
|
+
mounted: mountedRef.current,
|
|
500
|
+
viewerActive: activeViewerTokenRef.current === viewerToken,
|
|
501
|
+
epoch: inputLifecycle.epoch,
|
|
502
|
+
committedViewport: committedViewport.current,
|
|
503
|
+
state: stateRef.current,
|
|
504
|
+
},
|
|
505
|
+
packet,
|
|
506
|
+
epoch,
|
|
507
|
+
),
|
|
515
508
|
isDisplayCurrent: (packet) => {
|
|
516
509
|
const current = stateRef.current;
|
|
517
510
|
return (
|
|
@@ -962,6 +955,11 @@ export function SharedBrowserPanel({
|
|
|
962
955
|
lastPointRef.current = { x: point.x, y: point.y };
|
|
963
956
|
},
|
|
964
957
|
onActivity: handleInputActivity,
|
|
958
|
+
onInputAcknowledged: () => {
|
|
959
|
+
// Pixels requested before the host acknowledged this input cannot authorize
|
|
960
|
+
// the next press, even when their request began after the local press.
|
|
961
|
+
inputLifecycle.bump();
|
|
962
|
+
},
|
|
965
963
|
onInputBoundary: () => {
|
|
966
964
|
inputLifecycle.bump();
|
|
967
965
|
},
|
|
@@ -45,6 +45,8 @@ interface CanvasOptions {
|
|
|
45
45
|
onPoint(point: BrowserGesturePoint): void;
|
|
46
46
|
onActivity(active: boolean): void;
|
|
47
47
|
onInputBoundary(): void;
|
|
48
|
+
/** The host acknowledged an input that revoked every earlier frame receipt. */
|
|
49
|
+
onInputAcknowledged?(): void;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** Native touch coordinates are relative to the controlled overlay, with stable ID mapping. */
|
|
@@ -120,6 +122,9 @@ export function useBrowserCanvasInput(options: CanvasOptions) {
|
|
|
120
122
|
clearIdle();
|
|
121
123
|
if (alive.current) current.current.onError(error);
|
|
122
124
|
},
|
|
125
|
+
onAcknowledged: () => {
|
|
126
|
+
if (alive.current) current.current.onInputAcknowledged?.();
|
|
127
|
+
},
|
|
123
128
|
onNavigationComplete: () => {
|
|
124
129
|
// Server already closed the acknowledged channel. Quarantine local
|
|
125
130
|
// holds before state projection; never replay their old-page release.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** Deliberately defer React commits to exercise canvas paint and RPC replacement windows. */
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { BrowserState } from "../shared/browser";
|
|
3
4
|
import type {
|
|
4
5
|
BrowserVideoPacket,
|
|
5
6
|
BrowserVideoReadInput,
|
|
6
7
|
BrowserVideoReadReply,
|
|
7
8
|
} from "../shared/browser-video";
|
|
9
|
+
import { isVideoSourceCurrent } from "./browser-video-currency";
|
|
8
10
|
import type { BrowserVideoDecodeEnvironment } from "./browser-video-decoder";
|
|
9
11
|
|
|
10
12
|
interface HookSlot {
|
|
@@ -211,11 +213,30 @@ function setup(retainDocumentDisplay = false) {
|
|
|
211
213
|
fps,
|
|
212
214
|
epoch: () => mutationEpoch,
|
|
213
215
|
viewport: () => ({ width: 1280, height: 800 }),
|
|
216
|
+
// The mounted panel's real source predicate, fed a fake panel context, so a drift
|
|
217
|
+
// in what the panel decides (for example consulting the input-only actionable flag)
|
|
218
|
+
// fails here instead of in a copied predicate.
|
|
214
219
|
isCurrent: (value, epoch) =>
|
|
215
220
|
current &&
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
221
|
+
isVideoSourceCurrent(
|
|
222
|
+
{
|
|
223
|
+
mounted: true,
|
|
224
|
+
viewerActive: true,
|
|
225
|
+
epoch: mutationEpoch,
|
|
226
|
+
committedViewport: {
|
|
227
|
+
sessionId: identity.sessionId,
|
|
228
|
+
generation: identity.viewportGeneration,
|
|
229
|
+
},
|
|
230
|
+
state: {
|
|
231
|
+
sessionId: identity.sessionId,
|
|
232
|
+
runtimeId: identity.runtimeId,
|
|
233
|
+
bridgeEpoch: identity.captureEpoch,
|
|
234
|
+
navigationGeneration: identity.navigationGeneration,
|
|
235
|
+
viewportGeneration: identity.viewportGeneration,
|
|
236
|
+
} as unknown as BrowserState,
|
|
237
|
+
},
|
|
238
|
+
value,
|
|
239
|
+
epoch,
|
|
219
240
|
),
|
|
220
241
|
...(retainDocumentDisplay
|
|
221
242
|
? {
|
|
@@ -278,6 +299,19 @@ function setup(retainDocumentDisplay = false) {
|
|
|
278
299
|
packets: [{ ...packet(sequence), type: keyFrame ? "key" : "delta" }],
|
|
279
300
|
} as BrowserVideoReadReply);
|
|
280
301
|
},
|
|
302
|
+
replyPackets(index: number, sequences: number[], display: number[] = []) {
|
|
303
|
+
harness.reads[index]!.resolve({
|
|
304
|
+
status: "ready",
|
|
305
|
+
streamId: "s".repeat(32),
|
|
306
|
+
state: {},
|
|
307
|
+
packets: sequences.map((sequence) => ({
|
|
308
|
+
...packet(sequence),
|
|
309
|
+
type: sequence === 1 ? "key" : "delta",
|
|
310
|
+
// The host paints these but refuses them as input receipts.
|
|
311
|
+
...(display.includes(sequence) ? { actionable: false } : { actionable: true }),
|
|
312
|
+
})),
|
|
313
|
+
} as BrowserVideoReadReply);
|
|
314
|
+
},
|
|
281
315
|
disabled(index: number) {
|
|
282
316
|
harness.reads[index]!.resolve({
|
|
283
317
|
status: "unsupported",
|
|
@@ -521,6 +555,41 @@ describe("video presentation lifecycle", () => {
|
|
|
521
555
|
expect(result.error).toBeNull();
|
|
522
556
|
expect(result.frontRef.current).not.toBeNull();
|
|
523
557
|
});
|
|
558
|
+
it("decodes and paints a same-source display-only packet without breaking the delta chain or granting input", async () => {
|
|
559
|
+
const f = setup();
|
|
560
|
+
const front: (number | null)[] = [];
|
|
561
|
+
for (const sequence of [1, 2, 3]) {
|
|
562
|
+
f.replyPackets(sequence - 1, [sequence], [2]);
|
|
563
|
+
await drain();
|
|
564
|
+
f.paint();
|
|
565
|
+
const result = f.render();
|
|
566
|
+
f.commit();
|
|
567
|
+
front.push(result.frontRef.current?.sequence ?? null);
|
|
568
|
+
await vi.advanceTimersByTimeAsync(41);
|
|
569
|
+
}
|
|
570
|
+
// All three packets paint, so the codec chain 1 -> 2 -> 3 is intact.
|
|
571
|
+
expect(f.drawn).toEqual([1000, 2000, 3000]);
|
|
572
|
+
// The display-only packet never publishes input authority and does not leave the
|
|
573
|
+
// older actionable receipt current; the next actionable packet does.
|
|
574
|
+
expect(f.presented).not.toContain(2);
|
|
575
|
+
expect(f.presented).toContain(3);
|
|
576
|
+
expect(front).toEqual([1, null, 3]);
|
|
577
|
+
// Each read continued the same stream without asking for a recovery keyframe.
|
|
578
|
+
expect(harness.reads.slice(1, 4).map((read) => read.input.requestKeyFrame)).toEqual([
|
|
579
|
+
false,
|
|
580
|
+
false,
|
|
581
|
+
false,
|
|
582
|
+
]);
|
|
583
|
+
});
|
|
584
|
+
it("still drops wrong-document display-only packets without decoding or painting them", async () => {
|
|
585
|
+
const f = setup();
|
|
586
|
+
f.identity("navigationGeneration", 2);
|
|
587
|
+
f.replyPackets(0, [1, 2], [2]);
|
|
588
|
+
await drain();
|
|
589
|
+
f.paint();
|
|
590
|
+
expect(f.drawn).toEqual([]);
|
|
591
|
+
expect(f.presented).toEqual([]);
|
|
592
|
+
});
|
|
524
593
|
it("a host that disabled video stops reads and releases the decoder without an error", async () => {
|
|
525
594
|
const f = setup();
|
|
526
595
|
f.disabled(0);
|
|
@@ -71,6 +71,13 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
|
|
|
71
71
|
fallbackRef.current = ++fallbackCounter.current;
|
|
72
72
|
setFallbackRevision(fallbackRef.current);
|
|
73
73
|
};
|
|
74
|
+
/** Source/document/geometry validity: enough to decode and paint a packet. */
|
|
75
|
+
const sourceCurrent = (packet: BrowserVideoPacket, epoch: number) =>
|
|
76
|
+
latest.current.isCurrent(packet, epoch);
|
|
77
|
+
/** Input eligibility: source validity plus a host receipt. A display-only packet
|
|
78
|
+
* (actionable === false) may be decoded and painted but never admits a press. */
|
|
79
|
+
const inputCurrent = (packet: BrowserVideoPacket, epoch: number) =>
|
|
80
|
+
packet.actionable !== false && sourceCurrent(packet, epoch);
|
|
74
81
|
const displayCurrent = (packet: BrowserVideoPacket) =>
|
|
75
82
|
latest.current.isDisplayCurrent
|
|
76
83
|
? latest.current.isDisplayCurrent(packet)
|
|
@@ -78,7 +85,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
|
|
|
78
85
|
const front = presentation?.packet ?? null;
|
|
79
86
|
const setFront = (value: BrowserVideoPacket | null, epoch = 0) => {
|
|
80
87
|
displayedPacket.current = value;
|
|
81
|
-
if (!value ||
|
|
88
|
+
if (!value || inputCurrent(value, epoch)) {
|
|
82
89
|
fallbackRef.current = 0;
|
|
83
90
|
setFallbackRevision(0);
|
|
84
91
|
}
|
|
@@ -121,7 +128,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
|
|
|
121
128
|
if (
|
|
122
129
|
!fallbackRef.current &&
|
|
123
130
|
presentation.authorityRevision === authorityRevision.current &&
|
|
124
|
-
|
|
131
|
+
inputCurrent(presentation.packet, presentation.epoch)
|
|
125
132
|
) {
|
|
126
133
|
frontRef.current = presentation.packet;
|
|
127
134
|
latest.current.onPresented(presentation.packet, presentation.epoch);
|
|
@@ -167,9 +174,9 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
|
|
|
167
174
|
isCurrent: (packet, epoch) => !stopped && latest.current.isCurrent(packet, epoch),
|
|
168
175
|
// Same source/document packets preserve the codec's dependency chain,
|
|
169
176
|
// including a reply that began before a local press. They carry that old
|
|
170
|
-
// read epoch through paint and cannot authorize another press.
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
// read epoch through paint and cannot authorize another press. A display-only
|
|
178
|
+
// (non-actionable) packet is still source-valid and must keep decoding.
|
|
179
|
+
isSourceCurrent: (packet) => !stopped && sourceCurrent(packet, latest.current.epoch()),
|
|
173
180
|
onPresented(packet, epoch) {
|
|
174
181
|
if (stopped) return;
|
|
175
182
|
lastPaintedAt = Date.now();
|
|
@@ -177,7 +184,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
|
|
|
177
184
|
setError(null);
|
|
178
185
|
// Once this canvas is visible, its pixels change before React commits.
|
|
179
186
|
// Publish authority synchronously with that draw, never the old front.
|
|
180
|
-
if (visible.current &&
|
|
187
|
+
if (visible.current && inputCurrent(packet, epoch)) {
|
|
181
188
|
frontRef.current = packet;
|
|
182
189
|
latest.current.onPresented(packet, epoch);
|
|
183
190
|
} else {
|
package/package.json
CHANGED
package/server/browser-policy.ts
CHANGED
|
@@ -749,8 +749,10 @@ export class SessionManager {
|
|
|
749
749
|
};
|
|
750
750
|
session.videoReceipts.set(receipt, { frame, capturedAtMonotonicMs });
|
|
751
751
|
}
|
|
752
|
-
this
|
|
753
|
-
|
|
752
|
+
// False when input revoked this capture's time: it may still be painted,
|
|
753
|
+
// but it can never authorize a press, and the viewer must not try.
|
|
754
|
+
const actionable = this.rememberFrame(session, frame, capturedAtMonotonicMs);
|
|
755
|
+
packets.push({ ...packet, frame, actionable });
|
|
754
756
|
}
|
|
755
757
|
}
|
|
756
758
|
while (session.videoReceipts.size > MAX_RECENT_FRAMES) {
|
|
@@ -1746,12 +1748,12 @@ export class SessionManager {
|
|
|
1746
1748
|
frame: BrowserFrameAuthority,
|
|
1747
1749
|
capturedAtMonotonicMs?: number,
|
|
1748
1750
|
observedAt = this.now(),
|
|
1749
|
-
):
|
|
1751
|
+
): boolean {
|
|
1750
1752
|
if (capturedAtMonotonicMs !== undefined && session.videoInputNotBefore !== null) {
|
|
1751
1753
|
const exclusiveFloor = session.videoInputNotBefore + VIDEO_SOURCE_CLOCK_TOLERANCE_MS;
|
|
1752
1754
|
if (!Number.isFinite(capturedAtMonotonicMs) || capturedAtMonotonicMs <= exclusiveFloor) {
|
|
1753
1755
|
session.recentFrames.delete(frame.frameId);
|
|
1754
|
-
return;
|
|
1756
|
+
return false;
|
|
1755
1757
|
}
|
|
1756
1758
|
}
|
|
1757
1759
|
this.pruneRecentFrames(session);
|
|
@@ -1770,6 +1772,7 @@ export class SessionManager {
|
|
|
1770
1772
|
if (!oldest) break;
|
|
1771
1773
|
session.recentFrames.delete(oldest);
|
|
1772
1774
|
}
|
|
1775
|
+
return session.recentFrames.has(frame.frameId);
|
|
1773
1776
|
}
|
|
1774
1777
|
|
|
1775
1778
|
/**
|
|
@@ -153,6 +153,8 @@ it.each([-1, 0, 40])(
|
|
|
153
153
|
const late = await pendingRead;
|
|
154
154
|
pendingRead = null;
|
|
155
155
|
expect(late.packets[0]!.frame.frameId).toBe(frame.frameId);
|
|
156
|
+
// Late pixels are painted but flagged: the viewer must not try to press on them.
|
|
157
|
+
expect(late.packets[0]!.actionable).toBe(false);
|
|
156
158
|
// Late pixels still paint, but cannot re-authorize discrete agent input.
|
|
157
159
|
await expect(strictInput()).rejects.toThrow("frame is stale");
|
|
158
160
|
expect(calls).not.toContain("mouse.down");
|
|
@@ -185,6 +187,7 @@ it.each([-1, 0, 40])(
|
|
|
185
187
|
packet.capturedAt = new Date(now).toISOString();
|
|
186
188
|
const fresh = await manager.readVideo(videoInput);
|
|
187
189
|
const freshFrame = fresh.packets[0]!.frame;
|
|
190
|
+
expect(fresh.packets[0]!.actionable).toBe(true);
|
|
188
191
|
const accepted = await manager.beginGesture({
|
|
189
192
|
viewerToken,
|
|
190
193
|
controlToken,
|
package/shared/browser-video.ts
CHANGED
|
@@ -52,6 +52,9 @@ export type NativeVideoPacket = z.infer<typeof nativeVideoPacketSchema>;
|
|
|
52
52
|
|
|
53
53
|
export const browserVideoPacketSchema = nativeVideoPacketSchema.extend({
|
|
54
54
|
frame: browserFrameAuthoritySchema,
|
|
55
|
+
/** False when the host refused this capture as an input receipt (it predates
|
|
56
|
+
* acknowledged input). It may still be painted. Absent from older hosts. */
|
|
57
|
+
actionable: z.boolean().optional(),
|
|
55
58
|
});
|
|
56
59
|
export type BrowserVideoPacket = z.infer<typeof browserVideoPacketSchema>;
|
|
57
60
|
|