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
7 changes: 7 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
80 changes: 80 additions & 0 deletions apps/desktop/src/preview/GuestInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => {}));
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(
Expand Down
86 changes: 85 additions & 1 deletion apps/desktop/src/preview/GuestInput.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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<Electron.WebContents, Promise<Electron.NativeImage>>();

const copyViewport = (
webContents: Electron.WebContents,
signal: AbortSignal,
): Promise<Electron.NativeImage> => {
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>()(
"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<string, unknown>) =>
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<string, unknown>) =>
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,
Expand All @@ -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);
}
Expand Down
Loading