| author | |
| committer | |
| log | ff1761012af46ceb5f1ecc9b2be00aae288a1691 |
| tree | a1a0c899a9e22f4a9a81a3776f3d401e705bdbc9 |
| parent | a40aef4ccbc10b7bcd7bc817151db8d5a9b1c257 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 113 insertions(+), 31 deletions(-)
apps/web/src/browser/ElectronBrowserHost.tsx+4-1| ... | @@ -29,6 +29,8 @@ export function ElectronBrowserHost() { | ... | @@ -29,6 +29,8 @@ export function ElectronBrowserHost() { |
| 29 | previewState.serverEpoch, | 29 | previewState.serverEpoch, |
| 30 | snapshot.tabId, | 30 | snapshot.tabId, |
| 31 | ), | 31 | ), |
| 32 | pictureInPicture: | ||
| 33 | previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, | ||
| 32 | zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, | 34 | zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, |
| 33 | })) | 35 | })) |
| 34 | : []; | 36 | : []; |
| ... | @@ -80,7 +82,7 @@ export function ElectronBrowserHost() { | ... | @@ -80,7 +82,7 @@ export function ElectronBrowserHost() { |
| 80 | if (!isElectron) return null; | 82 | if (!isElectron) return null; |
| 81 | return ( | 83 | return ( |
| 82 | <div className="contents" data-electron-browser-host> | 84 | <div className="contents" data-electron-browser-host> |
| 83 | {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => { | 85 | {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { |
| 84 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; | 86 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; |
| 85 | return ( | 87 | return ( |
| 86 | <HostedBrowserWebview | 88 | <HostedBrowserWebview |
| ... | @@ -90,6 +92,7 @@ export function ElectronBrowserHost() { | ... | @@ -90,6 +92,7 @@ export function ElectronBrowserHost() { |
| 90 | runtimeTabId={runtimeTabId} | 92 | runtimeTabId={runtimeTabId} |
| 91 | initialUrl={url} | 93 | initialUrl={url} |
| 92 | viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} | 94 | viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} |
| 95 | pictureInPicture={pictureInPicture} | ||
| 93 | zoomFactor={zoomFactor} | 96 | zoomFactor={zoomFactor} |
| 94 | /> | 97 | /> |
| 95 | ); | 98 | ); |
apps/web/src/browser/HostedBrowserWebview.tsx+11-1| ... | @@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | ... | @@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | ||
| 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | import { useActiveBrowserRecordingTabIds } from "./browserRecording"; | ||
| 12 | import { | 13 | import { |
| 13 | browserViewportSettingKey, | 14 | browserViewportSettingKey, |
| 14 | resolveBrowserViewportLayout, | 15 | resolveBrowserViewportLayout, |
| ... | @@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: { | ... | @@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: { |
| 47 | readonly runtimeTabId: string; | 48 | readonly runtimeTabId: string; |
| 48 | readonly initialUrl: string | null; | 49 | readonly initialUrl: string | null; |
| 49 | readonly viewport: PreviewViewportSetting; | 50 | readonly viewport: PreviewViewportSetting; |
| 51 | readonly pictureInPicture: boolean; | ||
| 50 | readonly zoomFactor: number; | 52 | readonly zoomFactor: number; |
| 51 | }) { | 53 | }) { |
| 52 | const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props; | 54 | const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = |
| 55 | props; | ||
| 53 | const config = usePreviewWebviewConfig(threadRef.environmentId); | 56 | const config = usePreviewWebviewConfig(threadRef.environmentId); |
| 54 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); | 57 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); |
| 55 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | 58 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| ... | @@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: { | ... | @@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: { |
| 70 | }; | 73 | }; |
| 71 | }), | 74 | }), |
| 72 | ); | 75 | ); |
| 76 | const backgroundActivity = useBrowserSurfaceStore( | ||
| 77 | (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0, | ||
| 78 | ); | ||
| 79 | const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); | ||
| 73 | usePreviewBridge({ threadRef, tabId, runtimeTabId }); | 80 | usePreviewBridge({ threadRef, tabId, runtimeTabId }); |
| 74 | 81 | ||
| 75 | useEffect(() => { | 82 | useEffect(() => { |
| ... | @@ -231,8 +238,10 @@ export function HostedBrowserWebview(props: { | ... | @@ -231,8 +238,10 @@ export function HostedBrowserWebview(props: { |
| 231 | 238 | ||
| 232 | if (!config) return null; | 239 | if (!config) return null; |
| 233 | 240 | ||
| 241 | const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; | ||
| 234 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ | 242 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 235 | active, | 243 | active, |
| 244 | renderingActive, | ||
| 236 | cornerRadius: presentation.cornerRadius, | 245 | cornerRadius: presentation.cornerRadius, |
| 237 | rect: lastRect, | 246 | rect: lastRect, |
| 238 | hiddenSize, | 247 | hiddenSize, |
| ... | @@ -244,6 +253,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -244,6 +253,7 @@ export function HostedBrowserWebview(props: { |
| 244 | className="fixed overflow-hidden bg-muted/35" | 253 | className="fixed overflow-hidden bg-muted/35" |
| 245 | style={{ ...wrapperStyle, overscrollBehavior: "contain" }} | 254 | style={{ ...wrapperStyle, overscrollBehavior: "contain" }} |
| 246 | onScroll={syncContentPresentation} | 255 | onScroll={syncContentPresentation} |
| 256 | data-preview-rendering={renderingActive ? "active" : "suspended"} | ||
| 247 | data-preview-viewport={runtimeTabId} | 257 | data-preview-viewport={runtimeTabId} |
| 248 | > | 258 | > |
| 249 | <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}> | 259 | <div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}> |
apps/web/src/browser/browserRecording.test.ts+4-4| ... | @@ -179,10 +179,10 @@ describe("browser recording", () => { | ... | @@ -179,10 +179,10 @@ describe("browser recording", () => { |
| 179 | 179 | ||
| 180 | it("starts recording for a visible tab", async () => { | 180 | it("starts recording for a visible tab", async () => { |
| 181 | await startBrowserRecording("recording-tab"); | 181 | await startBrowserRecording("recording-tab"); |
| 182 | 182 | const startupEvents = [...events]; | |
| 183 | expect(events).toEqual(["start-screencast", "publish:recording-tab"]); | ||
| 184 | 183 | ||
| 185 | await stopBrowserRecording("recording-tab"); | 184 | await stopBrowserRecording("recording-tab"); |
| 185 | expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); | ||
| 186 | }); | 186 | }); |
| 187 | 187 | ||
| 188 | it("records a hidden tab without requiring it to become visible", async () => { | 188 | it("records a hidden tab without requiring it to become visible", async () => { |
| ... | @@ -195,11 +195,11 @@ describe("browser recording", () => { | ... | @@ -195,11 +195,11 @@ describe("browser recording", () => { |
| 195 | }; | 195 | }; |
| 196 | 196 | ||
| 197 | await startBrowserRecording("recording-tab"); | 197 | await startBrowserRecording("recording-tab"); |
| 198 | const startupEvents = [...events]; | ||
| 198 | 199 | ||
| 199 | expect(startScreencast).toHaveBeenCalledWith("recording-tab"); | 200 | expect(startScreencast).toHaveBeenCalledWith("recording-tab"); |
| 200 | expect(events).toEqual(["start-screencast", "publish:recording-tab"]); | ||
| 201 | |||
| 202 | await stopBrowserRecording("recording-tab"); | 201 | await stopBrowserRecording("recording-tab"); |
| 202 | expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); | ||
| 203 | }); | 203 | }); |
| 204 | 204 | ||
| 205 | it("fails startup instead of locking a fallback size when no frame arrives", async () => { | 205 | it("fails startup instead of locking a fallback size when no frame arrives", async () => { |
apps/web/src/browser/browserRecording.ts+8-6| ... | @@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { | ... | @@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { |
| 122 | const activeRecordings = new Map<string, ActiveRecording>(); | 122 | const activeRecordings = new Map<string, ActiveRecording>(); |
| 123 | let unsubscribeFrames: (() => void) | null = null; | 123 | let unsubscribeFrames: (() => void) | null = null; |
| 124 | 124 | ||
| 125 | const publishActiveRecordingTabIds = (): void => { | ||
| 126 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | ||
| 127 | tabIds: new Set(activeRecordings.keys()), | ||
| 128 | }); | ||
| 129 | }; | ||
| 130 | |||
| 125 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; | 131 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; |
| 126 | export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; | 132 | export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; |
| 127 | 133 | ||
| ... | @@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => { | ... | @@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => { |
| 228 | unsubscribeFrames?.(); | 234 | unsubscribeFrames?.(); |
| 229 | unsubscribeFrames = null; | 235 | unsubscribeFrames = null; |
| 230 | } | 236 | } |
| 231 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | 237 | publishActiveRecordingTabIds(); |
| 232 | tabIds: new Set(activeRecordings.keys()), | ||
| 233 | }); | ||
| 234 | }; | 238 | }; |
| 235 | 239 | ||
| 236 | const cleanupFailedRecordingStart = async ( | 240 | const cleanupFailedRecordingStart = async ( |
| ... | @@ -377,6 +381,7 @@ export async function startBrowserRecording( | ... | @@ -377,6 +381,7 @@ export async function startBrowserRecording( |
| 377 | lifecycle: { phase: "starting" }, | 381 | lifecycle: { phase: "starting" }, |
| 378 | }; | 382 | }; |
| 379 | activeRecordings.set(tabId, recording); | 383 | activeRecordings.set(tabId, recording); |
| 384 | publishActiveRecordingTabIds(); | ||
| 380 | try { | 385 | try { |
| 381 | try { | 386 | try { |
| 382 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | 387 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); |
| ... | @@ -487,9 +492,6 @@ export async function startBrowserRecording( | ... | @@ -487,9 +492,6 @@ export async function startBrowserRecording( |
| 487 | if (recording.lifecycle.phase === "starting") { | 492 | if (recording.lifecycle.phase === "starting") { |
| 488 | recording.lifecycle = { phase: "recording" }; | 493 | recording.lifecycle = { phase: "recording" }; |
| 489 | } | 494 | } |
| 490 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | ||
| 491 | tabIds: new Set(activeRecordings.keys()), | ||
| 492 | }); | ||
| 493 | return startedAt; | 495 | return startedAt; |
| 494 | } finally { | 496 | } finally { |
| 495 | settleStartup?.(); | 497 | settleStartup?.(); |
apps/web/src/browser/browserSurfaceStore.test.ts+13-1| ... | @@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; | ... | @@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | acquireBrowserSurface, | 4 | acquireBrowserSurface, |
| 5 | acquireBrowserSurfaceActivity, | ||
| 5 | resolveBrowserSurfacePanelRect, | 6 | resolveBrowserSurfacePanelRect, |
| 6 | useBrowserSurfaceStore, | 7 | useBrowserSurfaceStore, |
| 7 | } from "./browserSurfaceStore"; | 8 | } from "./browserSurfaceStore"; |
| 8 | 9 | ||
| 9 | describe("browserSurfaceStore", () => { | 10 | describe("browserSurfaceStore", () => { |
| 10 | beforeEach(() => { | 11 | beforeEach(() => { |
| 11 | useBrowserSurfaceStore.setState({ byTabId: {} }); | 12 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); |
| 13 | }); | ||
| 14 | |||
| 15 | it("keeps concurrent background work active until every lease is released", () => { | ||
| 16 | const first = acquireBrowserSurfaceActivity("background-browser"); | ||
| 17 | const second = acquireBrowserSurfaceActivity("background-browser"); | ||
| 18 | |||
| 19 | first(); | ||
| 20 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1); | ||
| 21 | |||
| 22 | second(); | ||
| 23 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined(); | ||
| 12 | }); | 24 | }); |
| 13 | 25 | ||
| 14 | it("freezes the source content dimensions for a fitted presentation", () => { | 26 | it("freezes the source content dimensions for a fitted presentation", () => { |
apps/web/src/browser/browserSurfaceStore.ts+26| ... | @@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation { | ... | @@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation { |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | interface BrowserSurfaceStoreState { | 31 | interface BrowserSurfaceStoreState { |
| 32 | readonly activityByTabId: Record<string, number>; | ||
| 32 | readonly byTabId: Record<string, BrowserSurfacePresentation>; | 33 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 34 | readonly acquireActivity: (tabId: string) => () => void; | ||
| 33 | readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; | 35 | readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; |
| 34 | readonly present: ( | 36 | readonly present: ( |
| 35 | tabId: string, | 37 | tabId: string, |
| ... | @@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): | ... | @@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): |
| 63 | left.height === right.height; | 65 | left.height === right.height; |
| 64 | 66 | ||
| 65 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ | 67 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ |
| 68 | activityByTabId: {}, | ||
| 66 | byTabId: {}, | 69 | byTabId: {}, |
| 70 | acquireActivity: (tabId) => { | ||
| 71 | let released = false; | ||
| 72 | set((state) => ({ | ||
| 73 | activityByTabId: { | ||
| 74 | ...state.activityByTabId, | ||
| 75 | [tabId]: (state.activityByTabId[tabId] ?? 0) + 1, | ||
| 76 | }, | ||
| 77 | })); | ||
| 78 | return () => { | ||
| 79 | if (released) return; | ||
| 80 | released = true; | ||
| 81 | set((state) => { | ||
| 82 | const count = state.activityByTabId[tabId] ?? 0; | ||
| 83 | const activityByTabId = { ...state.activityByTabId }; | ||
| 84 | if (count <= 1) delete activityByTabId[tabId]; | ||
| 85 | else activityByTabId[tabId] = count - 1; | ||
| 86 | return { activityByTabId }; | ||
| 87 | }); | ||
| 88 | }; | ||
| 89 | }, | ||
| 67 | claim: (tabId, owner, fitSourceContent) => | 90 | claim: (tabId, owner, fitSourceContent) => |
| 68 | set((state) => { | 91 | set((state) => { |
| 69 | const current = state.byTabId[tabId]; | 92 | const current = state.byTabId[tabId]; |
| ... | @@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = | ... | @@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 171 | }), | 194 | }), |
| 172 | })); | 195 | })); |
| 173 | 196 | ||
| 197 | export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) => | ||
| 198 | useBrowserSurfaceStore.getState().acquireActivity(tabId); | ||
| 199 | |||
| 174 | export function acquireBrowserSurface( | 200 | export function acquireBrowserSurface( |
| 175 | tabId: string, | 201 | tabId: string, |
| 176 | fitSourceContent = false, | 202 | fitSourceContent = false, |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+23-1| ... | @@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 10 | expect( | 10 | expect( |
| 11 | resolveHostedBrowserWebviewWrapperStyle({ | 11 | resolveHostedBrowserWebviewWrapperStyle({ |
| 12 | active: true, | 12 | active: true, |
| 13 | renderingActive: true, | ||
| 13 | rect: { x: 12, y: 34, width: 800, height: 600 }, | 14 | rect: { x: 12, y: 34, width: 800, height: 600 }, |
| 14 | hiddenSize: { width: 1280, height: 800 }, | 15 | hiddenSize: { width: 1280, height: 800 }, |
| 15 | }), | 16 | }), |
| ... | @@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 27 | expect( | 28 | expect( |
| 28 | resolveHostedBrowserWebviewWrapperStyle({ | 29 | resolveHostedBrowserWebviewWrapperStyle({ |
| 29 | active: true, | 30 | active: true, |
| 31 | renderingActive: true, | ||
| 30 | cornerRadius: 12, | 32 | cornerRadius: 12, |
| 31 | rect: { x: 12, y: 34, width: 360, height: 203 }, | 33 | rect: { x: 12, y: 34, width: 360, height: 203 }, |
| 32 | hiddenSize: { width: 1280, height: 800 }, | 34 | hiddenSize: { width: 1280, height: 800 }, |
| ... | @@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 40 | }); | 42 | }); |
| 41 | }); | 43 | }); |
| 42 | 44 | ||
| 43 | it("keeps an inactive webview paintable while moving it offscreen", () => { | 45 | it("suspends painting for an inactive webview", () => { |
| 44 | const style = resolveHostedBrowserWebviewWrapperStyle({ | 46 | const style = resolveHostedBrowserWebviewWrapperStyle({ |
| 45 | active: false, | 47 | active: false, |
| 48 | renderingActive: false, | ||
| 46 | rect: { x: 12, y: 34, width: 800, height: 600 }, | 49 | rect: { x: 12, y: 34, width: 800, height: 600 }, |
| 47 | hiddenSize: { width: 393, height: 852 }, | 50 | hiddenSize: { width: 393, height: 852 }, |
| 48 | }); | 51 | }); |
| ... | @@ -54,6 +57,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -54,6 +57,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 54 | height: 852, | 57 | height: 852, |
| 55 | zIndex: -1, | 58 | zIndex: -1, |
| 56 | pointerEvents: "none", | 59 | pointerEvents: "none", |
| 60 | visibility: "hidden", | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("keeps an active background task paintable offscreen", () => { | ||
| 65 | const style = resolveHostedBrowserWebviewWrapperStyle({ | ||
| 66 | active: false, | ||
| 67 | renderingActive: true, | ||
| 68 | rect: null, | ||
| 69 | hiddenSize: { width: 1280, height: 800 }, | ||
| 70 | }); | ||
| 71 | |||
| 72 | expect(style).toEqual({ | ||
| 73 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, | ||
| 74 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, | ||
| 75 | width: 1280, | ||
| 76 | height: 800, | ||
| 77 | zIndex: -1, | ||
| 78 | pointerEvents: "none", | ||
| 57 | visibility: "visible", | 79 | visibility: "visible", |
| 58 | }); | 80 | }); |
| 59 | }); | 81 | }); |
apps/web/src/browser/hostedBrowserWebviewStyle.ts+4-6| ... | @@ -13,18 +13,19 @@ export interface HostedBrowserWebviewWrapperStyle { | ... | @@ -13,18 +13,19 @@ export interface HostedBrowserWebviewWrapperStyle { |
| 13 | readonly zIndex: number; | 13 | readonly zIndex: number; |
| 14 | readonly pointerEvents: "auto" | "none"; | 14 | readonly pointerEvents: "auto" | "none"; |
| 15 | readonly borderRadius?: number; | 15 | readonly borderRadius?: number; |
| 16 | readonly visibility?: "visible"; | 16 | readonly visibility?: "hidden" | "visible"; |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; | 19 | export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; |
| 20 | 20 | ||
| 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { | 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 22 | readonly active: boolean; | 22 | readonly active: boolean; |
| 23 | readonly renderingActive: boolean; | ||
| 23 | readonly cornerRadius?: number; | 24 | readonly cornerRadius?: number; |
| 24 | readonly rect: BrowserSurfaceRect | null; | 25 | readonly rect: BrowserSurfaceRect | null; |
| 25 | readonly hiddenSize: HostedBrowserWebviewSize; | 26 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 26 | }): HostedBrowserWebviewWrapperStyle { | 27 | }): HostedBrowserWebviewWrapperStyle { |
| 27 | const { active, cornerRadius = 0, hiddenSize, rect } = input; | 28 | const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; |
| 28 | if (active && rect) { | 29 | if (active && rect) { |
| 29 | return { | 30 | return { |
| 30 | left: rect.x, | 31 | left: rect.x, |
| ... | @@ -44,9 +45,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { | ... | @@ -44,9 +45,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 44 | height: hiddenSize.height, | 45 | height: hiddenSize.height, |
| 45 | zIndex: -1, | 46 | zIndex: -1, |
| 46 | pointerEvents: "none", | 47 | pointerEvents: "none", |
| 47 | // Keep the guest CSS-visible even while physically offscreen. Electron | 48 | visibility: renderingActive ? "visible" : "hidden", |
| 48 | // webviews can keep metadata/status alive under `visibility:hidden` while | ||
| 49 | // CDP Runtime/Input commands stall, which breaks offscreen automation. | ||
| 50 | visibility: "visible", | ||
| 51 | }; | 49 | }; |
| 52 | } | 50 | } |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+20-11| ... | @@ -37,7 +37,10 @@ import { | ... | @@ -37,7 +37,10 @@ import { |
| 37 | stopBrowserRecording, | 37 | stopBrowserRecording, |
| 38 | } from "~/browser/browserRecording"; | 38 | } from "~/browser/browserRecording"; |
| 39 | import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; | 39 | import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; |
| 40 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | 40 | import { |
| 41 | acquireBrowserSurfaceActivity, | ||
| 42 | useBrowserSurfaceStore, | ||
| 43 | } from "~/browser/browserSurfaceStore"; | ||
| 41 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; | 44 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; |
| 42 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; | 45 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; |
| 43 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | 46 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| ... | @@ -98,7 +101,7 @@ const waitForDesktopOverlay = async ( | ... | @@ -98,7 +101,7 @@ const waitForDesktopOverlay = async ( |
| 98 | operation, | 101 | operation, |
| 99 | requestId, | 102 | requestId, |
| 100 | }); | 103 | }); |
| 101 | if (state.desktopByTabId[tabId] && previewBridge) { | 104 | if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) { |
| 102 | const status = await previewBridge.automation.status(runtimeTabId); | 105 | const status = await previewBridge.automation.status(runtimeTabId); |
| 103 | if (status.available) return; | 106 | if (status.available) return; |
| 104 | } | 107 | } |
| ... | @@ -121,6 +124,11 @@ const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => | ... | @@ -121,6 +124,11 @@ const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => |
| 121 | (candidate) => candidate.getAttribute("data-preview-tab") === tabId, | 124 | (candidate) => candidate.getAttribute("data-preview-tab") === tabId, |
| 122 | ) ?? null; | 125 | ) ?? null; |
| 123 | 126 | ||
| 127 | const isPreviewWebviewRendering = (runtimeTabId: string): boolean => { | ||
| 128 | const wrapper = findPreviewWebview(runtimeTabId)?.closest<HTMLElement>("[data-preview-viewport]"); | ||
| 129 | return wrapper?.getAttribute("data-preview-rendering") === "active"; | ||
| 130 | }; | ||
| 131 | |||
| 124 | const readWebviewViewport = async ( | 132 | const readWebviewViewport = async ( |
| 125 | webview: ExecutablePreviewWebview, | 133 | webview: ExecutablePreviewWebview, |
| 126 | ): Promise<PreviewRenderedViewportSize | null> => { | 134 | ): Promise<PreviewRenderedViewportSize | null> => { |
| ... | @@ -212,8 +220,12 @@ const currentStatus = async ( | ... | @@ -212,8 +220,12 @@ const currentStatus = async ( |
| 212 | const visible = runtimeTabId | 220 | const visible = runtimeTabId |
| 213 | ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false) | 221 | ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false) |
| 214 | : false; | 222 | : false; |
| 223 | const renderingActive = runtimeTabId ? isPreviewWebviewRendering(runtimeTabId) : false; | ||
| 215 | const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; | 224 | const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; |
| 216 | const viewport = runtimeTabId ? await readRenderedViewport(runtimeTabId).catch(() => null) : null; | 225 | const viewport = |
| 226 | runtimeTabId && renderingActive | ||
| 227 | ? await readRenderedViewport(runtimeTabId).catch(() => null) | ||
| 228 | : null; | ||
| 217 | const viewportStatus = { | 229 | const viewportStatus = { |
| 218 | ...(viewportSetting === undefined ? {} : { viewportSetting }), | 230 | ...(viewportSetting === undefined ? {} : { viewportSetting }), |
| 219 | ...(viewport === null ? {} : { viewport }), | 231 | ...(viewport === null ? {} : { viewport }), |
| ... | @@ -307,6 +319,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -307,6 +319,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 307 | threadId: request.threadId, | 319 | threadId: request.threadId, |
| 308 | }; | 320 | }; |
| 309 | let tabId = request.tabId ?? null; | 321 | let tabId = request.tabId ?? null; |
| 322 | const browserActivity = { release: null as (() => void) | null }; | ||
| 310 | try { | 323 | try { |
| 311 | let state = readThreadPreviewState(threadRef); | 324 | let state = readThreadPreviewState(threadRef); |
| 312 | const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); | 325 | const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); |
| ... | @@ -340,6 +353,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -340,6 +353,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 340 | } | 353 | } |
| 341 | const readyState = readThreadPreviewState(threadRef); | 354 | const readyState = readThreadPreviewState(threadRef); |
| 342 | const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); | 355 | const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); |
| 356 | browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); | ||
| 343 | await waitForDesktopOverlay( | 357 | await waitForDesktopOverlay( |
| 344 | threadRef, | 358 | threadRef, |
| 345 | request.requestId, | 359 | request.requestId, |
| ... | @@ -440,14 +454,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -440,14 +454,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 440 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); | 454 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); |
| 441 | } | 455 | } |
| 442 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { | 456 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { |
| 443 | await waitForDesktopOverlay( | 457 | await requireReadyTab(); |
| 444 | threadRef, | ||
| 445 | request.requestId, | ||
| 446 | activeTabId, | ||
| 447 | activeRuntimeTabId, | ||
| 448 | request.operation, | ||
| 449 | request.timeoutMs, | ||
| 450 | ); | ||
| 451 | } | 458 | } |
| 452 | if (shouldPresentPreview) { | 459 | if (shouldPresentPreview) { |
| 453 | // React commits the thread-bound surface asynchronously. Settle | 460 | // React commits the thread-bound surface asynchronously. Settle |
| ... | @@ -678,6 +685,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -678,6 +685,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 678 | tabId, | 685 | tabId, |
| 679 | cause, | 686 | cause, |
| 680 | }); | 687 | }); |
| 688 | } finally { | ||
| 689 | browserActivity.release?.(); | ||
| 681 | } | 690 | } |
| 682 | }, | 691 | }, |
| 683 | [environmentId, listPreviews, open, registry, resize], | 692 | [environmentId, listPreviews, open, registry, resize], |