diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 861fc063a8..474a2beb9d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -50,6 +50,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); + context.page.viewportSize.mockReturnValue(null); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -69,11 +70,16 @@ function makeFrame() { return frame; } -function makeSession(frame?: ReturnType) { +function makeSession( + viewport: { width: number; height: number }, + frame?: ReturnType, +) { const events = new NodeEvents.EventEmitter(); return { - on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), - emit: (name: string) => events.emit(name), + 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> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; @@ -89,6 +95,17 @@ function makeSession(frame?: ReturnType) { if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { return { result: { value: [] } }; } + if ( + method === "Runtime.evaluate" && + typeof input === "object" && + input !== null && + "contextId" in input && + input.contextId === 7 && + "expression" in input && + typeof input.expression === "string" && + input.expression.includes("innerWidth") + ) + return { result: { value: { ...viewport } } }; if (method === "Page.addScriptToEvaluateOnNewDocument") frame?.recording.resolve(); if (method === "Input.dispatchKeyEvent" && (input as { commands?: unknown }).commands) { if (focusedFrame) focusedFrame.copy = keyCopy || focusedFrame.copy; @@ -111,7 +128,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const setChooserFiles = vi.fn(async (_files: unknown) => {}); 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 = { @@ -141,7 +159,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { frames: () => [page], url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -176,10 +194,12 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const context = { page, sessions, + cssViewport, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), newCDPSession: async (target: unknown) => { const session = makeSession( + cssViewport, target === page ? undefined : (target as ReturnType), ); sessions.push(session); @@ -270,7 +290,10 @@ const dependencies = Layer.mergeAll( isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => releasedDesktopTabs.push(key.tabId)), + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopDetaches.emit("released", key); + }), ), pointer: () => Effect.void, }), @@ -306,6 +329,37 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); +const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => + Effect.gen(function* () { + let item = yield* Queue.take(viewer.output); + while (item._tag !== "viewport") item = yield* Queue.take(viewer.output); + return item; + }); +const desktopReady = 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 tabId = PreviewTabId.make(opened.tabId); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const status = broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(yield* status).toMatchObject({ viewport: { width: 1920, height: 1080 } }); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); + return { browser, manager, broker, tabId, viewer, context, cdp, status }; +}); beforeEach(() => { contexts.length = 0; @@ -1335,7 +1389,7 @@ it.live("only the current controller can answer a file picker", () => ).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; @@ -1349,12 +1403,18 @@ it.live("a file picker replaces a stalled viewer backlog instead of being droppe setFiles: async () => {}, }); 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"); 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)), ); @@ -1383,6 +1443,82 @@ 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; + 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 }); + yield* viewer.input({ type: "resize", width: 800, height: 500 }); + const released = yield* replaceBacklog({ type: "releaseControl" }); + expect(released).toHaveLength(2); + expect(released).toEqual( + expect.arrayContaining([ + expect.objectContaining({ _tag: "control", controller: "agent" }), + { _tag: "viewport", width: 800, height: 500 }, + ]), + ); + yield* viewer.input({ type: "takeControl" }); + yield* Queue.clear(viewer.output); + let copied = 0; + while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copied}` })) copied++; + yield* viewer.input({ type: "resize", width: 900, height: 600 }); + const kept = yield* Queue.takeAll(viewer.output); + expect(kept).toContainEqual({ _tag: "viewport", width: 900, height: 600 }); + expect(kept.at(-1)).toEqual({ _tag: "clipboard", text: `copy ${copied - 1}` }); + expect(kept).toHaveLength(copied); + + for (const item of kept) Queue.offerUnsafe(output, item); + keyCopy = "newest copy"; + yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 }); + const updated = yield* Queue.takeAll(viewer.output); + expect(updated).toHaveLength(copied); + expect(updated.at(-1)).toEqual({ _tag: "clipboard", text: "newest copy" }); + expect(updated.slice(1, -1)).toEqual(kept.slice(2)); + + stallViewer(viewer, { _tag: "clipboard", text: "unread" }); + contexts[0]!.page.emit("close"); + expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _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* () { @@ -1499,6 +1635,13 @@ it.live("drives the desktop's own page for a tab the desktop renders", () => ]); 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 }); expect(page.setViewportSize).not.toHaveBeenCalled(); @@ -1518,6 +1661,222 @@ 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, + manager, + tabId, + viewer: operator, + context, + cdp, + status, + } = yield* desktopReady; + const observer = yield* browser.attachViewer(viewerInput(tabId, false)); + yield* nextViewerViewport(observer); + 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* Queue.clear(viewer.output); + // The measured size already includes the desktop's native zoom. + yield* manager.adjust({ + threadId: scope.thread.threadId, + 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* status).toMatchObject({ viewport: { width: 1000, height: 600 } }); + Object.assign(context.cssViewport, { width: 800, height: 500 }); + expect(yield* status).toMatchObject({ viewport: { width: 800, height: 500 } }); + // 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, broker, tabId, viewer, context, cdp, status } = yield* desktopReady; + 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(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, + }); + 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 { viewer, context, cdp, status } = yield* desktopReady; + 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({}))); + // An older read stalls until after a newer read finishes. + const started = Promise.withResolvers(); + evaluations.push(() => { + started.resolve(); + return late.promise; + }); + const stale = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => started.promise); + 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* status).toMatchObject({ viewport: { width: 1500, height: 900 } }); + expect(evaluations).toEqual([]); + + // A failed newer read must not invalidate an older successful measurement. + const recovered = Promise.withResolvers>(); + const recoveringStarted = Promise.withResolvers(); + yield* Effect.addFinalizer(() => Effect.sync(() => recovered.resolve({}))); + evaluations.push(() => { + recoveringStarted.resolve(); + return recovered.promise; + }); + const recovering = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => recoveringStarted.promise); + evaluations.push(() => Promise.reject(new Error("Context destroyed"))); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + recovered.resolve({ result: { value: { width: 1000, height: 600 } } }); + yield* Effect.promise(() => recovering); + expect(yield* status).toMatchObject({ viewport: { width: 1000, height: 600 } }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop size read that never answers gives up and keeps the last size", () => + Effect.scoped( + Effect.gen(function* () { + const { viewer, context, cdp, status } = yield* desktopReady; + const send = cdp.send.getMockImplementation()!; + yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); + for (const phase of ["Page.getFrameTree", "Page.createIsolatedWorld", "Runtime.evaluate"]) { + const stuck = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve({}))); + let stall = true; + cdp.send.mockImplementation((method, input) => { + if (method !== phase || !stall) return send(method, input); + stall = false; + return stuck.promise; + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const resized = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(1_000)); + vi.useRealTimers(); + yield* Effect.promise(() => resized); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); + cdp.send.mockClear(); + // A late reply must neither change the cache nor start the next CDP step. + stuck.resolve({ + frameTree: { frame: { id: "main" } }, + executionContextId: 7, + result: { value: { width: 100, height: 100 } }, + }); + yield* Effect.promise(() => stuck.promise); + expect(cdp.send).not.toHaveBeenCalled(); + } + context.page.emit("dialog", { + type: () => "alert", + message: () => "Wait", + defaultValue: () => "", + accept: vi.fn(async () => {}), + dismiss: vi.fn(async () => {}), + }); + expect(yield* status).toMatchObject({ viewport: { width: 1920, height: 1080 } }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("desktop file pickers stay native outside agent actions and remote control", () => Effect.scoped( Effect.gen(function* () { @@ -1598,11 +1957,17 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); // The server's listener subscribes in its own fiber; detach once it is there. while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + yield* Queue.clear(viewer.output); + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + const released = Promise.withResolvers(); + desktopDetaches.once("released", () => released.resolve()); desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - let end = yield* Queue.take(viewer.output); - while (end._tag !== "reconnect" && end._tag !== "gone") - end = yield* Queue.take(viewer.output); - expect(end._tag).toBe("reconnect"); + yield* Effect.promise(() => released.promise); + expect(yield* Queue.clear(viewer.output)).toContainEqual({ _tag: "reconnect" }); + yield* Effect.promise(() => + desktopConnections[0]!.context.sessions[0]!.emitAsync("Page.frameResized"), + ); + expect(yield* Queue.clear(viewer.output)).toEqual([]); expect(releasedDesktopTabs).toEqual([opened.tabId]); const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); expect(sessions.map((session) => session.tabId)).toContain(opened.tabId); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0f134133ac..7f289a74eb 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -98,6 +98,8 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +// A dialog can open mid-read and block page script. +const DESKTOP_VIEWPORT_READ_TIMEOUT_MS = 1_000; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -305,6 +307,9 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; + desktopViewport: { readonly width: number; readonly height: number } | null; + desktopViewportReads: number; + desktopViewportApplied: number; loading: boolean; closing: boolean; recording: Recording | null; @@ -538,9 +543,12 @@ const make = Effect.gen(function* () { if (!dialog) throw new Error("No dialog is pending."); if (input.accept) await dialog.accept(input.promptText); else await dialog.dismiss(); - if (tab.dialog === dialog) tab.dialog = null; + // The native desktop close event may have already handled this dialog. + if (tab.dialog !== dialog) return; + tab.dialog = null; ServerBrowserPage.invalidateRefs(tab.page); broadcastControl(tab); + if (tab.desktop) await broadcastViewport(tab).catch(constVoid); }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { @@ -596,9 +604,56 @@ const make = Effect.gen(function* () { ); }; + // CDP-attached desktop pages have no Playwright viewport. An isolated world + // measures CSS pixels including native zoom without page overrides. + const measureDesktopViewport = async (tab: ServerTab) => { + if (tab.dialog || tab.closing) return tab.desktopViewport; + const read = ++tab.desktopViewportReads; + let active = true; + const readSize = async () => { + const { frameTree } = await tab.cdp.send("Page.getFrameTree"); + if (!active || tab.closing) return null; + const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-viewport", + }); + if (!active || tab.closing) return null; + const { result } = await tab.cdp.send("Runtime.evaluate", { + expression: "({ width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + return asRecord(result.value); + }; + let timer: ReturnType | undefined; + const size = await Promise.race([ + // A navigation can destroy the isolated world mid-read. + readSize().catch(() => null), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), DESKTOP_VIEWPORT_READ_TIMEOUT_MS); + }), + ]); + active = false; + clearTimeout(timer); + if (tab.closing || read < tab.desktopViewportApplied) return tab.desktopViewport; + if (!size || typeof size.width !== "number" || typeof size.height !== "number") + return tab.desktopViewport; + if ( + !Number.isFinite(size.width) || + !Number.isFinite(size.height) || + size.width <= 0 || + size.height <= 0 + ) + return tab.desktopViewport; + // Only a successful newer measurement supersedes an older read. + tab.desktopViewportApplied = read; + tab.desktopViewport = { width: size.width, height: size.height }; + return tab.desktopViewport; + }; + const layoutSize = (tab: ServerTab) => { 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)), @@ -622,9 +677,9 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); - if (!size) return; + const broadcastViewport = async (tab: ServerTab) => { + const size = tab.desktop ? await measureDesktopViewport(tab) : layoutSize(tab); + if (!size || tab.closing) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -641,7 +696,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); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -654,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); } }; @@ -771,6 +826,9 @@ const make = Effect.gen(function* () { setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, + desktopViewport: null, + desktopViewportReads: 0, + desktopViewportApplied: 0, loading: false, closing: false, recording: null, @@ -785,6 +843,21 @@ const make = Effect.gen(function* () { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); + if (desktop) { + // The desktop resizes and zooms its webview itself; viewers map input with the new size. + cdp.on("Page.frameResized", () => { + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + }); + // Someone at the desktop can close a dialog in its own window, outside resolveDialog. + 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(); @@ -1343,7 +1416,7 @@ const make = Effect.gen(function* () { }; } const url = tab.page.url(); - const viewport = tab.page.viewportSize(); + const viewport = tab.desktop ? await measureDesktopViewport(tab) : tab.page.viewportSize(); const status = { available: true, visible: tab.viewers.size > 0, @@ -2158,7 +2231,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": { @@ -2229,6 +2302,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 () => { @@ -2251,20 +2326,39 @@ const make = Effect.gen(function* () { pressedKeys: new Map(), 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") { - const dropped = Effect.runSyncExit(Queue.clear(output)); - if (dropped._tag === "Failure") return; + if (next._tag === "frame") { + runFork(next.ack); + return; + } + if (next._tag === "pointer" || next._tag === "probe") return; + // Replace synchronously so concurrent producers cannot restore older state. + const dropped = Effect.runSyncExit(Queue.clear(output)); + if (dropped._tag === "Failure") return; + for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); + if (ended) { Queue.offerUnsafe(output, next); - - const chooser = next._tag === "control" ? 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); + return; } + for (const tag of ["control", "viewport"] as const) { + const retained = + next._tag === tag ? next : dropped.value.findLast((item) => item._tag === tag); + if (retained) Queue.offerUnsafe(output, retained); + } + const chooser = next._tag === "fileChooser" ? next : fileChooserMessage(tab); + if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + // Keep recent one-time notifications in order, including the incoming one. + const notifications = [...dropped.value, next].filter((item) => + ["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag), + ); + const room = VIEWER_OUTPUT_LIMIT - Effect.runSync(Queue.size(output)); + for (const item of notifications.slice(Math.max(0, notifications.length - room))) + Queue.offerUnsafe(output, item); }, pause: () => { screencastParams = screencastParams.then(() => @@ -2283,6 +2377,7 @@ const make = Effect.gen(function* () { }), () => Effect.promise(async () => { + ended = true; await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session)); tab.viewers.delete(viewer); broadcastControl(tab); @@ -2357,7 +2452,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.