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
122 changes: 119 additions & 3 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2434,23 +2434,23 @@ describe("PreviewManager", () => {
);
expect(states.at(-1)?.pictureInPicture).toBe(true);
expect(capturePage).toHaveBeenCalledOnce();
const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length;

yield* manager.startRecording("tab_pip");
expect(capturePage).toHaveBeenCalledOnce();
expect(recordingFrames).toHaveLength(0);

yield* TestClock.adjust(100);
expect(capturePage).toHaveBeenCalledTimes(2);
expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording);
expect(recordingFrames).toHaveLength(1);

yield* manager.stopRecording("tab_pip");
expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
yield* TestClock.adjust(100);
expect(capturePage).toHaveBeenCalledTimes(3);
expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan(
framesBeforePictureInPictureOnlyTick,
);
expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick);
expect(recordingFrames).toHaveLength(1);

setBackgroundThrottling.mockImplementationOnce(() => {
Expand All @@ -2467,6 +2467,122 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("delivers unchanged frames only to a new picture-in-picture consumer", () =>
withManager((manager) =>
Effect.gen(function* () {
const jpeg = Buffer.from("shared-preview-frame");
const capturePage = vi.fn(async () => ({
toJPEG: () => jpeg,
getSize: () => ({ width: 1280, height: 720 }),
}));
fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
browserWindowConstructor.mockImplementation(function () {
return pictureInPictureWindow;
});
const recordingFrames: DesktopPreviewRecordingFrame[] = [];

yield* manager.subscribeRecordingFrames((frame) =>
Effect.sync(() => {
recordingFrames.push(frame);
}),
);
yield* manager.createTab("tab_recording_then_pip");
yield* manager.registerWebview("tab_recording_then_pip", 42);
yield* manager.startRecording("tab_recording_then_pip");

expect(recordingFrames).toHaveLength(1);
yield* manager.openPictureInPicture("tab_recording_then_pip");
expect(capturePage).toHaveBeenCalledOnce();
expect(send).not.toHaveBeenCalled();

yield* TestClock.adjust(100);

expect(capturePage).toHaveBeenCalledTimes(2);
expect(recordingFrames).toHaveLength(1);
expect(send).toHaveBeenCalledOnce();
yield* manager.closePictureInPicture("tab_recording_then_pip");
yield* manager.stopRecording("tab_recording_then_pip");
}),
),
);

effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () =>
withManager((manager) =>
Effect.gen(function* () {
const jpeg = Buffer.from("retry-preview-frame");
const capturePage = vi.fn(async () => ({
toJPEG: () => jpeg,
getSize: () => ({ width: 1280, height: 720 }),
}));
fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
send.mockImplementationOnce(() => {
throw new Error("picture-in-picture delivery failed");
});
browserWindowConstructor.mockImplementation(function () {
return pictureInPictureWindow;
});

yield* manager.createTab("tab_pip_delivery_retry");
yield* manager.registerWebview("tab_pip_delivery_retry", 42);
yield* manager.openPictureInPicture("tab_pip_delivery_retry");
expect(send).toHaveBeenCalledOnce();

yield* TestClock.adjust(100);

expect(capturePage).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenCalledTimes(2);
yield* manager.closePictureInPicture("tab_pip_delivery_retry");
}),
),
);

effectIt.effect("delivers recording frames only when pixels change", () =>
withManager((manager) =>
Effect.gen(function* () {
const unchanged = Buffer.from("unchanged-preview-frame");
const changed = Buffer.from("changed-preview-frame");
let captureIndex = 0;
const capturePage = vi.fn(async () => {
const jpeg = captureIndex < 2 ? unchanged : changed;
captureIndex += 1;
return {
toJPEG: () => jpeg,
getSize: () => ({ width: 1280, height: 720 }),
};
});
fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
const frames: DesktopPreviewRecordingFrame[] = [];

yield* manager.subscribeRecordingFrames((frame) =>
Effect.sync(() => {
frames.push(frame);
}),
);
yield* manager.createTab("tab_unchanged_frame");
yield* manager.registerWebview("tab_unchanged_frame", 42);
yield* manager.startRecording("tab_unchanged_frame");

expect(frames.map((frame) => frame.data)).toEqual([unchanged.toString("base64")]);

yield* TestClock.adjust(100);

expect(capturePage).toHaveBeenCalledTimes(2);
expect(frames.map((frame) => frame.data)).toEqual([unchanged.toString("base64")]);

yield* TestClock.adjust(100);

expect(capturePage).toHaveBeenCalledTimes(3);
expect(frames.map((frame) => frame.data)).toEqual([
unchanged.toString("base64"),
changed.toString("base64"),
]);
yield* manager.stopRecording("tab_unchanged_frame");
}),
),
);

effectIt.effect("retries a cold hidden-tab capture without dropping recording", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
53 changes: 48 additions & 5 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording";
interface FrameCaptureSession {
readonly scope: Scope.Closeable;
readonly consumers: ReadonlySet<FrameCaptureConsumer>;
readonly lastPictureInPictureFrame: Buffer | null;
readonly lastRecordingFrame: Buffer | null;
}

interface PictureInPictureSession {
Expand Down Expand Up @@ -616,7 +618,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return [
undefined,
replaceMap(sessions, (copy) => {
copy.set(tabId, { ...current, consumers });
copy.set(tabId, {
...current,
consumers,
lastPictureInPictureFrame:
consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame,
lastRecordingFrame: consumer === "recording" ? null : current.lastRecordingFrame,
});
}),
] as const;
}
Expand Down Expand Up @@ -2549,18 +2557,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
tabId,
webContentsId: wc.id,
},
() => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"),
() => image.toJPEG(RECORDING_JPEG_QUALITY),
);
const frameConsumers = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => {
const current = sessions.get(tabId);
if (current?.scope !== captureSession.scope) {
return [undefined, sessions] as const;
}
const recording =
current.consumers.has("recording") && current.lastRecordingFrame?.equals(encoded) !== true;
const pictureInPicture =
current.consumers.has("picture-in-picture") &&
current.lastPictureInPictureFrame?.equals(encoded) !== true;
if (!recording && !pictureInPicture) {
return [undefined, sessions] as const;
}
const next = recording ? { ...current, lastRecordingFrame: encoded } : current;
return [
{ pictureInPicture, recording, session: next },
recording
? replaceMap(sessions, (copy) => {
copy.set(tabId, next);
})
: sessions,
] as const;
});
if (!frameConsumers) return;
const receivedAt = yield* currentIso;
const frame: DesktopPreviewRecordingFrame = {
tabId,
data: encoded,
data: encoded.toString("base64"),
width: size.width,
height: size.height,
receivedAt,
};
const deliveries: Array<Effect.Effect<void>> = [];
if (currentCaptureSession.consumers.has("recording")) {
if (frameConsumers.recording) {
const listeners = yield* Ref.get(recordingFrameListenersRef);
deliveries.push(
Effect.forEach(
Expand All @@ -2570,7 +2602,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
),
);
}
if (currentCaptureSession.consumers.has("picture-in-picture")) {
if (frameConsumers.pictureInPicture) {
const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
tabId,
)?.window;
Expand Down Expand Up @@ -2620,6 +2652,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
},
);
yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => {
if (sessions.get(tabId) !== frameConsumers.session) return sessions;
return replaceMap(sessions, (copy) => {
copy.set(tabId, {
...frameConsumers.session,
lastPictureInPictureFrame: encoded,
});
});
});
}).pipe(
Effect.catch((error) =>
Effect.logWarning("Picture-in-picture frame delivery failed.", {
Expand Down Expand Up @@ -2687,6 +2728,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
copy.set(tabId, {
scope,
consumers: new Set([consumer]),
lastPictureInPictureFrame: null,
lastRecordingFrame: null,
});
}),
] as const;
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/previewStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
rememberPreviewUrl,
removePreviewThread,
resetPreviewStateForTests,
subscribeThreadPreviewState,
setActivePreviewTab,
updatePreviewServerSnapshot,
} from "./previewStateStore";
Expand Down Expand Up @@ -331,6 +332,38 @@ describe("previewStateStore (single-tab)", () => {
expect(state.snapshot?.canGoBack).toBe(false);
});

