Skip to content

Commit 37d7615

Browse files
authored
fix(desktop): preserve host focus on preview pointer presses (#28549)
1 parent ce9e016 commit 37d7615

3 files changed

Lines changed: 211 additions & 3 deletions

File tree

‎apps/desktop/src/preview/DesktopBrowserHost.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,7 @@ export const make = Effect.gen(function* () {
302302
if (tab.relay) return tab.relay;
303303
const { webContents, debugger: debuggee } = tab.debuggee;
304304
const send = (method: string, params: Record<string, unknown>, sessionId?: string) =>
305-
sessionId === undefined
306-
? sendPageCommand(webContents, debuggee, method, params)
307-
: debuggee.sendCommand(method, params, sessionId);
305+
sendPageCommand(webContents, debuggee, method, params, sessionId);
308306
const detachedOwners = new Set<string>();
309307
/** Viewers and recordings stream; the agent's own sessions never do. */
310308
const streamingOwners = new Set<string>();

‎apps/desktop/src/preview/GuestInput.test.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as NodeVM from "node:vm";
2+
13
import { describe, expect, it, vi } from "vite-plus/test";
24

35
import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts";
@@ -42,6 +44,149 @@ describe("preview guest input", () => {
4244
expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything());
4345
});
4446

47+
it.each([
48+
{
49+
sessionId: undefined,
50+
method: "Input.dispatchMouseEvent",
51+
type: "mousePressed",
52+
outcome: "restore",
53+
},
54+
{
55+
sessionId: "child-frame",
56+
method: "Input.dispatchMouseEvent",
57+
type: "mousePressed",
58+
outcome: "restore",
59+
},
60+
{
61+
sessionId: undefined,
62+
method: "Input.dispatchTouchEvent",
63+
type: "touchStart",
64+
outcome: "restore",
65+
},
66+
{
67+
sessionId: undefined,
68+
method: "Input.dispatchMouseEvent",
69+
type: "mousePressed",
70+
outcome: "other-field",
71+
},
72+
{
73+
sessionId: undefined,
74+
method: "Input.dispatchMouseEvent",
75+
type: "mousePressed",
76+
outcome: "removed",
77+
},
78+
{
79+
sessionId: undefined,
80+
method: "Input.dispatchMouseEvent",
81+
type: "mousePressed",
82+
outcome: "failure",
83+
},
84+
])(
85+
"preserves host focus for $method in $sessionId with $outcome",
86+
async ({ sessionId, method, type, outcome }) => {
87+
const document = { activeElement: {} as object };
88+
const composer = {
89+
isConnected: true,
90+
focus: () => {
91+
document.activeElement = composer;
92+
},
93+
};
94+
const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 };
95+
const otherField = { tagName: "INPUT" };
96+
document.activeElement = composer;
97+
const context = NodeVM.createContext({ document });
98+
const host = {
99+
isDestroyed: () => false,
100+
executeJavaScript: async (script: string) => NodeVM.runInContext(script, context),
101+
};
102+
const guest = { id: 7, focusedFrame: null, hostWebContents: host };
103+
const debuggee = {
104+
sendCommand: async () => {
105+
document.activeElement = outcome === "other-field" ? otherField : preview;
106+
if (outcome === "removed") composer.isConnected = false;
107+
if (outcome === "failure") throw new Error("Pointer failed");
108+
return {};
109+
},
110+
};
111+
const result = sendPageCommand(
112+
guest as unknown as Electron.WebContents,
113+
debuggee as unknown as Electron.Debugger,
114+
method,
115+
{ type, x: 10, y: 10, button: "left" },
116+
sessionId,
117+
);
118+
if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed");
119+
else await result;
120+
expect(document.activeElement).toBe(
121+
outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer,
122+
);
123+
expect(Object.keys(context)).toEqual(["document"]);
124+
},
125+
);
126+
127+
it.each([
128+
{
129+
method: "Input.dispatchMouseEvent",
130+
press: "mousePressed",
131+
release: "mouseReleased",
132+
failPress: false,
133+
},
134+
{
135+
method: "Input.dispatchTouchEvent",
136+
press: "touchStart",
137+
release: "touchEnd",
138+
failPress: false,
139+
},
140+
{
141+
method: "Input.dispatchMouseEvent",
142+
press: "mousePressed",
143+
release: "mouseReleased",
144+
failPress: true,
145+
},
146+
])(
147+
"keeps $method releases behind presses waiting for host focus, failed press: $failPress",
148+
async ({ method, press, release, failPress }) => {
149+
const captureStarted = Promise.withResolvers<void>();
150+
const finishCapture = Promise.withResolvers<void>();
151+
const events: Array<unknown> = [];
152+
const guest = {
153+
id: 7,
154+
focusedFrame: null,
155+
hostWebContents: {
156+
isDestroyed: () => false,
157+
executeJavaScript: async () => {
158+
captureStarted.resolve();
159+
await finishCapture.promise;
160+
},
161+
},
162+
};
163+
const debuggee = {
164+
sendCommand: async (_method: string, params: Record<string, unknown>) => {
165+
events.push(params["type"]);
166+
if (failPress && params["type"] === press) throw new Error("Press failed");
167+
return {};
168+
},
169+
};
170+
const send = (type: string) =>
171+
sendPageCommand(
172+
guest as unknown as Electron.WebContents,
173+
debuggee as unknown as Electron.Debugger,
174+
method,
175+
{ type },
176+
);
177+
const pressed = send(press);
178+
await captureStarted.promise;
179+
const released = send(release);
180+
finishCapture.resolve();
181+
const results = await Promise.allSettled([pressed, released]);
182+
expect(results.map((result) => result.status)).toEqual([
183+
failPress ? "rejected" : "fulfilled",
184+
"fulfilled",
185+
]);
186+
expect(events).toEqual([press, release]);
187+
},
188+
);
189+
45190
it("refuses input when a cross-site frame holds the unfocused page's focus", async () => {
46191
const guest = makeGuest({ focused: false, focusedDocument: false });
47192
await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow(

‎apps/desktop/src/preview/GuestInput.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,78 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => {
9292
return reply.result?.value;
9393
};
9494

95+
let focusSequence = 0;
96+
97+
/** A CDP pointer press focuses the guest's native view even with focus emulation enabled. */
98+
const sendPointerPress = async (
99+
webContents: Electron.WebContents,
100+
debuggee: Electron.Debugger,
101+
method: string,
102+
params: Record<string, unknown>,
103+
sessionId: string | undefined,
104+
) => {
105+
const host = webContents.hostWebContents;
106+
if (!host || host.isDestroyed() || webContents.focusedFrame) {
107+
return debuggee.sendCommand(method, params, sessionId);
108+
}
109+
const key = JSON.stringify(`__t3PreviewFocus${++focusSequence}`);
110+
await host.executeJavaScript(`(() => {
111+
const active = document.activeElement;
112+
if (!active || active === document.body || active.tagName === "WEBVIEW") return;
113+
globalThis[${key}] = () => {
114+
delete globalThis[${key}];
115+
const current = document.activeElement;
116+
if (active.isConnected && current?.tagName === "WEBVIEW" &&
117+
current.getWebContentsId() === ${webContents.id}) active.focus({ preventScroll: true });
118+
};
119+
})()`);
120+
try {
121+
return await debuggee.sendCommand(method, params, sessionId);
122+
} finally {
123+
if (!host.isDestroyed()) {
124+
await host.executeJavaScript(`globalThis[${key}]?.()`);
125+
}
126+
}
127+
};
128+
129+
const pointerCommands = new WeakMap<Electron.WebContents, Promise<unknown>>();
130+
131+
/** Focus capture crosses processes; later pointer events must not overtake the press. */
132+
const sendPointerCommand = async (
133+
webContents: Electron.WebContents,
134+
debuggee: Electron.Debugger,
135+
method: string,
136+
params: Record<string, unknown>,
137+
sessionId: string | undefined,
138+
) => {
139+
const previous = pointerCommands.get(webContents) ?? Promise.resolve();
140+
const current = previous
141+
.catch(() => {})
142+
.then(() =>
143+
params["type"] === "mousePressed" || params["type"] === "touchStart"
144+
? sendPointerPress(webContents, debuggee, method, params, sessionId)
145+
: debuggee.sendCommand(method, params, sessionId),
146+
);
147+
pointerCommands.set(webContents, current);
148+
try {
149+
return await current;
150+
} finally {
151+
if (pointerCommands.get(webContents) === current) pointerCommands.delete(webContents);
152+
}
153+
};
154+
95155
/** Sends one command to the guest's page, keeping reloads and input inside it. */
96156
export const sendPageCommand = async (
97157
webContents: Electron.WebContents,
98158
debuggee: Electron.Debugger,
99159
method: string,
100160
params: Record<string, unknown>,
161+
sessionId?: string,
101162
): Promise<unknown> => {
163+
if (method === "Input.dispatchMouseEvent" || method === "Input.dispatchTouchEvent") {
164+
return sendPointerCommand(webContents, debuggee, method, params, sessionId);
165+
}
166+
if (sessionId !== undefined) return debuggee.sendCommand(method, params, sessionId);
102167
// Electron's debugger routes Page.reload on a webview to its embedder window.
103168
if (method === "Page.reload") {
104169
if (params["ignoreCache"] === true) webContents.reloadIgnoringCache();

0 commit comments

Comments
 (0)