From 120e635df5918cf23f0019100b52fa9aff40d993 Mon Sep 17 00:00:00 2001 From: Max Nylander Date: Fri, 9 Oct 2026 22:12:23 +0200 Subject: [PATCH] fix(server): restore input for desktop browser streams Desktop-hosted browser tabs run in the desktop's own webview over CDP, where Playwright's viewportSize() is null. The server never sent a viewport event, so remote viewers could not map clicks, typing or scrolling to the page. Desktop tabs now measure their CSS size in an isolated CDP world and send it on attach, on native resize and in status. An open dialog uses the last size, and a late, empty or failed read keeps the newest one. Viewport updates now replace a stalled viewer's backlog, like control updates, and keep its queued control state, one-time notifications and terminal event. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 381 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 99 ++++- 2 files changed, 455 insertions(+), 25 deletions(-) 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 f3d9bea8b40f5..aa0395b419ca9 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -324,6 +324,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; @@ -554,6 +558,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) => { @@ -610,10 +615,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)), @@ -634,7 +671,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, @@ -643,8 +680,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 }); }; @@ -662,7 +699,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. */ @@ -676,7 +713,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -797,6 +834,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, @@ -811,6 +850,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(); @@ -1308,7 +1360,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, @@ -2113,7 +2165,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": { @@ -2185,6 +2237,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 () => { @@ -2209,18 +2263,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: () => { @@ -2316,7 +2387,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.