it("does not publish duplicate desktop browser state", () => {
const snapshot = makeSnapshot();
applyPreviewServerSnapshot(ref, snapshot);
const overlay = {
hasWebContents: true,
canGoBack: true,
canGoForward: false,
loading: false,
zoomFactor: 1,
pictureInPicture: false,
colorScheme: "system" as const,
audioMuted: false,
audible: false,
controller: "none" as const,
favicon: {
dataUrl: "data:image/png;base64,AA==",
pageUrl: "https://example.com",
capturedAt: 1,
},
};
let updateCount = 0;
const unsubscribe = subscribeThreadPreviewState(ref, () => {
updateCount += 1;
});

applyPreviewDesktopState(ref, snapshot.tabId, overlay);
applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, favicon: { ...overlay.favicon } });
unsubscribe();

expect(updateCount).toBe(1);
});

it("retains multiple tabs and switches active desktop state", () => {
const first = makeSnapshot();
const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" };
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/previewStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,27 @@ export function applyPreviewDesktopState(
overlay: DesktopPreviewOverlay | null,
): void {
updateThreadPreviewState(ref, (current) => {
const previous = current.desktopByTabId[tabId] ?? null;
if (
previous === overlay ||
(previous !== null &&
overlay !== null &&
previous.hasWebContents === overlay.hasWebContents &&
previous.canGoBack === overlay.canGoBack &&
previous.canGoForward === overlay.canGoForward &&
previous.loading === overlay.loading &&
previous.zoomFactor === overlay.zoomFactor &&
previous.pictureInPicture === overlay.pictureInPicture &&
previous.colorScheme === overlay.colorScheme &&
previous.audioMuted === overlay.audioMuted &&
previous.audible === overlay.audible &&
previous.controller === overlay.controller &&
previous.favicon?.dataUrl === overlay.favicon?.dataUrl &&
previous.favicon?.pageUrl === overlay.favicon?.pageUrl &&
previous.favicon?.capturedAt === overlay.favicon?.capturedAt)
) {
return current;
}
const desktopByTabId = { ...current.desktopByTabId };
if (overlay) desktopByTabId[tabId] = overlay;
else delete desktopByTabId[tabId];
Expand Down
Loading