fix(desktop): scope browser webviews by host

This commit is contained in:
Mohamed Boudra
2026-07-10 15:35:59 +02:00
parent 674233418a
commit b43ac770c2
6 changed files with 51 additions and 82 deletions

View File

@@ -17,9 +17,9 @@ It validates the compositor behavior that unit tests cannot see:
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that a page window handler gets first refusal, an unhandled
shortcut crosses once, digit wildcard shortcuts cross, and background automation
stays in the guest.
preload, then proves that a page window handler gets first refusal, unhandled
shortcuts cross from both ordinary and editable page targets, digit wildcard
shortcuts cross, and background automation stays in the guest.
Run it with the repo Electron:

View File

@@ -1847,8 +1847,25 @@ async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta,
pass("automation production guest preload forwards one page-unhandled browser shortcut");
checks.push({ group: "automation", check: "browser-shortcut-page-first-forward", pass: true });
automationBrowserShortcut(guest, "1");
const editableTarget = await guest.executeJavaScript(
"document.getElementById('name').focus(); document.activeElement.id",
true,
);
if (editableTarget !== "name") {
fail(`automation editable browser shortcut target was not focused: ${editableTarget}`);
}
automationBrowserShortcut(guest);
await waitForBrowserShortcutInput(shortcutInputs, 2);
if (!isDeepStrictEqual(shortcutInputs[1], expectedBrowserShortcutInput)) {
fail(
`editable browser shortcut crossed boundary incorrectly: inputs=${JSON.stringify(shortcutInputs)}`,
);
}
pass("automation production guest preload forwards an unhandled editable browser shortcut");
checks.push({ group: "automation", check: "browser-shortcut-editable-forward", pass: true });
automationBrowserShortcut(guest, "1");
await waitForBrowserShortcutInput(shortcutInputs, 3);
const expectedDigitShortcutInput = {
alt: false,
browserId,
@@ -1859,7 +1876,7 @@ async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta,
repeat: false,
shift: false,
};
if (!isDeepStrictEqual(shortcutInputs[1], expectedDigitShortcutInput)) {
if (!isDeepStrictEqual(shortcutInputs[2], expectedDigitShortcutInput)) {
fail(
`digit browser shortcut crossed boundary incorrectly: inputs=${JSON.stringify(shortcutInputs)}`,
);

View File

@@ -4,28 +4,26 @@ import { dispatchTrustedKey } from "./trusted-input.js";
describe("trusted browser input", () => {
test.each([
["a", "a"],
["Z", "Z"],
["Space", "Space"],
["ArrowDown", "Down"],
])("sends %s as Electron key code %s with unhandled redispatch disabled", (key, keyCode) => {
const events: IsolatedKeyboardInputEvent[] = [];
["a", "a", ["keyDown", "char", "keyUp"]],
["Z", "Z", ["keyDown", "char", "keyUp"]],
["Space", "Space", ["keyDown", "keyUp"]],
["ArrowDown", "Down", ["keyDown", "keyUp"]],
])(
"sends %s as Electron key code %s with unhandled redispatch disabled",
(key, keyCode, types) => {
const events: IsolatedKeyboardInputEvent[] = [];
dispatchTrustedKey((event) => {
events.push(event);
}, key);
dispatchTrustedKey((event) => {
events.push(event);
}, key);
expect(events).toEqual([
{
type: "keyDown",
keyCode,
skipIfUnhandled: true,
},
{
type: "keyUp",
keyCode,
skipIfUnhandled: true,
},
]);
});
expect(events).toEqual(
types.map((type) => ({
type,
keyCode,
skipIfUnhandled: true,
})),
);
},
);
});

View File

@@ -19,7 +19,7 @@ const MODIFIER_MASKS: Record<InputModifier, number> = {
};
export interface IsolatedKeyboardInputEvent extends KeyboardInputEvent {
type: "keyDown" | "keyUp";
type: "char" | "keyDown" | "keyUp";
// Electron accepts this NativeWebKeyboardEvent flag even though its public
// TypeScript declarations omit it. It stops an unhandled webview key from
// being redispatched to the embedder's active DOM element or application menu.
@@ -168,6 +168,13 @@ export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void
keyCode,
skipIfUnhandled: true,
});
if (key.length === 1) {
send({
type: "char",
keyCode,
skipIfUnhandled: true,
});
}
send({
type: "keyUp",
keyCode,

View File

@@ -44,31 +44,13 @@ function matchesCode(prefixCode: string, eventCode: string): boolean {
return /^(?:Digit|Numpad)[1-9]$/.test(eventCode);
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
if (
target.closest("[contenteditable=true], [contenteditable=''], [contenteditable=plaintext-only]")
) {
return true;
}
return target.matches("input, textarea, select, [role=textbox]");
}
function installKeydownListener(): void {
if (keydownListenerInstalled) {
return;
}
keydownListenerInstalled = true;
window.addEventListener("keydown", (event) => {
if (
!event.isTrusted ||
event.defaultPrevented ||
!browserId ||
isEditableTarget(event.target) ||
!matchesPolicy(event)
) {
if (!event.isTrusted || event.defaultPrevented || !browserId || !matchesPolicy(event)) {
return;
}
event.preventDefault();

View File

@@ -310,39 +310,4 @@ describe("PaseoBrowserWebviewRegistry", () => {
expect(registry.hasBrowserInOtherHostWindow(101, "browser-a")).toBe(false);
});
it("keeps the same-window active selection made before the guest attaches", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
});
it("drops a pre-attach selection when the guest attaches to another host window", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 202,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBeNull();
expect(registry.getActiveBrowserIdForHostWindow(202)).toBeNull();
});
});