diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 5a2b5463fc674..7bf1246f9a497 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -302,9 +302,7 @@ export const make = Effect.gen(function* () { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; const send = (method: string, params: Record, sessionId?: string) => - sessionId === undefined - ? sendPageCommand(webContents, debuggee, method, params) - : debuggee.sendCommand(method, params, sessionId); + sendPageCommand(webContents, debuggee, method, params, sessionId); const detachedOwners = new Set(); /** Viewers and recordings stream; the agent's own sessions never do. */ const streamingOwners = new Set(); diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index b531182c92959..579b564f59aa1 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -1,3 +1,5 @@ +import * as NodeVM from "node:vm"; + import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; @@ -42,6 +44,149 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); + it.each([ + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: "child-frame", + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchTouchEvent", + type: "touchStart", + outcome: "restore", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "other-field", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "removed", + }, + { + sessionId: undefined, + method: "Input.dispatchMouseEvent", + type: "mousePressed", + outcome: "failure", + }, + ])( + "preserves host focus for $method in $sessionId with $outcome", + async ({ sessionId, method, type, outcome }) => { + const document = { activeElement: {} as object }; + const composer = { + isConnected: true, + focus: () => { + document.activeElement = composer; + }, + }; + const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 }; + const otherField = { tagName: "INPUT" }; + document.activeElement = composer; + const context = NodeVM.createContext({ document }); + const host = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => NodeVM.runInContext(script, context), + }; + const guest = { id: 7, focusedFrame: null, hostWebContents: host }; + const debuggee = { + sendCommand: async () => { + document.activeElement = outcome === "other-field" ? otherField : preview; + if (outcome === "removed") composer.isConnected = false; + if (outcome === "failure") throw new Error("Pointer failed"); + return {}; + }, + }; + const result = sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type, x: 10, y: 10, button: "left" }, + sessionId, + ); + if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed"); + else await result; + expect(document.activeElement).toBe( + outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer, + ); + expect(Object.keys(context)).toEqual(["document"]); + }, + ); + + it.each([ + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: false, + }, + { + method: "Input.dispatchTouchEvent", + press: "touchStart", + release: "touchEnd", + failPress: false, + }, + { + method: "Input.dispatchMouseEvent", + press: "mousePressed", + release: "mouseReleased", + failPress: true, + }, + ])( + "keeps $method releases behind presses waiting for host focus, failed press: $failPress", + async ({ method, press, release, failPress }) => { + const captureStarted = Promise.withResolvers(); + const finishCapture = Promise.withResolvers(); + const events: Array = []; + const guest = { + id: 7, + focusedFrame: null, + hostWebContents: { + isDestroyed: () => false, + executeJavaScript: async () => { + captureStarted.resolve(); + await finishCapture.promise; + }, + }, + }; + const debuggee = { + sendCommand: async (_method: string, params: Record) => { + events.push(params["type"]); + if (failPress && params["type"] === press) throw new Error("Press failed"); + return {}; + }, + }; + const send = (type: string) => + sendPageCommand( + guest as unknown as Electron.WebContents, + debuggee as unknown as Electron.Debugger, + method, + { type }, + ); + const pressed = send(press); + await captureStarted.promise; + const released = send(release); + finishCapture.resolve(); + const results = await Promise.allSettled([pressed, released]); + expect(results.map((result) => result.status)).toEqual([ + failPress ? "rejected" : "fulfilled", + "fulfilled", + ]); + expect(events).toEqual([press, release]); + }, + ); + it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 42201b48ed61c..d3ccf29c76ed7 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -92,13 +92,78 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; +let focusSequence = 0; + +/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */ +const sendPointerPress = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const host = webContents.hostWebContents; + if (!host || host.isDestroyed() || webContents.focusedFrame) { + return debuggee.sendCommand(method, params, sessionId); + } + const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`); + await host.executeJavaScript(`(() => { + const active = document.activeElement; + if (!active || active === document.body || active.tagName === "WEBVIEW") return; + globalThis[${key}] = () => { + delete globalThis[${key}]; + const current = document.activeElement; + if (active.isConnected && current?.tagName === "WEBVIEW" && + current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true }); + }; + })()`); + try { + return await debuggee.sendCommand(method, params, sessionId); + } finally { + if (!host.isDestroyed()) { + await host.executeJavaScript(`globalThis[${key}]?.()`); + } + } +}; + +const pointerCommands = new WeakMap>(); + +/** Focus capture crosses processes; later pointer events must not overtake the press. */ +const sendPointerCommand = async ( + webContents: Electron.WebContents, + debuggee: Electron.Debugger, + method: string, + params: Record, + sessionId: string | undefined, +) => { + const previous = pointerCommands.get(webContents) ?? Promise.resolve(); + const current = previous + .catch(() => {}) + .then(() => + params["type"] === "mousePressed" || params["type"] === "touchStart" + ? sendPointerPress(webContents, debuggee, method, params, sessionId) + : debuggee.sendCommand(method, params, sessionId), + ); + pointerCommands.set(webContents, current); + try { + return await current; + } finally { + if (pointerCommands.get(webContents) === current) pointerCommands.delete(webContents); + } +}; + /** Sends one command to the guest's page, keeping reloads and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, method: string, params: Record, + sessionId?: string, ): Promise => { + if (method === "Input.dispatchMouseEvent" || method === "Input.dispatchTouchEvent") { + return sendPointerCommand(webContents, debuggee, method, params, sessionId); + } + if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId); // Electron's debugger routes Page.reload on a webview to its embedder window. if (method === "Page.reload") { if (params["ignoreCache"] === true) webContents.reloadIgnoringCache();