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
46 changes: 46 additions & 0 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger.
import { describe, expect, it } from "@effect/vitest";
import { DesktopBrowserEvent } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Queue from "effect/Queue";
Expand Down Expand Up @@ -73,6 +74,13 @@ const makeDebuggee = (
getURL: () => "http://localhost/",
getTitle: () => "Page",
getUserAgent: () => "Electron",
capturePage: async () => {
await debuggee.sendCommand("Page.captureScreenshot");
return {
toPNG: () => Buffer.from("native png"),
toJPEG: () => Buffer.from("native jpeg"),
};
},
});
return {
tab: {
Expand Down Expand Up @@ -134,6 +142,44 @@ const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) =
});

describe("DesktopBrowserHost", () => {
it.effect.each(["darwin", "linux"] as const)(
"uses native viewport capture only on macOS (%s)",
(platform) =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
let cdpCaptures = 0;
let nativeCaptures = 0;
const debuggee = makeDebuggee({
refuse: (method) => {
if (method !== "Page.captureScreenshot") return false;
cdpCaptures += 1;
return true;
},
});
debuggee.tab.webContents.capturePage = async () => {
nativeCaptures += 1;
return { toPNG: () => Buffer.from("native png") } as Electron.NativeImage;
};
host.attach(key, debuggee.tab);
const replies = yield* watchReplies(host);
yield* host.handleCommandLine(
encodeJson({
type: "cdp",
...key,
message: encodeJson({
id: 1,
method: "Page.captureScreenshot",
params: { format: "png" },
sessionId: "t3-preview-page",
}),
}),
);
expect(yield* Queue.take(replies)).toBe(1);
expect(nativeCaptures).toBe(platform === "darwin" ? 1 : 0);
expect(cdpCaptures).toBe(platform === "darwin" ? 0 : 1);
}).pipe(Effect.provideService(HostProcess.Platform, platform), Effect.scoped),
);

it.effect("announces tabs already attached to a backend that starts later", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
Expand Down
13 changes: 9 additions & 4 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DesktopBrowserEvent,
type DesktopBrowserEvent as DesktopBrowserEventType,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Context from "effect/Context";
import * as NodePath from "node:path";
import * as Effect from "effect/Effect";
Expand All @@ -15,6 +16,7 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";

import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
import { captureGuestPage } from "./GuestCapture.ts";
import { sendPageCommand } from "./GuestInput.ts";

const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
Expand Down Expand Up @@ -104,6 +106,7 @@ export class DesktopBrowserHost extends Context.Service<
>()("@t3tools/desktop/preview/DesktopBrowserHost") {}

export const make = Effect.gen(function* () {
const hostPlatform = yield* HostProcess.Platform;
const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>();
const pointers = yield* PubSub.sliding<{
readonly key: DesktopBrowserTabKey;
Expand Down Expand Up @@ -301,10 +304,12 @@ export const make = Effect.gen(function* () {
const relayFor = (tab: AttachedTab) => {
if (tab.relay) return tab.relay;
const { webContents, debugger: debuggee } = tab.debuggee;
const send = (method: string, params: Record<string, unknown>, sessionId?: string) =>
sessionId === undefined
? sendPageCommand(webContents, debuggee, method, params)
: debuggee.sendCommand(method, params, sessionId);
const send = (method: string, params: Record<string, unknown>, sessionId?: string) => {
if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId);
if (hostPlatform === "darwin" && method === "Page.captureScreenshot")
return captureGuestPage(webContents, debuggee, params);
return sendPageCommand(webContents, debuggee, method, params);
};
const detachedOwners = new Set<string>();
/** Viewers and recordings stream; the agent's own sessions never do. */
const streamingOwners = new Set<string>();
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/preview/GuestCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vite-plus/test";

import { captureGuestPage } from "./GuestCapture.ts";

const nativePage = () =>
({
capturePage: async () => ({
toPNG: () => Buffer.from("native png"),
toJPEG: (quality: number) => Buffer.from(`native jpeg ${quality}`),
}),
}) as unknown as Electron.WebContents;

const unavailableDebugger = {
sendCommand: async () => {
throw new Error("CDP capture is blocked");
},
} as unknown as Electron.Debugger;

describe("captureGuestPage", () => {
it("returns a viewport frame when CDP capture is unavailable", async () => {
const result = await captureGuestPage(nativePage(), unavailableDebugger, {});
expect(result).toEqual({ data: Buffer.from("native png").toString("base64") });
});

it("retains JPEG quality for remote viewer still frames", async () => {
const result = await captureGuestPage(nativePage(), unavailableDebugger, {
format: "jpeg",
quality: 65,
});
expect(result).toEqual({ data: Buffer.from("native jpeg 65").toString("base64") });
});

it("preserves CDP capture options that native viewport capture cannot represent", async () => {
const params = { format: "png", clip: { x: 10, y: 20, width: 30, height: 40, scale: 2 } };
const debuggee = {
sendCommand: async (method: string, received: unknown) => ({ method, received }),
} as unknown as Electron.Debugger;
expect(await captureGuestPage(nativePage(), debuggee, params)).toEqual({
method: "Page.captureScreenshot",
received: params,
});
});
});
28 changes: 28 additions & 0 deletions apps/desktop/src/preview/GuestCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Native capture supplies unchanged frames when an occluded guest's CDP capture stops. */
export const captureGuestPage = async (
webContents: Electron.WebContents,
debuggee: Electron.Debugger,
params: Record<string, unknown>,
): Promise<unknown> => {
const format = params["format"] ?? "png";
if (
Object.keys(params).some(
(key) =>
!["format", "quality", "optimizeForSpeed", "captureBeyondViewport", "fromSurface"].includes(
key,
),
) ||
params["clip"] !== undefined ||
params["captureBeyondViewport"] === true ||
params["fromSurface"] === false ||
(format !== "png" && format !== "jpeg")
) {
return debuggee.sendCommand("Page.captureScreenshot", params);
}
const image = await webContents.capturePage(undefined, { stayHidden: true });
const data =
format === "jpeg"
? image.toJPEG(typeof params["quality"] === "number" ? params["quality"] : 80)
: image.toPNG();
return { data: data.toString("base64") };
};
1 change: 1 addition & 0 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export function HostedBrowserWebview(props: {
const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
active,
renderingActive,
serverDriven: serverDriven && isMacPlatform(navigator.platform),
// Electron 43 can permanently blank a macOS webview after `visibility: hidden`.
keepPaintableWhenInactive: isMacPlatform(navigator.platform),
cornerRadius: presentation.cornerRadius,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/browser/hostedBrowserWebviewStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000;
export function resolveHostedBrowserWebviewWrapperStyle(input: {
readonly active: boolean;
readonly renderingActive: boolean;
readonly serverDriven?: boolean;
readonly keepPaintableWhenInactive?: boolean;
readonly cornerRadius?: number;
readonly zIndex?: number;
Expand All @@ -34,6 +35,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
keepPaintableWhenInactive = false,
rect,
renderingActive,
serverDriven = false,
zIndex = 30,
} = input;
if (active && rect) {
Expand All @@ -48,7 +50,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: {
};
}

if (renderingActive) {
if (renderingActive || serverDriven) {
// Electron stops compositing offscreen guests; keep capture-active guests inside the viewport behind the app.
return {
left: 0,
Expand Down
Loading