diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7b8729a11646c..bb7028f998214 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -78,6 +78,13 @@ const makeDebuggee = ( getUserAgent: () => "Electron", isLoadingMainFrame: () => options.loading === true, isDestroyed: () => destroyed, + // A viewport screenshot copies the guest's surface; it waits like a debugger command. + capturePage: () => + new Promise((resolve) => { + sent.push(undefined); + pending.push(() => resolve({ isEmpty: () => false, toPNG: () => Buffer.from("png") })); + sendWaiters.shift()?.(); + }), }); return { tab: { diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index a0889940a6682..fda66ad598b80 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -240,6 +240,86 @@ describe("preview guest input", () => { }, ); + it("screenshots the guest's surface once the app paints it, and leaves clips to Chromium", async () => { + vi.useFakeTimers(); + try { + const guest = makeGuest({ focused: false }); + const painted = { isEmpty: () => false, toPNG: () => Buffer.from("png") }; + const capturePage = vi + .fn() + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockResolvedValueOnce({ isEmpty: () => true }) + .mockResolvedValueOnce(painted); + Object.assign(guest.webContents, { capturePage, isDestroyed: () => false }); + const screenshot = guest.send("Page.captureScreenshot", { format: "png" }); + await vi.advanceTimersByTimeAsync(100); + await expect(screenshot).resolves.toEqual({ data: Buffer.from("png").toString("base64") }); + expect(capturePage).toHaveBeenCalledTimes(3); + await guest.send("Page.captureScreenshot", { format: "png", clip: { x: 0, y: 0 } }); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(guest.sendCommand).toHaveBeenCalledWith("Page.captureScreenshot", { + format: "png", + clip: { x: 0, y: 0 }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("fails a screenshot of a guest that never paints instead of leaving it unanswered", async () => { + vi.useFakeTimers(); + try { + const guest = makeGuest({ focused: false }); + const capturePage = vi.fn(() => new Promise(() => {})); + Object.assign(guest.webContents, { capturePage, isDestroyed: () => false }); + const screenshot = guest.send("Page.captureScreenshot", { format: "png" }); + const failed = expect(screenshot).rejects.toThrow(/did not paint within 5 seconds/); + await vi.advanceTimersByTimeAsync(5_000); + await failed; + expect(capturePage).toHaveBeenCalledTimes(1); + expect(guest.sendCommand).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("shares an unanswered copy across timeouts and captures again after it settles", async () => { + vi.useFakeTimers(); + try { + const guest = makeGuest({ focused: false }); + const painted = { isEmpty: () => false, toPNG: () => Buffer.from("png") }; + let finish: ((image: typeof painted) => void) | undefined; + const capturePage = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(painted); + Object.assign(guest.webContents, { capturePage, isDestroyed: () => false }); + for (let attempt = 0; attempt < 2; attempt++) { + const screenshot = guest.send("Page.captureScreenshot", { format: "png" }); + const failed = expect(screenshot).rejects.toThrow(/did not paint within 5 seconds/); + await vi.advanceTimersByTimeAsync(5_000); + await failed; + } + expect(capturePage).toHaveBeenCalledTimes(1); + const fresh = guest.send("Page.captureScreenshot", { format: "png" }); + await vi.advanceTimersByTimeAsync(0); + expect(capturePage).toHaveBeenCalledTimes(1); + finish!({ isEmpty: () => false, toPNG: () => Buffer.from("old frame") }); + await vi.advanceTimersByTimeAsync(0); + await expect(fresh).resolves.toEqual({ + data: Buffer.from("png").toString("base64"), + }); + expect(capturePage).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 2af13af8d7daa..a518af34369cb 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,4 +1,7 @@ // CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", @@ -167,7 +170,85 @@ const sendPointerCommand = async ( } }; -/** Sends one command to the guest's page, keeping reloads and input inside it. */ +const SCREENSHOT_DEADLINE = "5 seconds"; +const SCREENSHOT_RETRY = "50 millis"; +const pendingViewportCopies = new WeakMap>(); + +const copyViewport = ( + webContents: Electron.WebContents, + signal: AbortSignal, +): Promise => { + signal.throwIfAborted(); + const pending = pendingViewportCopies.get(webContents); + if (pending) return pending.catch(() => undefined).then(() => copyViewport(webContents, signal)); + const copy = webContents.capturePage().finally(() => { + if (pendingViewportCopies.get(webContents) === copy) pendingViewportCopies.delete(webContents); + }); + pendingViewportCopies.set(webContents, copy); + return copy; +}; + +class PreviewScreenshotError extends Schema.TaggedError()( + "PreviewScreenshotError", + { reason: Schema.Literals(["unpainted", "timeout"]) }, +) { + override get message(): string { + return this.reason === "timeout" + ? `The preview tab did not paint within ${SCREENSHOT_DEADLINE}.` + : "The preview tab has not painted yet."; + } +} + +/** Whether capturePage can stand in for this screenshot; a clip or full page goes to Chromium. */ +const capturesViewport = (params: Record) => + Object.entries(params).every(([name, value]) => { + switch (name) { + case "format": + return value === "png" || value === "jpeg"; + case "fromSurface": + return value !== false; + case "captureBeyondViewport": + return value !== true; + case "quality": + case "optimizeForSpeed": + return true; + default: + return false; + } + }); + +/** + * Page.captureScreenshot waits for a presented frame, which Chromium may never present for a + * guest the app paints behind itself. capturePage copies the surface instead. That surface exists + * only once the client paints the guest, so a refused copy is retried; a stalled one is not overlapped. + */ +const captureViewport = (webContents: Electron.WebContents, params: Record) => + Effect.runPromise( + Effect.tryPromise({ + try: (signal) => copyViewport(webContents, signal), + catch: () => new PreviewScreenshotError({ reason: "unpainted" }), + }).pipe( + Effect.filterOrFail( + (image) => !image.isEmpty(), + () => new PreviewScreenshotError({ reason: "unpainted" }), + ), + Effect.retry({ + schedule: Schedule.spaced(SCREENSHOT_RETRY), + while: () => !webContents.isDestroyed(), + }), + Effect.timeoutOrElse({ + duration: SCREENSHOT_DEADLINE, + orElse: () => Effect.fail(new PreviewScreenshotError({ reason: "timeout" })), + }), + Effect.map((image) => { + const quality = typeof params["quality"] === "number" ? params["quality"] : 80; + const encoded = params["format"] === "jpeg" ? image.toJPEG(quality) : image.toPNG(); + return { data: encoded.toString("base64") }; + }), + ), + ); + +/** Sends one command to the guest's page, keeping reloads, screenshots and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, @@ -185,6 +266,9 @@ export const sendPageCommand = async ( else webContents.reload(); return {}; } + if (method === "Page.captureScreenshot" && capturesViewport(params)) { + return captureViewport(webContents, params); + } if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { return debuggee.sendCommand(method, params); }