Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, sessionId?: string) =>
sessionId === undefined
? sendPageCommand(webContents, debuggee, method, params)
: debuggee.sendCommand(method, params, sessionId);
sendPageCommand(webContents, debuggee, method, params, sessionId);
const detachedOwners = new Set<string>();
/** Viewers and recordings stream; the agent's own sessions never do. */
const streamingOwners = new Set<string>();
Expand Down
145 changes: 145 additions & 0 deletions apps/desktop/src/preview/GuestInput.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void>();
const finishCapture = Promise.withResolvers<void>();
const events: Array<unknown> = [];
const guest = {
id: 7,
focusedFrame: null,
hostWebContents: {
isDestroyed: () => false,
executeJavaScript: async () => {
captureStarted.resolve();
await finishCapture.promise;
},
},
};
const debuggee = {
sendCommand: async (_method: string, params: Record<string, unknown>) => {
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(
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/preview/GuestInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
sessionId: string | undefined,
) => {
const host = webContents.hostWebContents;
if (!host || host.isDestroyed() || webContents.focusedFrame) {
return debuggee.sendCommand(method, params, sessionId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`);
await host.executeJavaScript(`(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
})()`);
try {
return await debuggee.sendCommand(method, params, sessionId);
} finally {
if (!host.isDestroyed()) {
await host.executeJavaScript(`globalThis[${key}]?.()`);
}
}
};

const pointerCommands = new WeakMap<Electron.WebContents, Promise<unknown>>();

/** 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<string, unknown>,
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<string, unknown>,
sessionId?: string,
): Promise<unknown> => {
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();
Expand Down
Loading