diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index f66e255c51b4f..c26d98b7e5cbc 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -51,6 +51,9 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); + context.page.viewportSize.mockReturnValue(null); + // A hidden `` measures 0x0 until it is shown. + if (desktopHiddenNext) Object.assign(context.cssViewport, { width: 0, height: 0 }); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -60,14 +63,28 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ }, })); -function makeSession() { +function makeSession(viewport: { width: number; height: number }) { + const events = new NodeEvents.EventEmitter(); return { - on: vi.fn(), + on: vi.fn((name: string, callback: (...args: unknown[]) => void) => events.on(name, callback)), + emit: (name: string, ...args: unknown[]) => events.emit(name, ...args), + emitAsync: (name: string, ...args: unknown[]) => + Promise.all(events.listeners(name).map((listener) => listener(...args))), detach: vi.fn(async () => {}), - send: vi.fn(async (method: string, _input?: unknown): Promise> => { + send: vi.fn(async (method: string, input?: unknown): Promise> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } }; if (method === "Page.captureScreenshot") return { data: "ZnJhbWU=" }; + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; + if ( + method === "Runtime.evaluate" && + typeof input === "object" && + input !== null && + "contextId" in input && + input.contextId === 7 + ) + return { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; @@ -77,7 +94,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const events = new NodeEvents.EventEmitter(); const sessions: ReturnType[] = []; let url = "about:blank"; - let viewport = { width: 1280, height: 800 }; + let viewport: ReturnType = { width: 1280, height: 800 }; + const cssViewport = { width: 1920, height: 1080 }; let closed = false; let contextClosed = false; const page = { @@ -93,7 +111,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { mainFrame: () => page, url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -128,6 +146,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const context = { page, sessions, + cssViewport, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), exposeBinding: vi.fn(async (_name: string, binding: ClipboardBinding) => { @@ -135,7 +154,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { }), addInitScript: vi.fn(async () => {}), newCDPSession: async () => { - const session = makeSession(); + const session = makeSession(cssViewport); sessions.push(session); return session; }, @@ -158,6 +177,7 @@ let clipboardBinding: ClipboardBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; +let desktopHiddenNext = false; /** Pages the fake desktop takes back or returns; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); @@ -266,6 +286,13 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); +/** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ +const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => + Stream.fromQueue(viewer.output).pipe( + Stream.filter((item) => item._tag === "viewport"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); beforeEach(() => { contexts.length = 0; @@ -274,6 +301,7 @@ beforeEach(() => { contextFailure = null; desktopTabs.clear(); desktopRendersNext = false; + desktopHiddenNext = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -1126,7 +1154,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); -it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => +it.live("a file picker survives replacement of a stalled viewer backlog", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -1141,13 +1169,19 @@ it.live("a file picker replaces a stalled viewer backlog instead of being droppe }); // The viewer stopped reading: its output is full when the picker opens. yield* Queue.clear(viewer.output); - stallViewer(viewer, { _tag: "viewport", width: 1, height: 1 }); + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); accept.resolve(".csv"); // The picker is offered within the microtasks that follow its accept attribute. yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - expect(yield* Queue.clear(viewer.output)).toEqual([ - expect.objectContaining({ _tag: "fileChooser", accept: ".csv" }), - ]); + const opened = yield* Queue.clear(viewer.output); + expect(opened).toEqual([expect.objectContaining({ _tag: "fileChooser", accept: ".csv" })]); + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + yield* viewer.input({ type: "resize", width: 900, height: 600 }); + const resized = yield* Queue.takeAll(viewer.output); + expect(resized).toHaveLength(2); + expect(resized).toEqual( + expect.arrayContaining([{ _tag: "viewport", width: 900, height: 600 }, opened[0]]), + ); }), ).pipe(Effect.provide(layer)), ); @@ -1177,6 +1211,79 @@ it.live("control updates replace a stalled viewer backlog in the order they happ ).pipe(Effect.provide(layer)), ); +it.live("a stalled viewer's backlog replacement keeps its other state and notifications", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + yield* Queue.clear(viewer.output); + const output = + viewer.output as unknown as Queue.Queue; + /** Fills the stalled backlog, sends input that replaces it, and returns what is left. */ + const replaceBacklog = (input: unknown) => + Effect.gen(function* () { + // Chromium sends no new frames until each dropped one is acked. + const acked = Promise.withResolvers(); + let unacked = 0; + const ack = Effect.sync(() => { + if (--unacked === 0) acked.resolve(); + }); + while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) + unacked++; + yield* viewer.input(input); + yield* Effect.promise(() => acked.promise); + return yield* Queue.takeAll(viewer.output); + }); + // One-time notifications cannot be rebuilt from tab state. + const notifications: Array = [ + { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, + { _tag: "clipboard", text: "copied" }, + { _tag: "popup", tabId: "popup-1" }, + { _tag: "fileChooserClosed", id: "chooser-1" }, + ]; + for (const item of notifications) Queue.offerUnsafe(output, item); + yield* viewer.input({ type: "takeControl" }); + const resized = yield* replaceBacklog({ type: "resize", width: 900, height: 600 }); + expect(resized).toHaveLength(2 + notifications.length); + expect(resized).toEqual( + expect.arrayContaining([ + { _tag: "viewport", width: 900, height: 600 }, + expect.objectContaining({ _tag: "control", controller: "you" }), + ]), + ); + expect(resized.filter((item) => notifications.includes(item))).toEqual(notifications); + yield* viewer.input({ type: "resize", width: 640, height: 480 }); + const released = yield* replaceBacklog({ type: "releaseControl" }); + expect(released).toHaveLength(2); + expect(released).toEqual( + expect.arrayContaining([ + expect.objectContaining({ _tag: "control", controller: "agent" }), + { _tag: "viewport", width: 640, height: 480 }, + ]), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a closing tab still ends a stalled viewer whose backlog is all notifications", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* Queue.clear(viewer.output); + stallViewer(viewer, { + _tag: "download", + id: "download-1", + fileName: "report.csv", + sizeBytes: 3, + }); + contexts[0]!.page.emit("close"); + expect(yield* Queue.takeAll(viewer.output)).toContainEqual({ _tag: "gone" }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("an agent's tabs stop at the limit until an unwatched idle tab closes", () => Effect.scoped( Effect.gen(function* () { @@ -1297,6 +1404,12 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => // No headless context was launched for it. expect(contexts).toEqual([]); const page = desktopConnections[0]!.context.page; + expect(page.viewportSize()).toBeNull(); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); // The desktop panel sizes its page, so a viewer resize leaves it alone. yield* viewer.input({ type: "takeControl" }); yield* viewer.input({ type: "resize", width: 390, height: 844 }); @@ -1319,6 +1432,252 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => ).pipe(Effect.provide(layer)), ); +it.live("desktop viewers follow the page's native size and zoom", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const operator = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const observer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + yield* operator.input({ type: "takeControl" }); + // The desktop panel sizes its page, even for a fixed setting. + const setting = { _tag: "preset", presetId: "desktop-1920x1080", width: 1920, height: 1080 }; + yield* operator.input({ type: "viewport", setting }); + for (const viewer of [operator, observer]) yield* nextViewerViewport(viewer); + // The measured size already includes the desktop's native zoom. + yield* manager.adjust({ + threadId: scope.thread.threadId, + tabId: opened.tabId, + zoomFactor: 2, + }); + Object.assign(context.cssViewport, { width: 1000, height: 600 }); + cdp.emit("Page.frameResized"); + for (const viewer of [operator, observer]) { + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1000, + height: 600, + }); + } + expect(context.page.setViewportSize).not.toHaveBeenCalled(); + expect(cdp.send.mock.calls.some(([method]) => method.startsWith("Emulation."))).toBe(false); + expect( + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }), + ).toMatchObject({ viewport: { width: 1000, height: 600 } }); + // Viewers map clicks with that size, so the page gets CSS coordinates as sent. + yield* operator.input({ type: "mouse", action: "down", x: 750, y: 450, button: "left" }); + expect(context.sessions[1]!.send).toHaveBeenCalledWith( + "Input.dispatchMouseEvent", + expect.objectContaining({ type: "mousePressed", x: 750, y: 450 }), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop dialog reuses the last size and remeasures when it closes", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const status = broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const dialog = { + type: () => "confirm", + message: () => "Continue?", + defaultValue: () => "", + accept: vi.fn(async () => {}), + dismiss: vi.fn(async () => {}), + }; + // An open dialog blocks page script, so status and a new viewer use the last size. + context.page.emit("dialog", dialog); + cdp.send.mockClear(); + expect(yield* status).toMatchObject({ + viewport: { width: 1920, height: 1080 }, + dialog: { type: "confirm", message: "Continue?" }, + }); + const reconnected = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + expect(yield* nextViewerViewport(reconnected)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); + expect(cdp.send).not.toHaveBeenCalled(); + yield* Queue.clear(viewer.output); + Object.assign(context.cssViewport, { width: 1440, height: 900 }); + yield* viewer.input({ type: "dialog", accept: true }); + expect(dialog.accept).toHaveBeenCalledOnce(); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1440, + height: 900, + }); + // The host can also close its own dialog, outside viewer input. + context.page.emit("dialog", dialog); + Object.assign(context.cssViewport, { width: 960, height: 600 }); + cdp.emit("Page.javascriptDialogClosed"); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 960, + height: 600, + }); + expect(yield* status).toMatchObject({ dialog: null, viewport: { width: 960, height: 600 } }); + // An agent can answer it too, and the status it reads next must not hide the new size. + yield* viewer.input({ type: "releaseControl" }); + context.page.emit("dialog", dialog); + Object.assign(context.cssViewport, { width: 1280, height: 720 }); + yield* broker.invoke({ + scope, + operation: "dialog", + input: { accept: true }, + tabId: PreviewTabId.make(opened.tabId), + }); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1280, + height: 720, + }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop size read that is late, empty or fails keeps the newest size", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const send = cdp.send.getMockImplementation()!; + // Each queued answer replaces one page evaluation; the rest measure cssViewport. + const evaluations: Array<() => Promise>> = []; + cdp.send.mockImplementation((method, input) => + method === "Runtime.evaluate" && evaluations.length > 0 + ? evaluations.shift()!() + : send(method, input), + ); + const late = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); + yield* nextViewerViewport(viewer); + // An older read stalls until after a newer read finishes. + evaluations.push(() => late.promise); + const stale = cdp.emitAsync("Page.frameResized"); + Object.assign(context.cssViewport, { width: 1500, height: 900 }); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + late.resolve({ result: { value: { width: 100, height: 100 } } }); + yield* Effect.promise(() => stale); + // A hidden webview measures 0x0. + Object.assign(context.cssViewport, { width: 0, height: 0 }); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => + item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], + ); + expect(new Set(sizes)).toEqual(new Set(["1500x900"])); + // A navigation can destroy the isolated world mid-read. + evaluations.push(() => Promise.reject(new Error("Cannot find context with specified id"))); + expect( + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }), + ).toMatchObject({ viewport: { width: 1500, height: 900 } }); + expect(evaluations).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a late first desktop size still reaches viewers when a newer read fails", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + desktopHiddenNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + // The viewer attached while hidden, so no size is known yet. + while (!cdp.send.mock.calls.some(([method]) => method === "Runtime.evaluate")) + yield* Effect.yieldNow; + const send = cdp.send.getMockImplementation()!; + const late = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); + // Once shown, an older read stalls and a navigation fails the newer one. + const evaluations = [ + () => late.promise, + () => Promise.reject(new Error("Cannot find context with specified id")), + ]; + cdp.send.mockImplementation((method, input) => + method === "Runtime.evaluate" && evaluations.length > 0 + ? evaluations.shift()!() + : send(method, input), + ); + const stale = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + late.resolve({ result: { value: { width: 1500, height: 900 } } }); + yield* Effect.promise(() => stale); + const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => + item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], + ); + expect(sizes).toEqual(["1500x900"]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a queued reconnect survives later viewer state on a stalled viewer", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + yield* nextViewerViewport(viewer); + const cdp = desktopConnections[0]!.context.sessions[0]!; + while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + // One free slot: the reconnect lands as the last queued item. + yield* Queue.take(viewer.output); + const capacity = (yield* Queue.size(viewer.output)) + 1; + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while ((yield* Queue.size(viewer.output)) < capacity) yield* Effect.yieldNow; + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + expect((yield* Queue.takeAll(viewer.output)).at(-1)).toEqual({ _tag: "reconnect" }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a desktop page the desktop takes back reconnects instead of closing", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 072a022f90c08..2e9f866f7128e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -320,6 +320,10 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; + /** Last CSS size measured on the desktop, usable while a dialog blocks page script. */ + desktopViewport: { readonly width: number; readonly height: number } | null; + /** Counts desktop size reads, so a slower older read cannot overwrite a newer one. */ + desktopViewportReads: number; loading: boolean; closing: boolean; recording: Recording | null; @@ -550,6 +554,7 @@ const make = Effect.gen(function* () { if (tab.dialog === dialog) tab.dialog = null; ServerBrowserPage.invalidateRefs(tab.page); broadcastControl(tab); + if (tab.desktop) await broadcastViewport(tab).catch(constVoid); }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { @@ -606,10 +611,42 @@ const make = Effect.gen(function* () { ); }; - /** The CSS size the page lays out in: the viewport shrunk by the tab's zoom, as Chrome zooms. */ - const layoutSize = (tab: ServerTab) => { + /** The CSS size the page lays out in: measured on a desktop, else the viewport shrunk by zoom. */ + const layoutSize = async (tab: ServerTab) => { + if (tab.desktop) { + if (tab.dialog) return tab.desktopViewport; + // CDP-connected pages have no Playwright viewport. Read the browser's CSS + // size, including scrollbars and native zoom, without page script overrides. + const read = ++tab.desktopViewportReads; + let value: unknown; + try { + const { frameTree } = await tab.cdp.send("Page.getFrameTree"); + const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-viewport", + }); + const { result } = await tab.cdp.send("Runtime.evaluate", { + expression: "({ width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + value = result.value; + } catch { + // A navigation can destroy the isolated world mid-read; keep the last size. + return tab.desktopViewport; + } + const size = asRecord(value); + // A late read only fills in a size while no newer read has stored one. + if (read !== tab.desktopViewportReads && tab.desktopViewport) return tab.desktopViewport; + if (!size || typeof size.width !== "number" || typeof size.height !== "number") + return tab.desktopViewport; + // A hidden webview measures 0x0; its last visible size still maps input. + if (size.width <= 0 || size.height <= 0) return tab.desktopViewport; + tab.desktopViewport = { width: size.width, height: size.height }; + return tab.desktopViewport; + } const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; + if (!size) return size; return { width: Math.max(1, Math.round(size.width / tab.zoomFactor)), height: Math.max(1, Math.round(size.height / tab.zoomFactor)), @@ -630,7 +667,7 @@ const make = Effect.gen(function* () { await tab.page.setViewportSize(size); return; } - const layout = layoutSize(tab)!; + const layout = (await layoutSize(tab))!; await tab.cdp.send("Emulation.setDeviceMetricsOverride", { width: layout.width, height: layout.height, @@ -639,8 +676,8 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); + const broadcastViewport = async (tab: ServerTab) => { + const size = await layoutSize(tab); if (!size) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -658,7 +695,7 @@ const make = Effect.gen(function* () { UNATTACHED_FILL_VIEWPORT; await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); }; /** Applies a tab's published appearance and, for headless tabs, zoom. */ @@ -672,7 +709,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -793,6 +830,8 @@ const make = Effect.gen(function* () { setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, + desktopViewport: null, + desktopViewportReads: 0, loading: false, closing: false, recording: null, @@ -807,6 +846,19 @@ const make = Effect.gen(function* () { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); + if (desktop) { + cdp.on("Page.frameResized", () => { + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + }); + cdp.on("Page.javascriptDialogClosed", () => { + if (!tab.dialog) return; + tab.dialog = null; + ServerBrowserPage.invalidateRefs(page); + broadcastControl(tab); + if (tab.viewers.size > 0) void broadcastViewport(tab).catch(constVoid); + }); + await cdp.send("Page.enable"); + } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); const navigationGenerations = new WeakMap(); @@ -1304,7 +1356,7 @@ const make = Effect.gen(function* () { }; } const url = tab.page.url(); - const viewport = tab.page.viewportSize(); + const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const status = { available: true, visible: tab.viewers.size > 0, @@ -2109,7 +2161,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); + await broadcastViewport(tab); return; } case "viewport": { @@ -2181,6 +2233,8 @@ const make = Effect.gen(function* () { const recentFrames: Array = []; let screencastParams = Promise.resolve(); let screencastScale = 1; + // The stream closes at `gone` or `reconnect`; nothing queued after it is read. + let ended = false; const startScreencast = (scale: number) => { screencastScale = scale; screencastParams = screencastParams.then(async () => { @@ -2205,18 +2259,35 @@ const make = Effect.gen(function* () { pressedButtons: new Map(), push: (next) => { // Dropped frames must still release Chromium. + if (ended) { + if (next._tag === "frame") runFork(next.ack); + return; + } + if (next._tag === "gone" || next._tag === "reconnect") ended = true; if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + else if (["gone", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; Queue.offerUnsafe(output, next); + for (const tag of ["control", "viewport"] as const) { + if (next._tag === tag) continue; + const retained = dropped.value.findLast((item) => item._tag === tag); + if (retained) Queue.offerUnsafe(output, retained); + } // The controller's open picker may have been in the dropped backlog. - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; + const chooser = + next._tag === "control" || next._tag === "viewport" ? fileChooserMessage(tab) : null; if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); - for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); + // One-time notifications cannot be rebuilt from state; keep them in order. + // A former controller may still cache an older picker, so closes stay too. + for (const item of dropped.value) { + if (["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag)) + Queue.offerUnsafe(output, item); + else if (item._tag === "frame") runFork(item.ack); + } } }, pause: () => { @@ -2312,7 +2383,7 @@ const make = Effect.gen(function* () { ), }); }); - broadcastViewport(tab); + void broadcastViewport(tab).catch(constVoid); yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it.