Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 6 additions & 6 deletions apps/web/src/browser/BrowserSurfaceSlot.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useEffect, useRef } from "react";
import { useLayoutEffect, useRef } from "react";

import { useBrowserSurfaceStore } from "./browserSurfaceStore";
import { acquireBrowserSurface } from "./browserSurfaceStore";

export function BrowserSurfaceSlot(props: {
readonly tabId: string;
Expand All @@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: {
const { tabId, visible, className } = props;
const elementRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
useLayoutEffect(() => {
const element = elementRef.current;
if (!element) return;
const lease = acquireBrowserSurface(tabId);
const update = () => {
const rect = element.getBoundingClientRect();
useBrowserSurfaceStore.getState().present(
tabId,
lease.present(
{
x: Math.round(rect.x),
y: Math.round(rect.y),
Expand All @@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: {
observer.disconnect();
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
useBrowserSurfaceStore.getState().hide(tabId);
lease.release();
};
}, [tabId, visible]);

Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { previewBridge } from "~/components/preview/previewBridge";
import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
import { cn } from "~/lib/utils";

import { useActiveBrowserRecordingTabId } from "./browserRecording";
import { useBrowserRecordingSurfaceTabId } from "./browserRecording";
import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
import { browserViewportSettingKey } from "./browserViewportLayout";
import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState";
Expand Down Expand Up @@ -56,7 +56,7 @@ export function HostedBrowserWebview(props: {
};
}),
);
const recording = useActiveBrowserRecordingTabId() === tabId;
const recording = useBrowserRecordingSurfaceTabId() === tabId;

usePreviewBridge({ threadRef, tabId });

Expand Down Expand Up @@ -181,12 +181,17 @@ export function HostedBrowserWebview(props: {
pointerEvents: "auto" as const,
}
: {
left: 0,
top: 0,
// Chromium must keep painting a background guest while recording
// so Page.startScreencast continues to emit frames. Every other
// inactive guest is both hidden and moved offscreen; z-index alone
// is not a reliable visibility boundary for Electron webviews.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
left: -100_000,
top: -100_000,
width: hiddenSize.width,
height: hiddenSize.height,
zIndex: recording ? 0 : -1,
pointerEvents: "none" as const,
visibility: recording ? ("visible" as const) : ("hidden" as const),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
};

return (
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/browser/browserRecording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const { events, onFrame, registrySet, save, startScreencast, stopScreencast } = vi.hoisted(() => {
const events: string[] = [];
return {
events,
onFrame: vi.fn(() => vi.fn()),
registrySet: vi.fn((_atom: unknown, value: string | null) => {
events.push(value === null ? "clear" : `publish:${value}`);
}),
save: vi.fn(async () => ({
id: "recording-test",
tabId: "recording-tab",
path: "/tmp/recording-test.webm",
mimeType: "video/webm" as const,
sizeBytes: 0,
createdAt: "2026-06-26T00:00:00.000Z",
})),
startScreencast: vi.fn(async () => {
events.push("start-screencast");
}),
stopScreencast: vi.fn(async () => undefined),
};
});

vi.mock("~/components/preview/previewBridge", () => ({
previewBridge: {
recording: { onFrame, save, startScreencast, stopScreencast },
},
}));

vi.mock("~/rpc/atomRegistry", () => ({
appAtomRegistry: { set: registrySet },
}));

vi.mock("./browserSurfaceStore", () => ({
useBrowserSurfaceStore: {
getState: () => ({ byTabId: {} }),
},
}));

import { startBrowserRecording, stopBrowserRecording } from "./browserRecording";

class FakeMediaRecorder {
static isTypeSupported(): boolean {
return true;
}

state: RecordingState = "inactive";
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();

addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}

start(): void {
this.state = "recording";
}

stop(): void {
this.state = "inactive";
for (const listener of this.listeners.get("stop") ?? []) {
if (typeof listener === "function") listener(new Event("stop"));
else listener.handleEvent(new Event("stop"));
}
}
}

describe("browser recording surface preparation", () => {
beforeEach(() => {
events.length = 0;
vi.clearAllMocks();
vi.stubGlobal("window", globalThis);
vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
vi.stubGlobal("document", {
createElement: () => ({
width: 0,
height: 0,
captureStream: () => ({}),
getContext: () => ({ drawImage: vi.fn() }),
}),
});
let frameId = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const id = ++frameId;
queueMicrotask(() => {
events.push(`paint:${id}`);
callback(id);
});
return id;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("makes an inactive guest paintable before starting its screencast", async () => {
await startBrowserRecording("recording-tab");

expect(events).toEqual([
"publish:recording-tab",
"paint:1",
"paint:2",
"start-screencast",
"publish:recording-tab",
]);

await stopBrowserRecording("recording-tab");
});
});
34 changes: 34 additions & 0 deletions apps/web/src/browser/browserRecording.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,24 @@ const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("preview:active-browser-recording-tab"),
);
const browserRecordingSurfaceTabIdAtom = Atom.make<string | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("preview:browser-recording-surface-tab"),
);

export function useActiveBrowserRecordingTabId(): string | null {
return useAtomValue(activeBrowserRecordingTabIdAtom);
}

/**
* The tab whose guest must remain paintable for Chromium screencast frames.
* This becomes active one paint before the public recording state so a
* background webview is visible to Chromium before Page.startScreencast.
*/
export function useBrowserRecordingSurfaceTabId(): string | null {
return useAtomValue(browserRecordingSurfaceTabIdAtom);
}

let active: ActiveRecording | null = null;
let unsubscribeFrames: (() => void) | null = null;

Expand Down Expand Up @@ -123,12 +136,31 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => {
await stopped;
};

const waitForBrowserRecordingSurfacePaint = (): Promise<void> =>
new Promise((resolve) => {
let settled = false;
let secondFrameId: number | null = null;
const finish = () => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
window.cancelAnimationFrame(firstFrameId);
if (secondFrameId !== null) window.cancelAnimationFrame(secondFrameId);
resolve();
};
const timeoutId = window.setTimeout(finish, 100);
const firstFrameId = window.requestAnimationFrame(() => {
secondFrameId = window.requestAnimationFrame(finish);
});
});

const clearActiveRecording = (recording: ActiveRecording): void => {
if (active !== recording) return;
active = null;
unsubscribeFrames?.();
unsubscribeFrames = null;
appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null);
appAtomRegistry.set(browserRecordingSurfaceTabIdAtom, null);
};

export async function startBrowserRecording(tabId: string): Promise<string> {
Expand Down Expand Up @@ -196,6 +228,8 @@ export async function startBrowserRecording(tabId: string): Promise<string> {
cause,
});
}
appAtomRegistry.set(browserRecordingSurfaceTabIdAtom, tabId);
await waitForBrowserRecordingSurfacePaint();
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
try {
await bridge.recording.startScreencast(tabId);
} catch (cause) {
Expand Down
48 changes: 44 additions & 4 deletions apps/web/src/browser/browserSurfaceStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { describe, expect, it } from "vite-plus/test";
import { beforeEach, describe, expect, it } from "vite-plus/test";

import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
import {
acquireBrowserSurface,
resolveBrowserSurfacePanelRect,
useBrowserSurfaceStore,
} from "./browserSurfaceStore";

describe("browserSurfaceStore", () => {
beforeEach(() => {
useBrowserSurfaceStore.setState({ byTabId: {} });
});

it("tracks content dimensions for a browser that has never been visible", () => {
const tabId = "hidden-browser-surface-content-test";
useBrowserSurfaceStore.getState().presentContent(tabId, {
Expand All @@ -28,11 +36,43 @@ describe("browserSurfaceStore", () => {
expect(
resolveBrowserSurfacePanelRect(
{
hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 },
active: { rect: liveRect, visible: true, content: null, updatedAt: 2 },
hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null },
active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null },
},
"hidden",
),
).toEqual(liveRect);
});

it("ignores updates and releases from a stale surface lease", () => {
const tabId = "leased-browser-surface";
const staleRect = { x: 0, y: 0, width: 500, height: 700 };
const liveRect = { x: 10, y: 20, width: 900, height: 640 };
const staleLease = acquireBrowserSurface(tabId);
staleLease.present(staleRect, true);

const liveLease = acquireBrowserSurface(tabId);
liveLease.present(liveRect, true);
staleLease.present(staleRect, true);
staleLease.release();

expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
rect: liveRect,
visible: true,
});
});

it("hides a surface when its current lease is released", () => {
const tabId = "released-browser-surface";
const lease = acquireBrowserSurface(tabId);
lease.present({ x: 10, y: 20, width: 900, height: 640 }, true);

lease.release();
lease.present({ x: 0, y: 0, width: 1, height: 1 }, true);

expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({
visible: false,
owner: null,
});
});
});
Loading
Loading