Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/browser/PreviewStreamWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ function PreviewStreamDocumentView({
onPictureInPicture?.(message, message.detail);
return;
case "status":
clearBootstrapTimer();
if (message.status !== "connecting") clearBootstrapTimer();
if (message.status === "error") {
fail(message.detail ?? "Browser stream failed.");
return;
Expand Down
63 changes: 62 additions & 1 deletion apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function makeSession(frame?: ReturnType<typeof makeFrame>) {
if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };
if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
if (method === "Page.captureScreenshot") {
if ((input as { format: string }).format === "jpeg" && heldStill) await heldStill();
// A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is.
return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" };
}
Expand Down Expand Up @@ -260,6 +261,7 @@ const contexts: ReturnType<typeof makeContext>[] = [];
const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = [];
const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];
let contextGate: PromiseWithResolvers<void> | null = null;
let heldStill: (() => Promise<void>) | null = null;
let pageCopy = "";
let keyCopy = "";
let focusedFrame: ReturnType<typeof makeFrame> | null = null;
Expand Down Expand Up @@ -371,6 +373,7 @@ beforeEach(() => {
encoderStreams.length = 0;
contextRequests.length = 0;
contextGate = null;
heldStill = null;
contextFailure = null;
initialNavigation = null;
pageCopy = "";
Expand Down Expand Up @@ -625,6 +628,61 @@ it.live("enforces provider ownership and explicit targets when a session has mul
).pipe(Effect.provide(layer)),
);

it.live("a viewer's stream opens before its first still arrives", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, tabId } = yield* ready;
const capturing = Promise.withResolvers<void>();
const still = Promise.withResolvers<void>();
heldStill = () => {
capturing.resolve();
return still.promise;
};
yield* Effect.addFinalizer(() => Effect.sync(() => still.resolve()));
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
yield* Effect.promise(() => capturing.promise);
still.resolve();
const frame = yield* Stream.fromQueue(viewer.output).pipe(
Stream.filter((output) => output._tag === "frame"),
Stream.runHead,
);
expect(Option.isSome(frame)).toBe(true);
}),
).pipe(Effect.provide(layer)),
);

it.effect("a first still that never returns does not stall the tab's later captures", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers()));
const capturing = Promise.withResolvers<void>();
const stuck = Promise.withResolvers<void>();
heldStill = () => {
heldStill = null;
capturing.resolve();
return stuck.promise;
};
yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve()));
yield* browser.attachViewer(viewerInput(tabId, true));
yield* Effect.promise(() => capturing.promise);
const snapshotting = yield* broker
.invoke<PreviewAutomationSnapshot>({ scope, tabId, operation: "snapshot", input: {} })
.pipe(Effect.forkScoped);
yield* Effect.promise(() => vi.advanceTimersByTimeAsync(2_000));
const snapshot = yield* Fiber.join(snapshotting);
expect(snapshot.accessibilityTree).toContain("delete");
const second = yield* browser.attachViewer(viewerInput(tabId, false));
const frame = yield* Stream.fromQueue(second.output).pipe(
Stream.filter((output) => output._tag === "frame"),
Stream.runHead,
);
expect(Option.isSome(frame)).toBe(true);
}),
).pipe(Effect.provide(layer)),
);

it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () =>
Effect.scoped(
Effect.gen(function* () {
Expand All @@ -643,7 +701,10 @@ it.live("streams to a read-only viewer without allowing takeover, input, or view
yield* viewer.input(message);
expect(page.setViewportSize).not.toHaveBeenCalled();
expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false);
const outputs = yield* Queue.takeAll(viewer.output);
const outputs = yield* Stream.fromQueue(viewer.output).pipe(
Stream.takeUntil((output) => output._tag === "frame"),
Stream.runCollect,
);
expect(outputs).toContainEqual(
expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }),
);
Expand Down
28 changes: 20 additions & 8 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ const SCREENCAST_MOTION_WINDOW_MS = 300;
const SCREENCAST_MOTION_QUALITY = 50;
const HOST_RECONNECT_DELAY = "1 second";
const DESKTOP_ATTACH_TIMEOUT = "10 seconds";
// A viewer still is best-effort; a capture Chromium never answers must not hold the capture lock.
const VIEWER_STILL_TIMEOUT_MS = 2_000;
const VIEWER_OUTPUT_LIMIT = 64;
const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const;
const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema);
Expand Down Expand Up @@ -2334,13 +2336,23 @@ const make = Effect.gen(function* () {
pushFileChooser(tab);
// Full scale: a scaled capture would flash in every other viewer.
const pushStill = async () => {
const data = await withCaptureLock(tab, () =>
ServerBrowserPage.captureViewport(tab.page, session, {
format: "jpeg",
quality,
scale: 1,
}),
).catch(() => null);
const data = await withCaptureLock(tab, async () => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
ServerBrowserPage.captureViewport(tab.page, session, {
format: "jpeg",
quality,
scale: 1,
}),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), VIEWER_STILL_TIMEOUT_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}).catch(() => null);
if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void });
};
let settleTimer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -2398,7 +2410,7 @@ const make = Effect.gen(function* () {
yield* Effect.promise(() => startScreencast(1));
// An idle page does not repaint for a new screencast, so the viewer
// starts from a still unless a live frame beat it.
if (!screencastStarted) yield* Effect.promise(pushStill);
if (!screencastStarted) yield* Effect.forkScoped(Effect.promise(pushStill));
return {
output,
input: (raw: unknown) =>
Expand Down
Loading